From a4dcbe798e0a7ccd4d1204d709d7353126caf9ba Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:03:28 +0200 Subject: [PATCH 01/17] feat(api): adding instant preview endpoint --- apps/api/package.json | 9 +- apps/api/src/.env.test | 1 + apps/api/src/app/bridge/bridge.controller.ts | 3 +- .../preview-step/preview-step.command.ts | 4 + .../preview-step/preview-step.usecase.ts | 59 +-- .../app/environments-v1/novu-bridge-client.ts | 7 +- .../app/environments-v1/novu-bridge.module.ts | 15 + .../construct-framework-workflow.command.ts | 4 + .../construct-framework-workflow.usecase.ts | 72 ++-- .../chat-output-renderer.usecase.ts | 13 + .../email-output-renderer.usecase.ts | 44 +++ .../email-schema-expander.usecase.ts | 77 ++++ .../expend-email-editor-schema-command.ts | 9 + .../in-app-output-renderer-usecase.ts | 75 ++++ .../usecases/output-renderers/index.ts | 7 + .../push-output-renderer-usecase.ts | 13 + .../output-renderers/render-command.ts | 5 + .../sms-output-renderer-usecase.ts | 13 + .../src/app/events/e2e/bridge-trigger.e2e.ts | 8 +- .../send-test-email.usecase.ts | 5 +- apps/api/src/app/pipes/zod-validation-pipe.ts | 18 + .../src/app/shared/commands/base.command.ts | 5 +- .../commands/commandValidationException.ts | 7 + .../mappers/map-step-type-to-output.mapper.ts | 3 +- .../step-schemas/step-schemas.controller.ts | 6 +- .../app/step-schemas/step-schemas.module.ts | 4 +- .../get-step-schema.usecase.ts | 2 +- .../src/app/step-schemas/usecases/index.ts | 7 +- .../api/src/app/workflows-v2/clients/index.ts | 2 + .../workflows-v2/clients/novu-base-client.ts | 170 ++++++++ .../workflows-v2/clients/workflows-client.ts | 76 ++++ .../exceptions/step-not-found-exception.ts | 7 + ...ts => workflow-already-exist-exception.ts} | 0 .../workflow-not-found-exception.ts | 4 +- .../app/workflows-v2/generate-preview.e2e.ts | 295 ++++++++++++++ .../mappers/notification-template-mapper.ts | 6 +- .../params/get-list-query-params.ts | 5 - .../generate-preview-command.ts | 8 + .../generate-preview.usecase.ts | 252 ++++++++++++ .../extract-defaults-command.ts | 6 + .../extract-defaults.usecase.ts | 48 +++ .../get-workflow/get-workflow.usecase.ts | 1 - ...oad-based-on-hydration-strategy-command.ts | 7 + ...laceholders-from-tip-tap-schema.usecase.ts | 107 +++++ ...e-default-payload-for-email-editor.spec.ts | 149 +++++++ ...payload-preview-value-generator.usecase.ts | 61 +++ .../transform-placeholder.usecase.ts | 53 +++ .../upsert-workflow.usecase.ts | 12 +- .../workflows-v2/workflow.controller.e2e.ts | 105 +++-- .../app/workflows-v2/workflow.controller.ts | 32 +- .../src/app/workflows-v2/workflow.module.ts | 17 +- apps/api/src/config/env.validators.ts | 5 +- apps/api/src/exception-filter.ts | 33 +- .../workflow-editor/workflow-canvas.tsx | 2 +- .../src/commands/base.command.ts | 5 +- .../commands/commandValidationException.ts | 7 + .../application-generic/src/commands/index.ts | 1 + .../execute-bridge-request.command.ts | 6 +- packages/framework/src/client.ts | 2 +- packages/shared/src/dto/index.ts | 1 + .../control-preview-issue-type.enum.ts | 5 + .../src/dto/step-schemas/control-schemas.ts | 73 ++++ .../generate-preview-request.dto.ts | 18 + .../generate-preview-response.dto.ts | 86 +++++ packages/shared/src/dto/step-schemas/index.ts | 5 + .../src/dto/step-schemas/json-schema-dto.ts | 74 ++++ .../src/dto/workflows/create-workflow-dto.ts | 2 +- .../dto/workflows/get-list-query-params.ts | 2 +- packages/shared/src/dto/workflows/index.ts | 7 +- .../dto/workflows/workflow-commons-fields.ts | 2 + .../dto/workflows/workflow-response-dto.ts | 17 +- .../src/types/notification-templates/index.ts | 1 - pnpm-lock.yaml | 365 ++++++++++++++++++ 73 files changed, 2452 insertions(+), 185 deletions(-) create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/chat-output-renderer.usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer-usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/index.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer-usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer-usecase.ts create mode 100644 apps/api/src/app/pipes/zod-validation-pipe.ts create mode 100644 apps/api/src/app/shared/commands/commandValidationException.ts create mode 100644 apps/api/src/app/workflows-v2/clients/index.ts create mode 100644 apps/api/src/app/workflows-v2/clients/novu-base-client.ts create mode 100644 apps/api/src/app/workflows-v2/clients/workflows-client.ts create mode 100644 apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts rename apps/api/src/app/workflows-v2/exceptions/{workflow-already-exist.ts => workflow-already-exist-exception.ts} (100%) create mode 100644 apps/api/src/app/workflows-v2/generate-preview.e2e.ts delete mode 100644 apps/api/src/app/workflows-v2/params/get-list-query-params.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview-command.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts create mode 100644 libs/application-generic/src/commands/commandValidationException.ts create mode 100644 packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts create mode 100644 packages/shared/src/dto/step-schemas/control-schemas.ts create mode 100644 packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts create mode 100644 packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts create mode 100644 packages/shared/src/dto/step-schemas/index.ts create mode 100644 packages/shared/src/dto/step-schemas/json-schema-dto.ts diff --git a/apps/api/package.json b/apps/api/package.json index af1670a4535..cf6c4790fbb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -50,12 +50,14 @@ "@sendgrid/mail": "^8.1.0", "@sentry/browser": "^8.33.1", "@sentry/hub": "^7.114.0", - "@sentry/node": "^8.33.1", "@sentry/nestjs": "^8.33.1", + "@sentry/node": "^8.33.1", "@sentry/profiling-node": "^8.33.1", "@sentry/tracing": "^7.40.0", "@types/newrelic": "^9.14.0", "@upstash/ratelimit": "^0.4.4", + "zod-to-json-schema": "^3.23.3", + "@maily-to/render": "^0.0.12", "axios": "^1.6.8", "bcrypt": "^5.0.0", "body-parser": "^1.20.0", @@ -67,6 +69,7 @@ "date-fns": "^2.29.2", "dotenv": "^16.4.5", "envalid": "^8.0.0", + "faker": "^6.6.6", "handlebars": "^4.7.7", "helmet": "^6.0.1", "i18next": "^23.7.6", @@ -91,7 +94,8 @@ "shortid": "^2.2.16", "slugify": "^1.4.6", "swagger-ui-express": "^4.4.0", - "twilio": "^4.14.1", + "twilio": "^4.14.1", + "zod": "^3.23.8", "json-schema-to-ts": "^3.0.0", "uuid": "^8.3.2" }, @@ -105,6 +109,7 @@ "@types/bull": "^3.15.8", "@types/chai": "^4.2.11", "@types/express": "4.17.17", + "@types/faker": "^6.6.9", "@types/mocha": "^10.0.2", "@types/node": "^20.15.0", "@types/passport-github": "^1.1.5", diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index 2efb832184e..6156beb5ce7 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -114,3 +114,4 @@ CLERK_PRIVATE_KEY= CLERK_PEM_PUBLIC_KEY= TUNNEL_BASE_ADDRESS=example.com +API_ROOT_URL=http://localhost:1337 diff --git a/apps/api/src/app/bridge/bridge.controller.ts b/apps/api/src/app/bridge/bridge.controller.ts index 792b0cbd1e2..bc7e6b89b00 100644 --- a/apps/api/src/app/bridge/bridge.controller.ts +++ b/apps/api/src/app/bridge/bridge.controller.ts @@ -14,7 +14,7 @@ import { UseInterceptors, } from '@nestjs/common'; -import { ControlValuesLevelEnum, UserSessionData, WorkflowTypeEnum } from '@novu/shared'; +import { ControlValuesLevelEnum, UserSessionData, WorkflowOriginEnum, WorkflowTypeEnum } from '@novu/shared'; import { AnalyticsService, ExternalApiAccessible, UserAuthGuard, UserSession } from '@novu/application-generic'; import { ControlValuesRepository, EnvironmentRepository, NotificationTemplateRepository } from '@novu/dal'; @@ -75,6 +75,7 @@ export class BridgeController { environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, + workflowOrigin: WorkflowOriginEnum.EXTERNAL, }) ); } diff --git a/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts b/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts index e2e89bd2621..b3ef25d56b3 100644 --- a/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts +++ b/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts @@ -1,8 +1,12 @@ import { EnvironmentWithUserCommand } from '@novu/application-generic'; +import { Subscriber } from '@novu/framework'; +import { WorkflowOriginEnum } from '@novu/shared'; export class PreviewStepCommand extends EnvironmentWithUserCommand { workflowId: string; stepId: string; controls: Record; payload: Record; + subscriber?: Subscriber; + workflowOrigin: WorkflowOriginEnum; } diff --git a/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts b/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts index 96e546826a8..38ca07529c5 100644 --- a/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts +++ b/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { PostActionEnum, HttpQueryKeysEnum, Event, JobStatusEnum, ExecuteOutput } from '@novu/framework'; +import { Event, ExecuteOutput, HttpQueryKeysEnum, JobStatusEnum, PostActionEnum } from '@novu/framework'; import { ExecuteBridgeRequest, ExecuteBridgeRequestCommand } from '@novu/application-generic'; -import { WorkflowOriginEnum } from '@novu/shared'; import { PreviewStepCommand } from './preview-step.command'; @@ -10,44 +9,46 @@ export class PreviewStep { constructor(private executeBridgeRequest: ExecuteBridgeRequest) {} async execute(command: PreviewStepCommand): Promise { - const event = this.mapEvent(command); - - const response = (await this.executeBridgeRequest.execute( - ExecuteBridgeRequestCommand.create({ - environmentId: command.environmentId, - action: PostActionEnum.PREVIEW, - event, - searchParams: { - [HttpQueryKeysEnum.WORKFLOW_ID]: command.workflowId, - [HttpQueryKeysEnum.STEP_ID]: command.stepId, - }, - // TODO: pass the origin from the command - workflowOrigin: WorkflowOriginEnum.EXTERNAL, - retriesLimit: 1, - }) - )) as ExecuteOutput; + const event = this.buildBridgeEventPayload(command); + const executeCommand = this.createExecuteCommand(command, event); + + const bridgeResult = await this.executeBridgeRequest.execute(executeCommand); + + return bridgeResult as ExecuteOutput; + } - return response; + private createExecuteCommand(command: PreviewStepCommand, event: Event) { + return ExecuteBridgeRequestCommand.create({ + environmentId: command.environmentId, + action: PostActionEnum.PREVIEW, + event, + searchParams: { + [HttpQueryKeysEnum.WORKFLOW_ID]: command.workflowId, + [HttpQueryKeysEnum.STEP_ID]: command.stepId, + }, + workflowOrigin: command.workflowOrigin, + retriesLimit: 1, + }); } - private mapEvent(command: PreviewStepCommand): Omit { - const payload = { - /** @deprecated - use controls instead */ - inputs: command.controls || {}, + private buildBridgeEventPayload(command: PreviewStepCommand): Event { + return { + inputs: {}, // @deprecated - use controls instead controls: command.controls || {}, - /** @deprecated - use payload instead */ - data: command.payload || {}, + + data: {}, // @deprecated - use payload instead payload: command.payload || {}, state: [ { stepId: 'trigger', - outputs: command.payload || {}, + outputs: {}, state: { status: JobStatusEnum.COMPLETED }, }, ], - subscriber: {}, + subscriber: command.subscriber || {}, + stepId: command.stepId, + workflowId: command.workflowId, + action: PostActionEnum.PREVIEW, }; - - return payload; } } diff --git a/apps/api/src/app/environments-v1/novu-bridge-client.ts b/apps/api/src/app/environments-v1/novu-bridge-client.ts index d7a3614898f..90bdd96add1 100644 --- a/apps/api/src/app/environments-v1/novu-bridge-client.ts +++ b/apps/api/src/app/environments-v1/novu-bridge-client.ts @@ -1,7 +1,6 @@ -import { Injectable, Inject, Scope } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Request, Response } from 'express'; - -import { Client, PostActionEnum, NovuRequestHandler, Workflow } from '@novu/framework'; +import { Client, NovuRequestHandler, PostActionEnum, Workflow } from '@novu/framework'; // @ts-expect-error - TODO: bundle CJS with @novu/framework import { NovuHandler } from '@novu/framework/nest'; import { GetDecryptedSecretKey, GetDecryptedSecretKeyCommand } from '@novu/application-generic'; @@ -46,12 +45,12 @@ export class NovuBridgeClient { ConstructFrameworkWorkflowCommand.create({ environmentId: req.params.environmentId, workflowId: req.query.workflowId as string, + controlValues: req.body.controls, }) ); workflows.push(programmaticallyConstructedWorkflow); } - this.novuRequestHandler = new NovuRequestHandler({ frameworkName, workflows, diff --git a/apps/api/src/app/environments-v1/novu-bridge.module.ts b/apps/api/src/app/environments-v1/novu-bridge.module.ts index c361bb0b53a..b0eaf4ce6e5 100644 --- a/apps/api/src/app/environments-v1/novu-bridge.module.ts +++ b/apps/api/src/app/environments-v1/novu-bridge.module.ts @@ -7,6 +7,14 @@ import { GetDecryptedSecretKey } from '@novu/application-generic'; import { NovuBridgeClient } from './novu-bridge-client'; import { ConstructFrameworkWorkflow } from './usecases/construct-framework-workflow'; import { NovuBridgeController } from './novu-bridge.controller'; +import { + ChatOutputRendererUsecase, + EmailOutputRendererUsecase, + ExpandEmailEditorSchemaUsecase, + InAppOutputRendererUsecase, + PushOutputRendererUsecase, + SmsOutputRendererUsecase, +} from './usecases/output-renderers'; @Module({ controllers: [NovuBridgeController], @@ -20,6 +28,13 @@ import { NovuBridgeController } from './novu-bridge.controller'; NotificationTemplateRepository, ConstructFrameworkWorkflow, GetDecryptedSecretKey, + InAppOutputRendererUsecase, + EmailOutputRendererUsecase, + SmsOutputRendererUsecase, + ChatOutputRendererUsecase, + PushOutputRendererUsecase, + EmailOutputRendererUsecase, + ExpandEmailEditorSchemaUsecase, ], }) export class NovuBridgeModule {} diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts index ed72ff190fe..7e90ec97427 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts @@ -5,4 +5,8 @@ export class ConstructFrameworkWorkflowCommand extends EnvironmentLevelCommand { @IsString() @IsDefined() workflowId: string; + + @IsObject() + @IsDefined() + controlValues: Record; } 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 ca9619d64ae..55ba195e9a4 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,41 +2,45 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ActionStep, ChannelStep, - ChatOutput, DelayOutput, DigestOutput, - EmailOutput, - InAppOutput, - PushOutput, - SmsOutput, Step, StepOptions, StepOutput, Workflow, workflow, } from '@novu/framework'; -import { NotificationTemplateRepository, NotificationTemplateEntity, NotificationStepEntity } from '@novu/dal'; +import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { StepTypeEnum } from '@novu/shared'; import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflow.command'; +import { + ChatOutputRendererUsecase, + EmailOutputRendererUsecase, + InAppOutputRendererUsecase, + PushOutputRendererUsecase, + SmsOutputRendererUsecase, +} from '../output-renderers'; @Injectable() export class ConstructFrameworkWorkflow { - constructor(private workflowsRepository: NotificationTemplateRepository) {} + constructor( + private workflowsRepository: NotificationTemplateRepository, + private inAppOutputRendererUseCase: InAppOutputRendererUsecase, + private emailOutputRendererUseCase: EmailOutputRendererUsecase, + private smsOutputRendererUseCase: SmsOutputRendererUsecase, + private chatOutputRendererUseCase: ChatOutputRendererUsecase, + private pushOutputRendererUseCase: PushOutputRendererUsecase + ) {} async execute(command: ConstructFrameworkWorkflowCommand): Promise { const dbWorkflow = await this.getDbWorkflow(command.environmentId, command.workflowId); - - return this.constructFrameworkWorkflow(dbWorkflow); - } - - private async getDbWorkflow(environmentId: string, workflowId: string): Promise { - const foundWorkflow = await this.workflowsRepository.findByTriggerIdentifier(environmentId, workflowId); - - if (!foundWorkflow) { - throw new InternalServerErrorException(`Workflow ${workflowId} not found`); + if (command.controlValues) { + for (const step of dbWorkflow.steps) { + step.controlVariables = command.controlValues; + } } - return foundWorkflow; + return this.constructFrameworkWorkflow(dbWorkflow); } private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow { @@ -48,6 +52,8 @@ export class ConstructFrameworkWorkflow { } }, { + payloadSchema: PERMISSIVE_EMPTY_SCHEMA, + /* * TODO: Workflow options are not needed currently, given that this endpoint * focuses on execution only. However we should reconsider if we decide to @@ -69,11 +75,9 @@ export class ConstructFrameworkWorkflow { const stepType = stepTemplate.type; const { stepId } = staticStep; - if (!stepId) { throw new InternalServerErrorException(`Step id not found for step ${staticStep.stepId}`); } - const stepControls = stepTemplate.controls; if (!stepControls) { @@ -87,8 +91,7 @@ export class ConstructFrameworkWorkflow { stepId, // The step callback function. Takes controls and returns the step outputs async (controlValues) => { - // TODO: insert custom in-app hydration logic here. - return controlValues as InAppOutput; + return this.inAppOutputRendererUseCase.execute({ controlValues }); }, // Step options this.constructChannelStepOptions(staticStep) @@ -97,8 +100,7 @@ export class ConstructFrameworkWorkflow { return step.email( stepId, async (controlValues) => { - // TODO: insert custom Maily.to hydration logic here. - return controlValues as EmailOutput; + return this.emailOutputRendererUseCase.execute({ controlValues }); }, this.constructChannelStepOptions(staticStep) ); @@ -106,8 +108,7 @@ export class ConstructFrameworkWorkflow { return step.inApp( stepId, async (controlValues) => { - // TODO: insert custom SMS hydration logic here. - return controlValues as SmsOutput; + return this.smsOutputRendererUseCase.execute({ controlValues }); }, this.constructChannelStepOptions(staticStep) ); @@ -115,8 +116,7 @@ export class ConstructFrameworkWorkflow { return step.inApp( stepId, async (controlValues) => { - // TODO: insert custom chat hydration logic here. - return controlValues as ChatOutput; + return this.chatOutputRendererUseCase.execute({ controlValues }); }, this.constructChannelStepOptions(staticStep) ); @@ -124,8 +124,7 @@ export class ConstructFrameworkWorkflow { return step.inApp( stepId, async (controlValues) => { - // TODO: insert custom push hydration logic here. - return controlValues as PushOutput; + return this.pushOutputRendererUseCase.execute({ controlValues }); }, this.constructChannelStepOptions(staticStep) ); @@ -178,4 +177,19 @@ export class ConstructFrameworkWorkflow { skip: (controlValues) => false, }; } + private async getDbWorkflow(environmentId: string, workflowId: string): Promise { + const foundWorkflow = await this.workflowsRepository.findByTriggerIdentifier(environmentId, workflowId); + + if (!foundWorkflow) { + throw new InternalServerErrorException(`Workflow ${workflowId} not found`); + } + + return foundWorkflow; + } } +const PERMISSIVE_EMPTY_SCHEMA = { + type: 'object', + properties: {}, + required: [], + additionalProperties: true, +} as const; diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/chat-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/chat-output-renderer.usecase.ts new file mode 100644 index 00000000000..1cad2b13e25 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/chat-output-renderer.usecase.ts @@ -0,0 +1,13 @@ +// Concrete Renderer for Chat Preview +import { ChatRenderOutput } from '@novu/shared'; +import { Injectable } from '@nestjs/common'; +import { RenderCommand } from './render-command'; + +@Injectable() +export class ChatOutputRendererUsecase { + execute(renderCommand: RenderCommand): ChatRenderOutput { + const body = renderCommand.controlValues.body as string; + + return { body }; + } +} diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts new file mode 100644 index 00000000000..e2a3c7856ad --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts @@ -0,0 +1,44 @@ +import { EmailRenderOutput, TipTapNode } from '@novu/shared'; +import { render } from '@maily-to/render'; +import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; +import { RenderCommand } from './render-command'; +import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander-usecase'; + +@Injectable() +export class EmailOutputRendererUsecase { + constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {} + + async execute(renderCommand: RenderCommand): Promise { + const parse = EmailStepControlSchema.parse(renderCommand.controlValues); + const schema = parse.emailEditor as TipTapNode; + const expandedSchema = this.expendEmailEditorSchemaUseCase.execute({ schema }); + const html = await render(expandedSchema); + + return { subject: parse.subject, body: html }; + } +} +const emailContentSchema = z + .object({ + type: z.string(), + content: z.array(z.lazy(() => emailContentSchema)).optional(), + text: z.string().optional(), + attr: z.record(z.unknown()).optional(), + }) + .strict(); + +const emailEditorSchema = z + .object({ + type: z.string(), + content: z.array(emailContentSchema).optional(), + text: z.string().optional(), + attr: z.record(z.unknown()).optional(), + }) + .strict(); + +export const EmailStepControlSchema = z + .object({ + emailEditor: emailEditorSchema, + subject: z.string(), + }) + .strict(); diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts new file mode 100644 index 00000000000..7383deffce2 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts @@ -0,0 +1,77 @@ +/* eslint-disable no-param-reassign */ +import { TipTapNode } from '@novu/shared'; +import { ExpendEmailEditorSchemaCommand } from './expend-email-editor-schema-command'; + +// Rename the class to ExpendEmailEditorSchemaUseCase +export class ExpandEmailEditorSchemaUsecase { + execute(command: ExpendEmailEditorSchemaCommand): TipTapNode { + return this.expendSchema(command.schema); + } + + private expendSchema(schema: TipTapNode): TipTapNode { + // todo: try to avoid ! + const content = schema.content!.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[]; + + return { ...schema, content }; + } + + private processItemNode(node: TipTapNode, item: any): TipTapNode { + if (node.type === 'text' && typeof node.text === 'string') { + const regex = /{#item\.(\w+)#}/g; + node.text = node.text.replace(regex, (_, key: string) => { + const propertyName = key; + + return item[propertyName] !== undefined ? item[propertyName] : _; + }); + } + + if (node.content) { + node.content = node.content.map((innerNode) => this.processItemNode(innerNode, item)); + } + + return node; + } + + private processNodeRecursive(node: TipTapNode): TipTapNode | null { + if (node.type === 'show') { + const whenValue = node.attr?.when; + if (whenValue !== 'true') { + return null; + } + } + + if (this.hasEachAttr(node)) { + return { type: 'section', content: this.handleFor(node) }; + } + + return this.processNodeContent(node); + } + + private processNodeContent(node: TipTapNode): TipTapNode | null { + if (node.content) { + node.content = node.content.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[]; + } + + return node; + } + + private hasEachAttr(node: TipTapNode): node is TipTapNode & { attr: { each: any } } { + return node.attr !== undefined && node.attr.each !== undefined; + } + + private handleFor(node: TipTapNode & { attr: { each: any } }): TipTapNode[] { + const items = node.attr.each; + const newContent: TipTapNode[] = []; + + const itemsParsed = JSON.parse(items.replace(/'/g, '"')); + for (const item of itemsParsed) { + const newNode = { ...node }; + newNode.content = newNode.content?.map((innerNode) => this.processItemNode(innerNode, item)) || []; + if (newNode.content) { + newContent.push(...newNode.content); + } + } + + return newContent; + } +} diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts new file mode 100644 index 00000000000..5231f047132 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts @@ -0,0 +1,9 @@ +// Define the command interface + +import { BaseCommand } from '@novu/application-generic'; +import { TipTapNode } from '@novu/shared'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ExpendEmailEditorSchemaCommand extends BaseCommand { + schema: TipTapNode; +} 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 new file mode 100644 index 00000000000..79983731bef --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer-usecase.ts @@ -0,0 +1,75 @@ +// Concrete Renderer for In-App Message Preview +import { InAppRenderOutput, RedirectTargetEnum } from '@novu/shared'; +import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; +import { RenderCommand } from './render-command'; + +@Injectable() +export class InAppOutputRendererUsecase { + execute(renderCommand: RenderCommand): InAppRenderOutput { + const inApp = InAppRenderOutputSchema.parse(renderCommand.controlValues); + + return { + subject: inApp.subject, + body: inApp.body, + avatar: inApp.avatar, + primaryAction: inApp.primaryAction + ? { + label: inApp.primaryAction.label, + redirect: { + url: inApp.primaryAction.redirect.url, + target: inApp.primaryAction.redirect.target as RedirectTargetEnum, + }, + } + : undefined, + secondaryAction: inApp.secondaryAction + ? { + label: inApp.secondaryAction?.label, + redirect: { + url: inApp.secondaryAction?.redirect.url, + target: inApp.secondaryAction?.redirect.target as RedirectTargetEnum, + }, + } + : undefined, + redirect: inApp.redirect + ? { + url: inApp.redirect.url, + target: inApp.redirect.target as RedirectTargetEnum, + } + : undefined, + data: inApp.data as Record, + }; + } +} +const RedirectTargetEnumSchema = z.enum(['_self', '_blank', '_parent', '_top', '_unfencedTop']); + +const InAppRenderOutputSchema = z.object({ + subject: z.string().optional(), + body: z.string(), + avatar: z.string().optional(), + primaryAction: z + .object({ + label: z.string(), + redirect: z.object({ + url: z.string(), + target: RedirectTargetEnumSchema.optional(), // Optional target + }), + }) + .optional(), + secondaryAction: z + .object({ + label: z.string(), + redirect: z.object({ + url: z.string(), + target: RedirectTargetEnumSchema.optional(), // Optional target + }), + }) + .optional(), // Optional secondary action + data: z.record(z.unknown()).optional(), // Optional data + redirect: z + .object({ + url: z.string(), + target: RedirectTargetEnumSchema.optional(), // Optional target + }) + .optional(), +}); diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts new file mode 100644 index 00000000000..1f3f9a37d4f --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts @@ -0,0 +1,7 @@ +export * from './chat-output-renderer-usecase'; +export * from './email-output-renderer-usecase'; +export * from './render-command'; +export * from './push-output-renderer-usecase'; +export * from './sms-output-renderer-usecase'; +export * from './in-app-output-renderer-usecase'; +export * from './email-schema-expander-usecase'; diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer-usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer-usecase.ts new file mode 100644 index 00000000000..326fe07f4bf --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer-usecase.ts @@ -0,0 +1,13 @@ +import { PushRenderOutput } from '@novu/shared'; +import { Injectable } from '@nestjs/common'; +import { RenderCommand } from './render-command'; + +@Injectable() +export class PushOutputRendererUsecase { + execute(renderCommand: RenderCommand): PushRenderOutput { + const subject = renderCommand.controlValues.subject as string; + const body = renderCommand.controlValues.body as string; + + return { subject, body }; + } +} diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts new file mode 100644 index 00000000000..c6b569963d2 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts @@ -0,0 +1,5 @@ +import { BaseCommand } from '@novu/application-generic'; + +export class RenderCommand extends BaseCommand { + controlValues: Record; +} diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer-usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer-usecase.ts new file mode 100644 index 00000000000..47a34748d45 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer-usecase.ts @@ -0,0 +1,13 @@ +// Concrete Renderer for SMS Preview +import { SmsRenderOutput } from '@novu/shared'; +import { Injectable } from '@nestjs/common'; +import { RenderCommand } from './render-command'; + +@Injectable() +export class SmsOutputRendererUsecase { + execute(renderCommand: RenderCommand): SmsRenderOutput { + const body = renderCommand.controlValues.body as string; + + return { body }; + } +} diff --git a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts index a88266a8d1e..9fd00e571e0 100644 --- a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts +++ b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts @@ -20,7 +20,7 @@ import { WorkflowCreationSourceEnum, WorkflowResponseDto, } from '@novu/shared'; -import { workflow, channelStepSchemas } from '@novu/framework'; +import { workflow } from '@novu/framework'; import { DetailEnum } from '@novu/application-generic'; import { BridgeServer } from '../../../../e2e/bridge.server'; @@ -1493,9 +1493,6 @@ describe('Novu-Hosted Bridge Trigger', () => { { type: StepTypeEnum.IN_APP, name: 'Test Step 1', - controls: { - schema: channelStepSchemas.in_app.output, - }, controlValues: { body: 'Test Body', }, @@ -1503,9 +1500,6 @@ describe('Novu-Hosted Bridge Trigger', () => { { type: StepTypeEnum.IN_APP, name: 'Test Step 2', - controls: { - schema: channelStepSchemas.in_app.output, - }, controlValues: { body: 'Test Body', }, diff --git a/apps/api/src/app/events/usecases/send-test-email/send-test-email.usecase.ts b/apps/api/src/app/events/usecases/send-test-email/send-test-email.usecase.ts index 210a42bfdf9..2211809f47d 100644 --- a/apps/api/src/app/events/usecases/send-test-email/send-test-email.usecase.ts +++ b/apps/api/src/app/events/usecases/send-test-email/send-test-email.usecase.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { addBreadcrumb } from '@sentry/node'; -import { OrganizationRepository, IntegrationEntity } from '@novu/dal'; -import { ChannelTypeEnum, EmailProviderIdEnum, IEmailOptions } from '@novu/shared'; +import { IntegrationEntity, OrganizationRepository } from '@novu/dal'; +import { ChannelTypeEnum, EmailProviderIdEnum, IEmailOptions, WorkflowOriginEnum } from '@novu/shared'; import { AnalyticsService, @@ -102,6 +102,7 @@ export class SendTestEmail { environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, + workflowOrigin: WorkflowOriginEnum.EXTERNAL, }) ); diff --git a/apps/api/src/app/pipes/zod-validation-pipe.ts b/apps/api/src/app/pipes/zod-validation-pipe.ts new file mode 100644 index 00000000000..4d777876f64 --- /dev/null +++ b/apps/api/src/app/pipes/zod-validation-pipe.ts @@ -0,0 +1,18 @@ +import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; +import { ZodError, ZodSchema } from 'zod'; + +@Injectable() +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown, metadata: ArgumentMetadata) { + if (metadata.type === 'body') { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new ZodError(result.error.errors); + } + + return result.data; + } + } +} diff --git a/apps/api/src/app/shared/commands/base.command.ts b/apps/api/src/app/shared/commands/base.command.ts index 914867a7361..7371d99b2f3 100644 --- a/apps/api/src/app/shared/commands/base.command.ts +++ b/apps/api/src/app/shared/commands/base.command.ts @@ -2,7 +2,8 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { addBreadcrumb } from '@sentry/node'; -import { BadRequestException, flatten } from '@nestjs/common'; +import { flatten } from '@nestjs/common'; +import { CommandValidationException } from './commandValidationException'; export abstract class BaseCommand { static create(this: new (...args: any[]) => T, data: T): T { @@ -19,7 +20,7 @@ export abstract class BaseCommand { data: mappedErrors, }); - throw new BadRequestException(mappedErrors); + throw new CommandValidationException(mappedErrors); } return convertedObject; diff --git a/apps/api/src/app/shared/commands/commandValidationException.ts b/apps/api/src/app/shared/commands/commandValidationException.ts new file mode 100644 index 00000000000..625421c424a --- /dev/null +++ b/apps/api/src/app/shared/commands/commandValidationException.ts @@ -0,0 +1,7 @@ +import { BadRequestException } from '@nestjs/common'; + +export class CommandValidationException extends BadRequestException { + constructor(private mappedErrors: string[]) { + super({ message: 'Validation failed', errors: mappedErrors }); + } +} diff --git a/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts b/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts index 0c0c89d50fa..c59c9e44366 100644 --- a/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts +++ b/apps/api/src/app/step-schemas/shared/mappers/map-step-type-to-output.mapper.ts @@ -1,8 +1,9 @@ import { ActionStepEnum, actionStepSchemas, ChannelStepEnum, channelStepSchemas } from '@novu/framework'; +import { EmailStepControlSchema } from '@novu/shared'; export const mapStepTypeToOutput = { [ChannelStepEnum.SMS]: channelStepSchemas[ChannelStepEnum.SMS].output, - [ChannelStepEnum.EMAIL]: channelStepSchemas[ChannelStepEnum.EMAIL].output, + [ChannelStepEnum.EMAIL]: EmailStepControlSchema, [ChannelStepEnum.PUSH]: channelStepSchemas[ChannelStepEnum.PUSH].output, [ChannelStepEnum.CHAT]: channelStepSchemas[ChannelStepEnum.CHAT].output, [ChannelStepEnum.IN_APP]: channelStepSchemas[ChannelStepEnum.IN_APP].output, diff --git a/apps/api/src/app/step-schemas/step-schemas.controller.ts b/apps/api/src/app/step-schemas/step-schemas.controller.ts index a4de8873fc0..37629c47aa1 100644 --- a/apps/api/src/app/step-schemas/step-schemas.controller.ts +++ b/apps/api/src/app/step-schemas/step-schemas.controller.ts @@ -1,19 +1,19 @@ import { ClassSerializerInterceptor, Controller, Get, Query, UseInterceptors } from '@nestjs/common'; -import { UserSessionData } from '@novu/shared'; import { ExternalApiAccessible, UserSession } from '@novu/application-generic'; import { StepType } from '@novu/framework'; +import { UserSessionData } from '@novu/shared'; import { createGetStepSchemaCommand } from './usecases/get-step-schema/get-step-schema.command'; import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; -import { GetStepSchema } from './usecases/get-step-schema/get-step-schema.usecase'; +import { GetStepSchemaUseCase } from './usecases/get-step-schema/get-step-schema.usecase'; import { StepSchemaDto } from './dtos/step-schema.dto'; @Controller('/step-schemas') @UserAuthentication() @UseInterceptors(ClassSerializerInterceptor) export class StepSchemasController { - constructor(private getStepDefaultSchemaUsecase: GetStepSchema) {} + constructor(private getStepDefaultSchemaUsecase: GetStepSchemaUseCase) {} @Get() @ExternalApiAccessible() diff --git a/apps/api/src/app/step-schemas/step-schemas.module.ts b/apps/api/src/app/step-schemas/step-schemas.module.ts index c65d179853b..9f9f75d39c8 100644 --- a/apps/api/src/app/step-schemas/step-schemas.module.ts +++ b/apps/api/src/app/step-schemas/step-schemas.module.ts @@ -3,9 +3,11 @@ import { USE_CASES } from './usecases'; import { StepSchemasController } from './step-schemas.controller'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; +import { BridgeModule } from '../bridge'; +import { WorkflowModule } from '../workflows-v2/workflow.module'; @Module({ - imports: [forwardRef(() => AuthModule), SharedModule], + imports: [forwardRef(() => AuthModule), SharedModule, BridgeModule, WorkflowModule], controllers: [StepSchemasController], providers: [...USE_CASES], exports: [...USE_CASES], diff --git a/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts b/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts index 94d3f9a1320..1823deb108c 100644 --- a/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts +++ b/apps/api/src/app/step-schemas/usecases/get-step-schema/get-step-schema.usecase.ts @@ -13,7 +13,7 @@ import { StepSchemaDto } from '../../dtos/step-schema.dto'; import { mapStepTypeToOutput, mapStepTypeToResult } from '../../shared'; @Injectable() -export class GetStepSchema { +export class GetStepSchemaUseCase { constructor(private readonly notificationTemplateRepository: NotificationTemplateRepository) {} async execute(command: GetStepSchemaCommand): Promise { diff --git a/apps/api/src/app/step-schemas/usecases/index.ts b/apps/api/src/app/step-schemas/usecases/index.ts index 637d8146898..b4587716583 100644 --- a/apps/api/src/app/step-schemas/usecases/index.ts +++ b/apps/api/src/app/step-schemas/usecases/index.ts @@ -1,3 +1,6 @@ -import { GetStepSchema } from './get-step-schema/get-step-schema.usecase'; +import { GetPreferences } from '@novu/application-generic'; +import { GetStepSchemaUseCase } from './get-step-schema/get-step-schema.usecase'; +import { GetWorkflowUseCase } from '../../workflows-v2/usecases/get-workflow/get-workflow.usecase'; +import { GetWorkflowByIdsUseCase } from '../../workflows-v2/usecases/get-workflow-by-ids/get-workflow-by-ids.usecase'; -export const USE_CASES = [GetStepSchema]; +export const USE_CASES = [GetStepSchemaUseCase, GetWorkflowUseCase, GetPreferences, GetWorkflowByIdsUseCase]; diff --git a/apps/api/src/app/workflows-v2/clients/index.ts b/apps/api/src/app/workflows-v2/clients/index.ts new file mode 100644 index 00000000000..4785faa1419 --- /dev/null +++ b/apps/api/src/app/workflows-v2/clients/index.ts @@ -0,0 +1,2 @@ +export * from './novu-base-client'; +export * from './workflows-client'; diff --git a/apps/api/src/app/workflows-v2/clients/novu-base-client.ts b/apps/api/src/app/workflows-v2/clients/novu-base-client.ts new file mode 100644 index 00000000000..849b925da45 --- /dev/null +++ b/apps/api/src/app/workflows-v2/clients/novu-base-client.ts @@ -0,0 +1,170 @@ +// Base HttpError class with response field +export class HttpError extends Error { + constructor( + public responseText: string, + public status: number, + public response: Response // Add response field + ) { + super(`${status}: ${responseText}`); + this.name = this.constructor.name; + } + + toString(): string { + return `${this.name} (status: ${this.status}): ${this.responseText}`; + } +} + +// Specific error classes extending HttpError +export class NovuBadRequestError extends HttpError {} +export class NovuUnauthorizedError extends HttpError {} +export class NovuForbiddenError extends HttpError {} +export class NovuNotFoundError extends HttpError {} +export class NovuInternalServerError extends HttpError {} +export class NovuNotImplementedError extends HttpError {} +export class NovuBadGatewayError extends HttpError {} +export class NovuServiceUnavailableError extends HttpError {} +export class NovuGatewayTimeoutError extends HttpError {} +export class NovuRedirectError extends HttpError { + redirectUrl: string; + + constructor(responseText: string, status: number, redirectUrl: string, response: Response) { + super(responseText, status, response); // Pass response to the base class + this.redirectUrl = redirectUrl; + } +} + +// Map of status codes to specific error classes +const errorMap: Record HttpError> = { + 400: NovuBadRequestError, + 401: NovuUnauthorizedError, + 403: NovuForbiddenError, + 404: NovuNotFoundError, + 500: NovuInternalServerError, + 501: NovuNotImplementedError, + 502: NovuBadGatewayError, + 503: NovuServiceUnavailableError, + 504: NovuGatewayTimeoutError, +}; + +// Type for the fetch function +type FetchFunction = () => Promise; + +// Result class for handling success and failure +export class NovuRestResult { + public isSuccess: boolean; + public value?: T; + public error?: E; + + private constructor(isSuccess: boolean, value?: T, error?: E) { + this.isSuccess = isSuccess; + this.value = value; + this.error = error; + } + + static ok(value: T): NovuRestResult { + return new NovuRestResult(true, value); + } + + static fail(error: E): NovuRestResult { + return new NovuRestResult(false, undefined as never, error); + } + + public isSuccessResult(): this is { value: T; error: never } { + return this.isSuccess; + } +} + +// Functional version of NovuBaseClient +export const createNovuBaseClient = (baseUrl: string, headers: HeadersInit = {}) => { + const defaultHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + + const buildUrl = (endpoint: string): string => `${baseUrl}${endpoint}`; + + const safeFetch = async (url: string, fetchFunc: FetchFunction): Promise> => { + const response: Response = await fetchFunc(); + + if (response.ok) { + const jsonData = await response.json(); + + return NovuRestResult.ok(jsonData.data); + } + + if (response.status >= 300 && response.status < 400) { + const responseText = await getErrorResponse(response); + const redirectError = new NovuRedirectError( + responseText, + response.status, + response.headers.get('Location') || '', + response // Pass the response object + ); + + return NovuRestResult.fail(redirectError); + } + + const ErrorClass = errorMap[response.status] || HttpError; + const responseText = await getErrorResponse(response); + const errorResult = new ErrorClass(responseText, response.status, response); // Pass the response object + + return NovuRestResult.fail(errorResult); + }; + + async function getErrorResponse(response: Response): Promise { + // Try to parse the response as JSON + try { + const json = await response.json(); + + return JSON.stringify(json); // Return the JSON as a string + } catch { + // If JSON parsing fails, fallback to text response + return await response.text(); + } + } + + const safeGet = async (endpoint: string): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'GET', + headers: defaultHeaders, + }) + ); + }; + + const safePut = async (endpoint: string, data: object): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'PUT', + headers: defaultHeaders, + body: JSON.stringify(data), + }) + ); + }; + + const safePost = async (endpoint: string, data: object): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'POST', + headers: defaultHeaders, + body: JSON.stringify(data), + }) + ); + }; + + const safeDelete = async (endpoint: string): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'DELETE', + headers: defaultHeaders, + }) + ); + }; + + return { + safeGet, + safePut, + safePost, + safeDelete, + }; +}; diff --git a/apps/api/src/app/workflows-v2/clients/workflows-client.ts b/apps/api/src/app/workflows-v2/clients/workflows-client.ts new file mode 100644 index 00000000000..827a41db2e5 --- /dev/null +++ b/apps/api/src/app/workflows-v2/clients/workflows-client.ts @@ -0,0 +1,76 @@ +import { + CreateWorkflowDto, + GeneratePreviewRequestDto, + GeneratePreviewResponseDto, + GetListQueryParams, + ListWorkflowResponse, + UpdateWorkflowDto, + WorkflowResponseDto, +} from '@novu/shared'; +import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client'; + +// Define the WorkflowClient as a function that utilizes the base client +export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) => { + const baseClient = createNovuBaseClient(baseUrl, headers); + + const createWorkflow = async ( + createWorkflowDto: CreateWorkflowDto + ): Promise> => { + return await baseClient.safePost('/v2/workflows', createWorkflowDto); + }; + + const updateWorkflow = async ( + workflowId: string, + updateWorkflowDto: UpdateWorkflowDto + ): Promise> => { + return await baseClient.safePut(`/v2/workflows/${workflowId}`, updateWorkflowDto); + }; + + const getWorkflow = async (workflowId: string): Promise> => { + return await baseClient.safeGet(`/v2/workflows/${workflowId}`); + }; + + const deleteWorkflow = async (workflowId: string): Promise> => { + return await baseClient.safeDelete(`/v2/workflows/${workflowId}`); + }; + + const searchWorkflows = async ( + queryParams: GetListQueryParams + ): Promise> => { + const query = new URLSearchParams(); + query.append('offset', queryParams.offset?.toString() || '0'); + query.append('limit', queryParams.limit?.toString() || '50'); + if (queryParams.orderDirection) { + query.append('orderDirection', queryParams.orderDirection); + } + if (queryParams.orderByField) { + query.append('orderByField', queryParams.orderByField); + } + if (queryParams.query) { + query.append('query', queryParams.query); + } + + return await baseClient.safeGet(`/v2/workflows?${query.toString()}`); + }; + + const generatePreview = async ( + workflowId: string, + stepUuid: string, + generatePreviewDto: GeneratePreviewRequestDto + ): Promise> => { + return await baseClient.safePost( + `/v2/workflows/${workflowId}/step/${stepUuid}/preview`, + generatePreviewDto + ); + }; + + // Return the methods as an object + return { + generatePreview, + createWorkflow, + updateWorkflow, + getWorkflow, + deleteWorkflow, + searchWorkflows, + }; +}; diff --git a/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts b/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts new file mode 100644 index 00000000000..deefe38fda2 --- /dev/null +++ b/apps/api/src/app/workflows-v2/exceptions/step-not-found-exception.ts @@ -0,0 +1,7 @@ +import { NotFoundException } from '@nestjs/common'; + +export class StepNotFoundException extends NotFoundException { + constructor(stepUuid: string) { + super({ message: 'Step cannot be found using the UUID Supplied', stepUuid }); + } +} diff --git a/apps/api/src/app/workflows-v2/exceptions/workflow-already-exist.ts b/apps/api/src/app/workflows-v2/exceptions/workflow-already-exist-exception.ts similarity index 100% rename from apps/api/src/app/workflows-v2/exceptions/workflow-already-exist.ts rename to apps/api/src/app/workflows-v2/exceptions/workflow-already-exist-exception.ts diff --git a/apps/api/src/app/workflows-v2/exceptions/workflow-not-found-exception.ts b/apps/api/src/app/workflows-v2/exceptions/workflow-not-found-exception.ts index ad9389bff96..ccdad2c731b 100644 --- a/apps/api/src/app/workflows-v2/exceptions/workflow-not-found-exception.ts +++ b/apps/api/src/app/workflows-v2/exceptions/workflow-not-found-exception.ts @@ -1,6 +1,6 @@ -import { BadRequestException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; -export class WorkflowNotFoundException extends BadRequestException { +export class WorkflowNotFoundException extends NotFoundException { constructor(id: string) { super({ message: 'Workflow cannot be found', diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts new file mode 100644 index 00000000000..4428da63e95 --- /dev/null +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -0,0 +1,295 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { randomUUID } from 'node:crypto'; +import { after, beforeEach } from 'mocha'; +import { sleep } from '@nestjs/terminus/dist/utils'; +import { + ChannelTypeEnum, + EmailStepControlSchemaDto, + FeatureFlagsKeysEnum, + GeneratePreviewRequestDto, + GeneratePreviewResponseDto, + RedirectTargetEnum, + StepTypeEnum, + TipTapNode, +} from '@novu/shared'; +import { InAppOutput } from '@novu/framework'; +import { createWorkflowClient, HttpError, NovuRestResult } from './clients'; +import { buildCreateWorkflowDto } from './workflow.controller.e2e'; + +describe('Control Schema', () => { + let session: UserSession; + let workflowsClient: ReturnType; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + workflowsClient = createWorkflowClient(session.serverUrl, getHeaders()); + // @ts-ignore + process.env[FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED] = 'true'; + }); + after(async () => { + await sleep(1000); + }); + describe('Generate Preview', () => { + describe('Hydration testing', () => { + const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; + + channelTypes.forEach(({ type, description }) => { + it(`${type}:should match the body in the preview response`, async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(type); + const requestDto = buildDtoWithPayload(type); + const previewResponseDto = await generatePreview(workflowId, stepUuid, requestDto, description); + console.log('previewResponseDto', JSON.stringify(previewResponseDto)); + expect(previewResponseDto.result!.preview).to.exist; + const expectedRenderedResult = buildInAppControlValues(); + expectedRenderedResult.subject = buildInAppControlValues().subject!.replace( + PLACEHOLDER_SUBJECT_INAPP, + PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE + ); + expect(previewResponseDto.result!.preview).to.deep.equal(expectedRenderedResult); + }); + }); + }); + describe('Happy Path, no payload, expected same response as requested', () => { + const channelTypes = [ + { type: StepTypeEnum.IN_APP, description: 'InApp' }, + { type: StepTypeEnum.SMS, description: 'SMS' }, + { type: StepTypeEnum.PUSH, description: 'Push' }, + { type: StepTypeEnum.CHAT, description: 'Chat' }, + ]; + + channelTypes.forEach(({ type, description }) => { + it(`${type}:should match the body in the preview response`, async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(type); + const requestDto = buildDtoNoPayload(type); + const previewResponseDto = await generatePreview(workflowId, stepUuid, requestDto, description); + expect(previewResponseDto.result!.preview).to.exist; + expect(previewResponseDto.issues).to.exist; + console.log('previewResponseDto.issues', JSON.stringify(previewResponseDto.issues)); + + if (type !== StepTypeEnum.EMAIL) { + expect(previewResponseDto.result!.preview).to.deep.equal(stepTypeTo[type]); + } else { + assertEmail(previewResponseDto); + } + }); + }); + }); + describe('Missing Required ControlValues', () => { + const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; + + channelTypes.forEach(({ type, description }) => { + it(`${type}: should assign default values to missing elements`, async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(type); + const requestDto = buildDtoWithMissingControlValues(type); + const previewResponseDto = await generatePreview(workflowId, stepUuid, requestDto, description); + expect(previewResponseDto.result!.preview.body).to.exist; + expect(previewResponseDto.result!.preview.body).to.equal('PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING'); + const { issues } = previewResponseDto; + expect(issues).to.exist; + expect(issues.body).to.exist; + }); + }); + }); + }); + + function getHeaders(): HeadersInit { + return { + Authorization: session.token, // Fixed space + 'Novu-Environment-Id': session.environment._id, + }; + } + + async function generatePreview( + workflowId: string, + stepUuid: string, + dto: GeneratePreviewRequestDto, + description: string + ): Promise { + console.log('dto', JSON.stringify(dto, null, 2)); + const novuRestResult = await workflowsClient.generatePreview(workflowId, stepUuid, dto); + if (novuRestResult.isSuccessResult()) { + return novuRestResult.value; + } + throw await assertHttpError(description, novuRestResult); + } + + async function createWorkflowAndReturnId(type: StepTypeEnum) { + const createWorkflowDto = buildCreateWorkflowDto(`${type}:${randomUUID()}`); + createWorkflowDto.steps[0].type = type; + const workflowResult = await workflowsClient.createWorkflow(createWorkflowDto); + if (!workflowResult.isSuccessResult()) { + throw new Error(`Failed to create workflow ${JSON.stringify(workflowResult.error)}`); + } + + return { workflowId: workflowResult.value._id, stepUuid: workflowResult.value.steps[0].stepUuid }; + } +}); + +function buildDtoNoPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto { + return { + validationStrategies: [], + controlValues: stepTypeTo[stepTypeEnum], + }; +} +function buildDtoWithPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto { + return { + validationStrategies: [], + controlValues: stepTypeTo[stepTypeEnum], + payloadValues: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE }, + }; +} + +function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto { + const stepTypeToElement = stepTypeTo[stepTypeEnum]; + if (stepTypeEnum === StepTypeEnum.EMAIL) { + delete stepTypeToElement.subject; + } else { + delete stepTypeToElement.body; + } + + return { + validationStrategies: [], + controlValues: stepTypeToElement, + payloadValues: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE }, + }; +} + +const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; + +const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; +const PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE = 'this is the replacement text for the placeholder'; +function mailyJsonExample(): TipTapNode { + return { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '{{payload.intro}} Wow, this editor instance exports its content as JSON.', + }, + ], + }, + { + type: 'for', + attr: { + each: '{{payload.comment}}', + }, + content: [ + { + type: 'h1', + content: [ + { + type: 'text', + text: FOR_ITEM_VALUE_PLACEHOLDER, + }, + ], + }, + ], + }, + { + type: 'show', + attr: { + when: '{{payload.isPremiumPlan}}', + }, + content: [ + { + type: 'h1', + content: [ + { + type: 'text', + text: TEST_SHOW_VALUE, + }, + ], + }, + ], + }, + ], + }; +} +function buildEmailControlValuesPayload(): EmailStepControlSchemaDto { + return { + subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, + emailEditor: mailyJsonExample(), + }; +} +function buildInAppControlValues(): InAppOutput { + return { + subject: `Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, + body: 'Hello, World! {{payload.placeholder.body}}', + avatar: 'https://www.example.com/avatar.png', + primaryAction: { + label: 'Primary Action', + redirect: { + target: RedirectTargetEnum.BLANK, + url: 'https://www.example.com/primary-action', + }, + }, + secondaryAction: { + label: 'Secondary Action', + redirect: { + target: RedirectTargetEnum.BLANK, + url: 'https://www.example.com/secondary-action', + }, + }, + data: { + key: 'value', + }, + redirect: { + target: RedirectTargetEnum.BLANK, + url: 'https://www.example.com/redirect', + }, + }; +} + +function buildSmsControlValuesPayload() { + return { + body: 'Hello, World!', + }; +} + +function buildPushControlValuesPayload() { + return { + subject: 'Hello, World!', + body: 'Hello, World!', + }; +} + +function buildChatControlValuesPayload() { + return { + body: 'Hello, World!', + }; +} +const FOR_ITEM_VALUE_PLACEHOLDER = '{#item.body#}'; +const TEST_SHOW_VALUE = 'TEST_SHOW_VALUE'; +const stepTypeTo = { + [StepTypeEnum.SMS]: buildSmsControlValuesPayload(), + [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload() as unknown as Record, + [StepTypeEnum.PUSH]: buildPushControlValuesPayload(), + [StepTypeEnum.CHAT]: buildChatControlValuesPayload(), + [StepTypeEnum.IN_APP]: buildInAppControlValues(), +}; + +async function assertHttpError( + description: string, + novuRestResult: NovuRestResult +) { + if (novuRestResult.error) { + return new Error(`${description}: Failed to generate preview: ${novuRestResult.error.message}`); + } + + return new Error(`${description}: Failed to generate preview, bug in response error mapping `); +} + +function assertEmail(dto: GeneratePreviewResponseDto) { + if (dto.result!.type === ChannelTypeEnum.EMAIL) { + const preview = dto.result!.preview.body; + expect(preview).to.exist; + expect(preview).to.not.contain('{{payload.comment}}'); + expect(preview).to.contain(FOR_ITEM_VALUE_PLACEHOLDER); + expect(preview).to.contain(TEST_SHOW_VALUE); + } +} diff --git a/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts b/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts index 057b346ea75..d5f58359ba3 100644 --- a/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts +++ b/apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts @@ -13,7 +13,6 @@ import { } from '@novu/shared'; import { ControlValuesEntity, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; import { GetPreferencesResponseDto } from '@novu/application-generic'; -import { BadRequestException } from '@nestjs/common'; export function toResponseWorkflowDto( template: NotificationTemplateEntity, @@ -77,6 +76,7 @@ export function toWorkflowsMinifiedDtos(templates: NotificationTemplateEntity[]) function toStepResponseDto(step: NotificationStepEntity): StepResponseDto { return { name: step.name || 'Missing Name', + slug: step.stepId || 'Missing Name', stepUuid: step._templateId, stepId: step.stepId || 'Missing Step Id', type: step.template?.type || StepTypeEnum.EMAIL, @@ -88,9 +88,9 @@ function toStepResponseDto(step: NotificationStepEntity): StepResponseDto { function convertControls(step: NotificationStepEntity): ControlsSchema { if (step.template?.controls) { return { schema: step.template.controls.schema }; - } else { - throw new BadRequestException('Step controls must be defined.'); } + + return { schema: {} }; // This is not a usecase, it's only here to be backwards compatible with V1 Notification Entities } function buildStepTypeOverview(step: NotificationStepEntity): StepTypeEnum | undefined { diff --git a/apps/api/src/app/workflows-v2/params/get-list-query-params.ts b/apps/api/src/app/workflows-v2/params/get-list-query-params.ts deleted file mode 100644 index 53f395b9be8..00000000000 --- a/apps/api/src/app/workflows-v2/params/get-list-query-params.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LimitOffsetPaginationDto, WorkflowResponseDto } from '@novu/shared'; - -export class GetListQueryParams extends LimitOffsetPaginationDto { - query?: string; -} diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview-command.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview-command.ts new file mode 100644 index 00000000000..531d8814dfd --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview-command.ts @@ -0,0 +1,8 @@ +import { GeneratePreviewRequestDto } from '@novu/shared'; +import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; + +export class GeneratePreviewCommand extends EnvironmentWithUserObjectCommand { + workflowId: string; + stepUuid: string; + generatePreviewRequestDto: GeneratePreviewRequestDto; +} 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 new file mode 100644 index 00000000000..b73ea5b7009 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts @@ -0,0 +1,252 @@ +import { Injectable } from '@nestjs/common'; +import { + ChannelTypeEnum, + ControlPreviewIssue, + ControlPreviewIssueTypeEnum, + ControlsSchema, + GeneratePreviewRequestDto, + GeneratePreviewResponseDto, + JSONSchemaDto, + StepTypeEnum, + WorkflowOriginEnum, +} from '@novu/shared'; +import { merge } from 'lodash/fp'; +import { difference, isArray, isObject, reduce } from 'lodash'; +import { GeneratePreviewCommand } from './generate-preview-command'; +import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; +import { GetWorkflowUseCase } from '../get-workflow/get-workflow.usecase'; +import { CreateMockPayloadUseCase } from '../placeholder-enrichment/payload-preview-value-generator-usecase'; +import { StepNotFoundException } from '../../exceptions/step-not-found-exception'; +import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults-usecase'; + +@Injectable() +export class GeneratePreviewUsecase { + constructor( + private legacyPreviewStepUseCase: PreviewStep, + private getWorkflowUseCase: GetWorkflowUseCase, + private createMockPayloadUseCase: CreateMockPayloadUseCase, + private extractDefaultsUseCase: ExtractDefaultsUsecase + ) {} + + async execute(command: GeneratePreviewCommand): Promise { + const payloadHydrationInfo = this.payloadHydrationLogic(command); + const workflowInfo = await this.getWorkflowUserIdentifierFromWorkflowObject(command); + const controlValuesResult = this.addMissingValuesToControlValues(command, workflowInfo.stepControlSchema); + const executeOutput = await this.executePreviewUsecase( + workflowInfo.workflowId, + workflowInfo.stepId, + workflowInfo.origin, + payloadHydrationInfo.augmentedPayload, + controlValuesResult.augmentedControlValues, + command + ); + + return buildResponse( + controlValuesResult.issuesMissingValues, + payloadHydrationInfo.issues, + executeOutput, + workflowInfo.stepType + ); + } + + private addMissingValuesToControlValues(command: GeneratePreviewCommand, stepControlSchema: ControlsSchema) { + const defaultValues = this.extractDefaultsUseCase.execute({ + jsonSchemaDto: stepControlSchema.schema as JSONSchemaDto, + }); + + return { + augmentedControlValues: merge(defaultValues, command.generatePreviewRequestDto.controlValues), + issuesMissingValues: this.buildMissingControlValuesIssuesList(defaultValues, command), + }; + } + + private buildMissingControlValuesIssuesList(defaultValues: Record, command: GeneratePreviewCommand) { + const missingRequiredControlValues = this.findMissingKeys( + defaultValues, + command.generatePreviewRequestDto.controlValues || {} + ); + + return this.buildControlPreviewIssues(missingRequiredControlValues); + } + + private buildControlPreviewIssues(keys: string[]): Record { + const record: Record = {}; + + keys.forEach((key) => { + record[key] = [ + { + issueType: ControlPreviewIssueTypeEnum.MISSING_VALUE, + message: `Value is missing on a required control`, // Custom message for the issue + }, + ]; + }); + + return record; + } + private findMissingKeys(requiredRecord: Record, actualRecord: Record) { + const requiredKeys = this.collectKeys(requiredRecord); + const actualKeys = this.collectKeys(actualRecord); + + return difference(requiredKeys, actualKeys); + } + private collectKeys(obj, prefix = '') { + return reduce( + obj, + (result, value, key) => { + const newKey = prefix ? `${prefix}.${key}` : key; + if (isObject(value) && !isArray(value)) { + result.push(...this.collectKeys(value, newKey)); + } else { + // Otherwise, just add the key + result.push(newKey); + } + + return result; + }, + [] + ); + } + private async executePreviewUsecase( + workflowId: string, + stepId: string, + origin: WorkflowOriginEnum, + hydratedPayload: Record, + updatedControlValues: Record, + command: GeneratePreviewCommand + ) { + return await this.legacyPreviewStepUseCase.execute( + PreviewStepCommand.create({ + payload: hydratedPayload, + controls: updatedControlValues || {}, + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + stepId, + userId: command.user._id, + workflowId, + workflowOrigin: origin, + }) + ); + } + + private async getWorkflowUserIdentifierFromWorkflowObject(command: GeneratePreviewCommand) { + const workflowResponseDto = await this.getWorkflowUseCase.execute({ + identifierOrInternalId: command.workflowId, + user: command.user, + }); + const { workflowId, steps } = workflowResponseDto; + const step = steps.find((stepDto) => stepDto.stepUuid === command.stepUuid); + if (!step) { + throw new StepNotFoundException(command.stepUuid); + } + + return { + workflowId, + stepId: step.slug, + stepType: step.type, + stepControlSchema: step.controls, + origin: workflowResponseDto.origin, + }; + } + + private payloadHydrationLogic(command: GeneratePreviewCommand) { + const dto = command.generatePreviewRequestDto; + + let aggregatedDefaultValues = {}; + const aggregatedDefaultValuesForControl: Record> = {}; + for (const controlValueKey in dto.controlValues) { + if (dto.controlValues.hasOwnProperty(controlValueKey)) { + const defaultValuesForSingleControlValue = this.createMockPayloadUseCase.execute({ dto, controlValueKey }); + if (defaultValuesForSingleControlValue) { + aggregatedDefaultValuesForControl[controlValueKey] = defaultValuesForSingleControlValue; + } + aggregatedDefaultValues = merge(defaultValuesForSingleControlValue, aggregatedDefaultValues); + } + } + + return { + augmentedPayload: merge(aggregatedDefaultValues, dto.payloadValues), + issues: this.buildVariableMissingIssueRecord(aggregatedDefaultValuesForControl, aggregatedDefaultValues, dto), + }; + } + + private buildVariableMissingIssueRecord( + valueKeyToDefaultsMap: Record>, + aggregatedDefaultValues: Record, + dto: GeneratePreviewRequestDto + ) { + const defaultVariableToValueKeyMap = flattenJsonWithArrayValues(valueKeyToDefaultsMap); + const missingRequiredPayloadIssues = this.findMissingKeys(aggregatedDefaultValues, dto.payloadValues || {}); + + return this.buildPayloadIssues(missingRequiredPayloadIssues, defaultVariableToValueKeyMap); + } + private buildPayloadIssues( + missingVariables: string[], + variableToControlValueKeys: Record + ): Record { + const record: Record = {}; + + missingVariables.forEach((missingVariable) => { + variableToControlValueKeys[missingVariable].forEach((controlValueKey) => { + record[controlValueKey] = [ + { + issueType: ControlPreviewIssueTypeEnum.MISSING_VARIABLE_IN_PAYLOAD, // Set issueType to MISSING_VALUE + message: `Variable payload.${missingVariable} is missing in payload`, // Custom message for the issue + variableName: `payload.${missingVariable}`, + }, + ]; + }); + }); + + return record; + } +} + +function buildResponse( + missingValuesIssue: Record, + missingPayloadVariablesIssue: Record, + executionOutput, + stepType: StepTypeEnum +): GeneratePreviewResponseDto { + return { + issues: merge(missingValuesIssue, missingPayloadVariablesIssue), + result: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + preview: executionOutput.outputs as any, + type: stepType as unknown as ChannelTypeEnum, + }, + }; +} +function flattenJsonWithArrayValues(valueKeyToDefaultsMap: Record>) { + const flattened = {}; + Object.keys(valueKeyToDefaultsMap).forEach((controlValue) => { + const defaultPayloads = valueKeyToDefaultsMap[controlValue]; + const defaultPlaceholders = getDotNotationKeys(defaultPayloads); + defaultPlaceholders.forEach((defaultPlaceholder) => { + if (!flattened[defaultPlaceholder]) { + flattened[defaultPlaceholder] = []; + } + flattened[defaultPlaceholder].push(controlValue); + }); + }); + + return flattened; +} +type NestedRecord = Record; + +function getDotNotationKeys(input: NestedRecord, parentKey: string = '', keys: string[] = []): string[] { + for (const key in input) { + if (input.hasOwnProperty(key)) { + const newKey = parentKey ? `${parentKey}.${key}` : key; // Construct dot notation key + + if (typeof input[key] === 'object' && input[key] !== null && !Array.isArray(input[key])) { + // Recursively flatten the object and collect keys + getDotNotationKeys(input[key] as NestedRecord, newKey, keys); + } else { + // Push the dot notation key to the keys array + keys.push(newKey); + } + } + } + + return keys; +} diff --git a/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts b/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts new file mode 100644 index 00000000000..df89e0532d4 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts @@ -0,0 +1,6 @@ +import { BaseCommand } from '@novu/application-generic'; +import { JSONSchemaDto } from '@novu/shared'; + +export class ExtractDefaultsCommand extends BaseCommand { + jsonSchemaDto: JSONSchemaDto; +} diff --git a/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts b/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts new file mode 100644 index 00000000000..62db341c0c3 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts @@ -0,0 +1,48 @@ +import { JSONSchemaDto } from '@novu/shared'; +import { ExtractDefaultsCommand } from './extract-defaults-command'; + +export class ExtractDefaultsUsecase { + /** + * Executes the use case to extract default values from the JSON Schema. + * @param command - The command containing the JSON Schema DTO. + * @returns A nested JSON structure with field paths and their default values. + */ + execute(command: ExtractDefaultsCommand): Record { + const { jsonSchemaDto } = command; + + return this.extractDefaults(jsonSchemaDto); + } + + private extractDefaults(schema: JSONSchemaDto): Record { + const result: Record = {}; + + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + if (!isJSONSchemaDto(value)) { + continue; + } + const isRequired = schema.required ? schema.required.includes(key) : false; + if (!isRequired) { + continue; + } + + if (value.default !== undefined) { + result[key] = value.default; + } else { + result[key] = 'PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING'; + } + + const nestedDefaults = this.extractDefaults(value); + if (Object.keys(nestedDefaults).length > 0) { + result[key] = { ...result[key], ...nestedDefaults }; + } + } + } + + return result; + } +} + +function isJSONSchemaDto(schema: any): schema is JSONSchemaDto { + return schema && typeof schema === 'object' && 'type' in schema; +} diff --git a/apps/api/src/app/workflows-v2/usecases/get-workflow/get-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/get-workflow/get-workflow.usecase.ts index 5c68a3d9f3d..7d977481bb5 100644 --- a/apps/api/src/app/workflows-v2/usecases/get-workflow/get-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/get-workflow/get-workflow.usecase.ts @@ -8,7 +8,6 @@ import { } from '@novu/dal'; import { ControlValuesLevelEnum, WorkflowResponseDto } from '@novu/shared'; import { GetPreferences, GetPreferencesCommand } from '@novu/application-generic'; - import { GetWorkflowCommand } from './get-workflow.command'; import { toResponseWorkflowDto } from '../../mappers/notification-template-mapper'; import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts new file mode 100644 index 00000000000..7234a7fed19 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts @@ -0,0 +1,7 @@ +import { BaseCommand } from '@novu/application-generic'; +import { GeneratePreviewRequestDto } from '@novu/shared'; + +export class AddKeysToPayloadBasedOnHydrationStrategyCommand extends BaseCommand { + dto: GeneratePreviewRequestDto; + controlValueKey: string; +} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts new file mode 100644 index 00000000000..03b8d7ee7c6 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts @@ -0,0 +1,107 @@ +/* eslint-disable no-param-reassign,no-cond-assign */ +// Importing necessary types +import { TipTapNode } from '@novu/shared'; + +// Define the PlaceholderMap type +export type PlaceholderMap = { + for?: { + [key: string]: string[]; + }; + show?: { + [key: string]: any[]; + }; + regular?: { + [key: string]: any[]; + }; +}; + +// Define the command interface for parameters +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface CollectPlaceholdersCommand { + node: TipTapNode; +} + +// Create the main class with a UseCase suffix +export class CollectPlaceholdersFromTipTapSchemaUsecase { + /** + * The main entry point for executing the use case. + * + * @param {CollectPlaceholdersCommand} command - The command containing parameters. + * @returns {PlaceholderMap} An object mapping main placeholders to their nested placeholders. + */ + public execute(command: CollectPlaceholdersCommand): PlaceholderMap { + const placeholders: Required = { + for: {}, + show: {}, + regular: {}, + }; + + this.traverse(command.node, placeholders); + + return placeholders; + } + + private traverse(node: TipTapNode, placeholders: Required) { + if (node.type === 'for' && node.attr) { + this.handleForTraversal(node, placeholders); + } else if (node.type === 'show' && node.attr && node.attr.when) { + this.handleShowTraversal(node, placeholders); + } else if (node.type === 'text' && node.text) { + const regularPlaceholders = extractPlaceholders(node.text).filter((x) => !x.startsWith('item')); + for (const regularPlaceholder of regularPlaceholders) { + placeholders.regular[regularPlaceholder] = []; + } + } + + if (node.content) { + node.content.forEach((childNode) => this.traverse(childNode, placeholders)); + } + } + + private handleForTraversal(node: TipTapNode, placeholders: Required) { + if (node.type === 'show' && node.attr && typeof node.attr.each === 'string') { + const mainPlaceholder = extractPlaceholders(node.attr.each); + if (mainPlaceholder && mainPlaceholder.length === 1) { + if (!placeholders.for[mainPlaceholder[0]]) { + placeholders.for[mainPlaceholder[0]] = []; + } + + if (node.content) { + node.content.forEach((nestedNode) => { + if (nestedNode.content) { + nestedNode.content.forEach((childNode) => { + if (childNode.type === 'text' && childNode.text) { + const nestedPlaceholders = extractPlaceholders(childNode.text); + for (const nestedPlaceholder of nestedPlaceholders) { + placeholders.for[mainPlaceholder[0]].push(nestedPlaceholder); + } + } + }); + } + }); + } + } + } + } + + private handleShowTraversal(node: TipTapNode, placeholders: Required) { + if (node.type === 'show' && node.attr && typeof node.attr.when === 'string') { + const nestedPlaceholders = extractPlaceholders(node.attr.when); + placeholders.show[nestedPlaceholders[0]] = []; + } + } +} +export function extractPlaceholders(text: string): string[] { + const regex = /\{\{\{(.*?)\}\}\}|\{\{(.*?)\}\}|\{#(.*?)#\}/g; // todo: add support for nested placeholders + const matches: string[] = []; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + const placeholder = match[1] || match[2] || match[3]; + if (placeholder) { + matches.push(placeholder.trim()); + } + } + + return matches; +} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts new file mode 100644 index 00000000000..fde9bd18745 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts @@ -0,0 +1,149 @@ +import { expect } from 'chai'; +import { TipTapNode } from '@novu/shared'; +import { + CollectPlaceholdersFromTipTapSchemaUsecase, + PlaceholderMap, +} from './collect-placeholders-from-tip-tap-schema.usecase'; +import { TransformPlaceholderMapUseCase } from './transform-placeholder-usecase'; + +describe('default paylaod creator for email editor', () => { + it('should collect placeholders from multiple for nodes, show nodes, and regular placeholders', () => { + const node: TipTapNode = { + type: 'doc', + content: [ + { + type: 'for', + attr: { + each: '{{payload.comments}}', + }, + content: [ + { + type: 'h1', + content: [ + { + type: 'text', + text: '{{item.subject}}-{{item.body}}', + }, + ], + }, + ], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '{{payload.intro}} This is an introduction paragraph.', + }, + ], + }, + { + type: 'for', + attr: { + each: '{{payload.comment2}}', + }, + content: [ + { + type: 'h2', + content: [ + { + type: 'text', + text: '{{item.body2}}', + }, + ], + }, + ], + }, + { + type: 'show', + attr: { + when: '{{payload.isPremiumPlan}}', + }, + content: [], + }, + { + type: 'show', + attr: { + when: '{{payload.isBetaUser}}', + }, + content: [], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'This is a regular text without placeholders.', + }, + ], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: '{{payload.footer}} This is the footer text.', + }, + ], + }, + ], + }; + + const result = new CollectPlaceholdersFromTipTapSchemaUsecase().execute({ node }); + + expect(result).to.deep.equal({ + for: { + 'payload.comments': ['item.subject', 'item.body'], + 'payload.comment2': ['item.body2'], + }, + show: { + 'payload.isPremiumPlan': [], + 'payload.isBetaUser': [], + }, + regular: { + 'payload.intro': [], + 'payload.footer': [], + }, + }); + }); +}); + +describe('transformPlaceholderMap', () => { + it('should transform the PlaceholderMap into a nested JSON structure', () => { + const input: PlaceholderMap = { + for: { + 'payload.comments': ['item.field1', 'item.field2'], + }, + show: { + 'payload.isPremiumPlan': [], + 'payload.isBetaUser': [], + }, + regular: { + 'payload.intro': [], + 'payload.footer': [], + }, + }; + + const expectedOutput = { + payload: { + comments: [ + { + field1: '{{item.field1}}-1', + field2: '{{item.field2}}-1', + }, + { + field1: '{{item.field1}}-2', + field2: '{{item.field2}}-2', + }, + ], + isPremiumPlan: 'true', + isBetaUser: 'true', + intro: '{{payload.intro}}', + footer: '{{payload.footer}}', + }, + }; + + const output = new TransformPlaceholderMapUseCase().execute({ input }); + expect(output).to.deep.equal(expectedOutput); + }); +}); diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts new file mode 100644 index 00000000000..4f18a372695 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { TipTapNode } from '@novu/shared'; +import { TransformPlaceholderMapUseCase } from './transform-placeholder-usecase'; +import { + CollectPlaceholdersFromTipTapSchemaUsecase, + extractPlaceholders, +} from './collect-placeholders-from-tip-tap-schema.usecase'; +import { AddKeysToPayloadBasedOnHydrationStrategyCommand } from './add-keys-to-payload-based-on-hydration-strategy-command'; + +@Injectable() +export class CreateMockPayloadUseCase { + constructor( + private readonly collectPlaceholdersFromTipTapSchemaUsecase: CollectPlaceholdersFromTipTapSchemaUsecase, + private readonly transformPlaceholderMapUseCase: TransformPlaceholderMapUseCase + ) {} + + public execute(command: AddKeysToPayloadBasedOnHydrationStrategyCommand): Record { + const { dto, controlValueKey } = command; + + if (!dto.controlValues) { + return {}; + } + + const controlValue = dto.controlValues[controlValueKey]; + if (typeof controlValue === 'object') { + return this.buildPayloadForEmailEditor(controlValue); + } + + return this.buildPayloadForRegularText(controlValue); + } + + private buildPayloadForEmailEditor(controlValue: unknown): Record { + const collectPlaceholderMappings = this.collectPlaceholdersFromTipTapSchemaUsecase.execute({ + node: controlValue as TipTapNode, + }); + const transformPlaceholderMap = this.transformPlaceholderMapUseCase.execute({ input: collectPlaceholderMappings }); + + return transformPlaceholderMap.payload; + } + + private buildPayloadForRegularText(controlValue: unknown) { + const strings = extractPlaceholders(controlValue as string).filter( + (placeholder) => !placeholder.startsWith('subscriber') && !placeholder.startsWith('actor') + ); + + return this.transformPlaceholderMapUseCase.execute({ + input: { regular: convertToRecord(strings) }, + }).payload; + } +} + +function convertToRecord(keys: string[]): Record { + return keys.reduce( + (acc, key) => { + acc[key] = ''; // You can set the value to any default value you want + + return acc; + }, + {} as Record + ); +} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts new file mode 100644 index 00000000000..b6fa5f142a0 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts @@ -0,0 +1,53 @@ +import { PlaceholderMap } from './collect-placeholders-from-tip-tap-schema.usecase'; + +export class TransformPlaceholderMapCommand { + input: PlaceholderMap; +} + +export class TransformPlaceholderMapUseCase { + public execute(command: TransformPlaceholderMapCommand): Record { + const defaultPayload: Record = {}; + + const setNestedValue = (obj: Record, path: string, value: any) => { + const keys = path.split('.'); + let current = obj; + + keys.forEach((key, index) => { + if (!current[key]) { + current[key] = index === keys.length - 1 ? value : {}; + } + current = current[key]; + }); + }; + + this.processFor(command.input, setNestedValue, defaultPayload); + + for (const key in command.input.show) { + setNestedValue(defaultPayload, key, 'true'); + } + + for (const key in command.input.regular) { + setNestedValue(defaultPayload, key, `{{${key}}}`); + } + + return defaultPayload; + } + + private processFor( + input: PlaceholderMap, + setNestedValue: (obj: Record, path: string, value: any) => void, + defaultPayload: Record + ) { + for (const key in input.for) { + const items = input.for[key]; + const finalValue = [{}, {}]; + setNestedValue(defaultPayload, key, finalValue); + items.forEach((item) => { + const extractedKey = item.replace('item.', ''); + const valueFunc = (suffix) => `{#${item}#}-${suffix}`; + setNestedValue(finalValue[0], extractedKey, valueFunc('1')); + setNestedValue(finalValue[1], extractedKey, valueFunc('2')); + }); + } + } +} 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 e54d78baea6..2a2fb3a363f 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 @@ -14,13 +14,13 @@ import { GetPreferencesCommand, GetPreferencesResponseDto, NotificationStep, + slugifyName, UpdateWorkflow, UpdateWorkflowCommand, UpsertControlValuesCommand, UpsertControlValuesUseCase, UpsertPreferences, UpsertUserWorkflowPreferencesCommand, - slugifyName, } from '@novu/application-generic'; import { CreateWorkflowDto, @@ -37,6 +37,7 @@ import { StepUpsertMechanismFailedMissingIdException } from '../../exceptions/st import { toResponseWorkflowDto } from '../../mappers/notification-template-mapper'; import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase'; import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command'; +import { mapStepTypeToOutput } from '../../../step-schemas/shared'; function buildUpsertControlValuesCommand( command: UpsertWorkflowCommand, @@ -250,8 +251,8 @@ export class UpsertWorkflowUseCase { persistedWorkflow: NotificationTemplateEntity | undefined, step: StepDto | (StepDto & { stepUuid: string }) ): NotificationStep { - const stepEntityToReturn = this.buildBaseStepEntity(step); const foundPersistedStep = this.getPersistedStepIfFound(persistedWorkflow, step); + const stepEntityToReturn = this.buildBaseStepEntity(step, foundPersistedStep); if (foundPersistedStep) { return { ...stepEntityToReturn, @@ -264,12 +265,15 @@ export class UpsertWorkflowUseCase { return stepEntityToReturn; } - private buildBaseStepEntity(step: StepDto | (StepDto & { stepUuid: string })): NotificationStep { + private buildBaseStepEntity( + step: StepDto | StepUpdateDto, + foundPersistedStep?: NotificationStepEntity + ): NotificationStep { return { template: { type: step.type, name: step.name, - controls: step.controls, + controls: foundPersistedStep?.template?.controls || { schema: mapStepTypeToOutput[step.type] }, content: '', }, stepId: slugifyName(step.name), 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 cf3289e3fef..a29aee24803 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -1,5 +1,7 @@ import { expect } from 'chai'; import { UserSession } from '@novu/testing'; +import { randomBytes } from 'crypto'; +import { slugifyName } from '@novu/application-generic'; import { CreateWorkflowDto, DEFAULT_WORKFLOW_PREFERENCES, @@ -15,9 +17,7 @@ import { WorkflowListResponseDto, WorkflowResponseDto, } from '@novu/shared'; -import { randomBytes } from 'crypto'; -import { channelStepSchemas, JsonSchema } from '@novu/framework'; -import { slugifyName } from '@novu/application-generic'; +import { createWorkflowClient } from './clients'; const v2Prefix = '/v2'; const PARTIAL_UPDATED_NAME = 'Updated'; @@ -27,23 +27,22 @@ const TEST_WORKFLOW_NAME = 'Test Workflow Name'; const TEST_TAGS = ['test']; let session: UserSession; -const SCHEMA_WITH_TEXT: JsonSchema = { - type: 'object', - properties: { - text: { - type: 'string', - }, - }, - required: ['text'], -}; - describe('Workflow Controller E2E API Testing', () => { + let workflowsClient: ReturnType; + beforeEach(async () => { // @ts-ignore process.env.IS_WORKFLOW_PREFERENCES_ENABLED = 'true'; session = new UserSession(); await session.initialize(); + workflowsClient = createWorkflowClient(session.serverUrl, getHeaders()); }); + function getHeaders(): HeadersInit { + return { + Authorization: session.token, // Fixed space + 'Novu-Environment-Id': session.environment._id, + }; + } it('Smoke Testing', async () => { // @ts-ignore @@ -88,7 +87,9 @@ describe('Workflow Controller E2E API Testing', () => { const nameSuffix = `Test Workflow${new Date().toString()}`; const workflowCreated: WorkflowResponseDto = await createWorkflowAndValidate(nameSuffix); const updateDtoWithValues = buildUpdateDtoWithValues(workflowCreated); - await updateWorkflowAndValidate(workflowCreated._id, workflowCreated.updatedAt, updateDtoWithValues); + const updatedWorkflow: WorkflowResponseDto = await updateWorkflowRest(workflowCreated._id, updateDtoWithValues); + expect(updatedWorkflow.steps[0].controlValues.test).to.be.equal(updateDtoWithValues.steps[0].controlValues.test); + expect(updatedWorkflow.steps[1].controlValues.test).to.be.equal(updateDtoWithValues.steps[1].controlValues.test); }); it('should keep the step id on updated ', async () => { @@ -140,7 +141,7 @@ describe('Workflow Controller E2E API Testing', () => { it('should not return workflows if offset is bigger than the amount of available workflows', async () => { const uuid = generateUUID(); await create10Workflows(uuid); - const listWorkflowResponse = await getAllAndValidate({ + await getAllAndValidate({ searchQuery: uuid, offset: 11, limit: 15, @@ -152,7 +153,7 @@ describe('Workflow Controller E2E API Testing', () => { const uuid = generateUUID(); await create10Workflows(uuid); - const listWorkflowResponse = await getAllAndValidate({ + await getAllAndValidate({ searchQuery: uuid, offset: 0, limit: 15, @@ -164,7 +165,7 @@ describe('Workflow Controller E2E API Testing', () => { it('should return results without query', async () => { const uuid = generateUUID(); await create10Workflows(uuid); - const listWorkflowResponse = await getAllAndValidate({ + await getAllAndValidate({ searchQuery: uuid, offset: 0, limit: 15, @@ -194,6 +195,17 @@ describe('Workflow Controller E2E API Testing', () => { expect(idsDeduplicated.size).to.be.equal(10); }); }); + describe('Get Workflow Permutations', () => { + it('should return 404 if workflow does not exist', async () => { + const notExistingId = '123'; + const novuRestResult = await workflowsClient.getWorkflow(notExistingId); + expect(novuRestResult.isSuccess).to.be.false; + expect(novuRestResult.error).to.be.ok; + expect(novuRestResult.error!.status).to.equal(404); + expect(novuRestResult.error!.responseText).to.contain('Workflow'); + expect(JSON.parse(novuRestResult.error!.responseText).workflowId).to.contain(notExistingId); + }); + }); }); function buildErrorMsg(createWorkflowDto: Omit, createdWorkflowWithoutUpdateDate) { @@ -216,6 +228,11 @@ async function createWorkflowAndValidate(nameSuffix: string = ''): Promise - removeFields(step, 'stepUuid', 'stepId') + removeFields(step, 'stepUuid', 'slug', 'controls','stepId') ); expect(createdWorkflowWithoutUpdateDate).to.deep.equal( removeFields(createWorkflowDto, '__source'), @@ -240,9 +257,6 @@ async function createWorkflowAndValidate(nameSuffix: string = ''): Promise = {}): CreateWorkflowDto { +export function buildCreateWorkflowDto( + nameSuffix: string, + overrides: Partial = {} +): CreateWorkflowDto { return { __source: WorkflowCreationSourceEnum.EDITOR, name: TEST_WORKFLOW_NAME + nameSuffix, @@ -286,13 +300,10 @@ function isStepUpdateDto(obj: StepDto): obj is StepUpdateDto { return typeof obj === 'object' && obj !== null && 'stepUuid' in obj; } -function buildStepWithoutUUid(stepInResponse: StepDto & { stepUuid: string }) { +function buildStepWithoutUUid(stepInResponse: StepResponseDto) { if (!stepInResponse.controls) { return { controlValues: stepInResponse.controlValues, - controls: { - schema: channelStepSchemas[stepInResponse.type].output, - }, name: stepInResponse.name, type: stepInResponse.type, }; @@ -340,18 +351,24 @@ function validateUpdatedWorkflowAndRemoveResponseFields( 'status', 'type' ); - const augmentedStep: (StepUpdateDto | StepCreateDto)[] = []; + const augmentedSteps: (StepUpdateDto | StepCreateDto)[] = []; + for (const stepInResponse of workflowResponse.steps) { + const responseStep = removeFields(stepInResponse, 'controls'); expect(stepInResponse.stepUuid).to.be.ok; - const { stepUuid } = stepInResponse; + + const { stepUuid } = responseStep; const stepOnRequestBasedOnId = findStepOnRequestBasedOnId(workflowUpdateRequest, stepUuid); + let augmentedStep: StepUpdateDto | StepCreateDto; if (!stepOnRequestBasedOnId) { - augmentedStep.push(buildStepWithoutUUid(stepInResponse)); + augmentedStep = buildStepWithoutUUid(responseStep); } else { - augmentedStep.push({ ...stepInResponse }); + augmentedStep = { ...responseStep }; } + augmentedSteps.push(augmentedStep); } - updatedWorkflowWoUpdated.steps = [...augmentedStep]; + + updatedWorkflowWoUpdated.steps = [...augmentedSteps]; return updatedWorkflowWoUpdated; } @@ -421,7 +438,7 @@ async function getWorkflowRest( } async function validateWorkflowDeleted(workflowId: string): Promise { - await session.testAgent.get(`${v2Prefix}/workflows/${workflowId}`).expect(400); + await session.testAgent.get(`${v2Prefix}/workflows/${workflowId}`).expect(404); } async function getWorkflowAndValidate(workflowCreated: WorkflowResponseDto) { @@ -559,9 +576,6 @@ async function safeGet(url: string): Promise { async function safePut(url: string, data: object): Promise { return (await safeRest(url, () => session.testAgent.put(url).send(data) as unknown as Promise)) as T; } -async function safePost(url: string, data: object): Promise { - return (await safeRest(url, () => session.testAgent.post(url).send(data) as unknown as Promise)) as T; -} async function safeDelete(url: string): Promise { await safeRest(url, () => session.testAgent.delete(url) as unknown as Promise, 204); } @@ -588,7 +602,17 @@ function buildInAppStepWithValues() { } function convertResponseToUpdateDto(workflowCreated: WorkflowResponseDto): UpdateWorkflowDto { - return removeFields(workflowCreated, 'updatedAt', '_id', 'origin', 'type', 'status') as UpdateWorkflowDto; + const steps = workflowCreated.steps.map((step) => removeFields(step, 'slug')) as StepUpdateDto[]; + const workflowResponseDto = removeFields( + workflowCreated, + 'updatedAt', + '_id', + 'origin', + 'type', + 'status' + ) as UpdateWorkflowDto; + + return { ...workflowResponseDto, steps }; } function buildUpdateDtoWithValues(workflowCreated: WorkflowResponseDto): UpdateWorkflowDto { @@ -607,9 +631,6 @@ function createStep(): StepCreateDto { return { name: 'someStep', type: StepTypeEnum.SMS, - controls: { - schema: SCHEMA_WITH_TEXT, - }, controlValues: { text: '{SOME_TEXT_VARIABLE}', }, diff --git a/apps/api/src/app/workflows-v2/workflow.controller.ts b/apps/api/src/app/workflows-v2/workflow.controller.ts index a8c7e3e1671..56f6e068e5d 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.ts @@ -1,29 +1,30 @@ +import { ApiTags } from '@nestjs/swagger'; +import { ExternalApiAccessible, UserAuthGuard, UserSession } from '@novu/application-generic'; import { Body, - ClassSerializerInterceptor, Controller, Delete, Get, HttpCode, - HttpStatus, Param, Post, Put, Query, UseGuards, UseInterceptors, -} from '@nestjs/common'; - -import { ApiTags } from '@nestjs/swagger'; +} from '@nestjs/common/decorators'; +import { ClassSerializerInterceptor, HttpStatus } from '@nestjs/common'; import { CreateWorkflowDto, DirectionEnum, + GeneratePreviewRequestDto, + GeneratePreviewResponseDto, + GetListQueryParams, ListWorkflowResponse, UpdateWorkflowDto, UserSessionData, WorkflowResponseDto, } from '@novu/shared'; -import { ExternalApiAccessible, UserAuthGuard, UserSession } from '@novu/application-generic'; import { ApiCommonResponses } from '../shared/framework/response.decorator'; import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; import { GetWorkflowCommand } from './usecases/get-workflow/get-workflow.command'; @@ -34,7 +35,8 @@ import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.us import { ListWorkflowsCommand } from './usecases/list-workflows/list-workflows.command'; import { DeleteWorkflowUseCase } from './usecases/delete-workflow/delete-workflow.usecase'; import { DeleteWorkflowCommand } from './usecases/delete-workflow/delete-workflow.command'; -import { GetListQueryParams } from './params/get-list-query-params'; +import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview-usecase'; +import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview-command'; @ApiCommonResponses() @Controller({ path: `/workflows`, version: '2' }) @@ -46,7 +48,8 @@ export class WorkflowController { private upsertWorkflowUseCase: UpsertWorkflowUseCase, private getWorkflowUseCase: GetWorkflowUseCase, private listWorkflowsUseCase: ListWorkflowsUseCase, - private deleteWorkflowUsecase: DeleteWorkflowUseCase + private deleteWorkflowUsecase: DeleteWorkflowUseCase, + private generatePreviewUseCase: GeneratePreviewUsecase ) {} @Post('') @@ -114,4 +117,17 @@ export class WorkflowController { }) ); } + + @Post('/:workflowId/step/:stepUuid/preview') + @UseGuards(UserAuthGuard) + async generatePreview( + @UserSession() user: UserSessionData, + @Param('workflowId') workflowId: string, + @Param('stepUuid') stepUuid: string, + @Body() generatePreviewRequestDto: GeneratePreviewRequestDto + ): Promise { + return await this.generatePreviewUseCase.execute( + GeneratePreviewCommand.create({ user, workflowId, stepUuid, generatePreviewRequestDto }) + ); + } } diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index b24e1c7d1b5..4b32390b92d 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -17,9 +17,16 @@ import { GetWorkflowUseCase } from './usecases/get-workflow/get-workflow.usecase import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.usecase'; import { DeleteWorkflowUseCase } from './usecases/delete-workflow/delete-workflow.usecase'; import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-workflow-by-ids.usecase'; +import { GetStepSchemaUseCase } from '../step-schemas/usecases/get-step-schema/get-step-schema.usecase'; +import { BridgeModule } from '../bridge'; +import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview-usecase'; +import { CreateMockPayloadUseCase } from './usecases/placeholder-enrichment/payload-preview-value-generator-usecase'; +import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schema/extract-defaults-usecase'; +import { CollectPlaceholdersFromTipTapSchemaUsecase } from './usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase'; +import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichment/transform-placeholder-usecase'; @Module({ - imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, IntegrationModule], + imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule], controllers: [WorkflowController], providers: [ CreateWorkflow, @@ -32,6 +39,14 @@ import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-work UpsertControlValuesUseCase, GetPreferences, GetWorkflowByIdsUseCase, + GetStepSchemaUseCase, + GeneratePreviewUsecase, + GetWorkflowUseCase, + GetPreferences, + CreateMockPayloadUseCase, + ExtractDefaultsUsecase, + CollectPlaceholdersFromTipTapSchemaUsecase, + TransformPlaceholderMapUseCase, ], }) export class WorkflowModule implements NestModule { diff --git a/apps/api/src/config/env.validators.ts b/apps/api/src/config/env.validators.ts index c75a787cd8e..8145ecbc97e 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -1,4 +1,4 @@ -import { json, port, str, num, url, ValidatorSpec, bool, cleanEnv, CleanedEnv } from 'envalid'; +import { bool, CleanedEnv, cleanEnv, json, num, port, str, url, ValidatorSpec } from 'envalid'; import { DEFAULT_MESSAGE_GENERIC_RETENTION_DAYS, DEFAULT_MESSAGE_IN_APP_RETENTION_DAYS, @@ -48,11 +48,10 @@ export const envValidators = { MESSAGE_GENERIC_RETENTION_DAYS: num({ default: DEFAULT_MESSAGE_GENERIC_RETENTION_DAYS }), MESSAGE_IN_APP_RETENTION_DAYS: num({ default: DEFAULT_MESSAGE_IN_APP_RETENTION_DAYS }), LEGACY_STAGING_DASHBOARD_URL: url({ default: undefined }), - API_ROOT_URL: url({ default: undefined }), + API_ROOT_URL: url(), NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER: str({ default: undefined }), HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID: str({ default: undefined }), HUBSPOT_PRIVATE_APP_ACCESS_TOKEN: str({ default: undefined }), - // Feature Flags ...Object.keys(FeatureFlagsKeysEnum).reduce( (acc, key) => { diff --git a/apps/api/src/exception-filter.ts b/apps/api/src/exception-filter.ts index 265acb7d4d9..c5ece85e5ff 100644 --- a/apps/api/src/exception-filter.ts +++ b/apps/api/src/exception-filter.ts @@ -1,8 +1,9 @@ import { ArgumentsHost, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; import { Response } from 'express'; -import { PinoLogger } from '@novu/application-generic'; +import { CommandValidationException, PinoLogger } from '@novu/application-generic'; import { randomUUID } from 'node:crypto'; import { captureException } from '@sentry/node'; +import { ZodError } from 'zod'; export class AllExceptionsFilter implements ExceptionFilter { constructor(private readonly logger: PinoLogger) {} @@ -71,6 +72,14 @@ export class AllExceptionsFilter implements ExceptionFilter { private getResponseMetadata(exception: unknown): { status: number; message: string | object | Object } { let status: number; let message: string | object; + + if (exception instanceof ZodError) { + return handleZod(exception); + } + if (exception instanceof CommandValidationException) { + return handleCommandValidation(exception); + } + if (exception instanceof HttpException) { status = exception.getStatus(); message = exception.getResponse(); @@ -83,6 +92,7 @@ export class AllExceptionsFilter implements ExceptionFilter { message: `Internal server error, contact support and provide them with the errorId`, }; } + private getUuid(exception: unknown) { if (process.env.SENTRY_DSN) { try { @@ -101,7 +111,6 @@ export class AllExceptionsFilter implements ExceptionFilter { */ export class ErrorDto { statusCode: number; - timestamp: string; /** @@ -110,6 +119,24 @@ export class ErrorDto { errorId?: string; path: string; - message: string | object; } + +function handleZod(exception: ZodError) { + const status = HttpStatus.BAD_REQUEST; // Set appropriate status for ZodError + const message = { + errors: exception.errors.map((err) => ({ + message: err.message, + path: err.path, + })), + }; + + return { status, message }; +} + +function handleCommandValidation(exception: CommandValidationException) { + const { mappedErrors } = exception; + const { message } = exception; + + return { message: { message, cause: mappedErrors }, status: HttpStatus.BAD_REQUEST }; +} diff --git a/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx b/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx index 98f112028a6..74a42851d5a 100644 --- a/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx +++ b/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx @@ -23,7 +23,7 @@ import { SmsNode, TriggerNode, } from './nodes'; -import { AddNodeEdgeType, AddNodeEdge } from './edges'; +import { AddNodeEdge, AddNodeEdgeType } from './edges'; import { NODE_HEIGHT, NODE_WIDTH } from './base-node'; import { StepTypeEnum } from '@/utils/enums'; import { Step } from '@/utils/types'; diff --git a/libs/application-generic/src/commands/base.command.ts b/libs/application-generic/src/commands/base.command.ts index c96a1d5bdc2..b077443b002 100644 --- a/libs/application-generic/src/commands/base.command.ts +++ b/libs/application-generic/src/commands/base.command.ts @@ -2,7 +2,8 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { addBreadcrumb } from '@sentry/node'; -import { BadRequestException, flatten } from '@nestjs/common'; +import { flatten } from '@nestjs/common'; +import { CommandValidationException } from './commandValidationException'; export abstract class BaseCommand { static create( @@ -24,7 +25,7 @@ export abstract class BaseCommand { data: mappedErrors, }); - throw new BadRequestException(mappedErrors); + throw new CommandValidationException(mappedErrors); } return convertedObject; diff --git a/libs/application-generic/src/commands/commandValidationException.ts b/libs/application-generic/src/commands/commandValidationException.ts new file mode 100644 index 00000000000..fd2c983c2e1 --- /dev/null +++ b/libs/application-generic/src/commands/commandValidationException.ts @@ -0,0 +1,7 @@ +import { BadRequestException } from '@nestjs/common'; + +export class CommandValidationException extends BadRequestException { + constructor(public mappedErrors: unknown[]) { + super(`Command validation failed: ${JSON.stringify(mappedErrors)}`); + } +} diff --git a/libs/application-generic/src/commands/index.ts b/libs/application-generic/src/commands/index.ts index a14fdeb7717..04291a7537f 100644 --- a/libs/application-generic/src/commands/index.ts +++ b/libs/application-generic/src/commands/index.ts @@ -2,3 +2,4 @@ export * from './authenticated.command'; export * from './base.command'; export * from './organization.command'; export * from './project.command'; +export { CommandValidationException } from './commandValidationException'; diff --git a/libs/application-generic/src/usecases/execute-bridge-request/execute-bridge-request.command.ts b/libs/application-generic/src/usecases/execute-bridge-request/execute-bridge-request.command.ts index 58618a39252..290d1502580 100644 --- a/libs/application-generic/src/usecases/execute-bridge-request/execute-bridge-request.command.ts +++ b/libs/application-generic/src/usecases/execute-bridge-request/execute-bridge-request.command.ts @@ -3,12 +3,12 @@ import { AfterResponseHook } from 'got'; import { CodeResult, DiscoverOutput, - ExecuteOutput, - HealthCheck, Event, + ExecuteOutput, GetActionEnum, - PostActionEnum, + HealthCheck, HttpQueryKeysEnum, + PostActionEnum, } from '@novu/framework'; import { WorkflowOriginEnum } from '@novu/shared'; import { EnvironmentLevelCommand } from '../../commands'; diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index bf98090628f..17b7d72bec6 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -4,7 +4,6 @@ import ora from 'ora'; import { ChannelStepEnum, FRAMEWORK_VERSION, PostActionEnum, SDK_VERSION } from './constants'; import { - StepControlCompilationFailedError, ExecutionEventControlsInvalidError, ExecutionEventPayloadInvalidError, ExecutionProviderOutputInvalidError, @@ -14,6 +13,7 @@ import { ExecutionStateResultInvalidError, ProviderExecutionFailedError, ProviderNotFoundError, + StepControlCompilationFailedError, StepNotFoundError, WorkflowAlreadyExistsError, WorkflowNotFoundError, diff --git a/packages/shared/src/dto/index.ts b/packages/shared/src/dto/index.ts index 178cb0ce34e..742ae9555dc 100644 --- a/packages/shared/src/dto/index.ts +++ b/packages/shared/src/dto/index.ts @@ -14,3 +14,4 @@ export * from './widget'; export * from './session'; export * from './controls'; export * from './subscription'; +export * from './step-schemas'; diff --git a/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts b/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts new file mode 100644 index 00000000000..3199add41f3 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts @@ -0,0 +1,5 @@ +export enum ControlPreviewIssueTypeEnum { + MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD', + VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH', + MISSING_VALUE = 'MISSING_VALUE', +} diff --git a/packages/shared/src/dto/step-schemas/control-schemas.ts b/packages/shared/src/dto/step-schemas/control-schemas.ts new file mode 100644 index 00000000000..7e1ce35ecd4 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/control-schemas.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { JSONSchema } from 'json-schema-to-ts'; + +export interface TipTapNode { + type: string; + content?: TipTapNode[]; + text?: string; + attr?: Record; +} + +export interface EmailStepControlSchemaDto { + emailEditor: TipTapNode; + subject: string; +} + +export enum CustomComponentsEnum { + EMAIL_EDITOR = 'EMAIL_EDITOR', + TEXT_AREA = 'TEXT_FIELD', +} + +export const EmailStepControlSchema: JSONSchema = { + type: 'object', + properties: { + emailEditor: { + type: 'object', + properties: { + type: { + type: 'string', + }, + content: { + type: 'array', + items: { + type: 'object', + properties: { + type: { + type: 'string', + }, + content: { + type: 'array', + items: { + $ref: '#/properties/emailEditor/properties/content/items', + }, + }, + text: { + type: 'string', + }, + attr: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['type'], + additionalProperties: false, + }, + }, + text: { + type: 'string', + }, + attr: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['type'], + additionalProperties: false, + }, + subject: { + type: 'string', + }, + }, + required: ['emailEditor', 'subject'], + additionalProperties: false, +}; diff --git a/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts b/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts new file mode 100644 index 00000000000..a09feb2268f --- /dev/null +++ b/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts @@ -0,0 +1,18 @@ +import { JSONSchemaDto } from './json-schema-dto'; + +export enum ValidationStrategyEnum { + VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION = 'VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION', + VALIDATE_MISSING_CONTROL_VALUES = 'VALIDATE_MISSING_CONTROL_VALUES', +} + +// Interface for Generate Preview Request DTO +// eslint-disable-next-line @typescript-eslint/naming-convention +interface GeneratePreviewRequestDto { + controlValues?: Record; // Optional control values + payloadValues?: Record; // Optional payload values + variablesSchema?: JSONSchemaDto; // Optional variables schema + validationStrategies: ValidationStrategyEnum[]; // Array of validation strategies +} + +// Export the GeneratePreviewRequestDto type +export type { GeneratePreviewRequestDto }; diff --git a/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts b/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts new file mode 100644 index 00000000000..866a11ad8f2 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts @@ -0,0 +1,86 @@ +import { ChannelTypeEnum } from '../../types'; +import { ControlPreviewIssueTypeEnum } from './control-preview-issue-type.enum'; + +export class RenderOutput {} + +export class ChatRenderOutput extends RenderOutput { + body: string; +} + +export class SmsRenderOutput extends RenderOutput { + body: string; +} + +export class PushRenderOutput extends RenderOutput { + subject: string; + body: string; +} + +export class EmailRenderOutput extends RenderOutput { + subject: string; + body: string; +} + +export enum RedirectTargetEnum { + SELF = '_self', + BLANK = '_blank', + PARENT = '_parent', + TOP = '_top', + UNFENCED_TOP = '_unfencedTop', +} + +export class InAppRenderOutput extends RenderOutput { + subject?: string; + body: string; + avatar?: string; + primaryAction?: { + label: string; + redirect: { + url: string; + target?: RedirectTargetEnum; + }; + }; + secondaryAction?: { + label: string; + redirect: { + url: string; + target?: RedirectTargetEnum; + }; + }; + data?: Record; + redirect?: { + url: string; + target?: RedirectTargetEnum; + }; +} + +export class ControlPreviewIssue { + issueType: ControlPreviewIssueTypeEnum; + variableName?: string; + message: string; +} + +export class GeneratePreviewResponseDto { + issues: Record; + result?: + | { + type: ChannelTypeEnum.EMAIL; + preview: EmailRenderOutput; + } + | { + type: ChannelTypeEnum.IN_APP; + preview: InAppRenderOutput; + } + | { + type: ChannelTypeEnum.SMS; + preview: SmsRenderOutput; + } + | { + type: ChannelTypeEnum.PUSH; + preview: PushRenderOutput; + } + | { + type: ChannelTypeEnum.CHAT; + preview: ChatRenderOutput; + }; +} diff --git a/packages/shared/src/dto/step-schemas/index.ts b/packages/shared/src/dto/step-schemas/index.ts new file mode 100644 index 00000000000..f7d6fd458e3 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/index.ts @@ -0,0 +1,5 @@ +export * from './generate-preview-request.dto'; +export * from './generate-preview-response.dto'; +export * from './control-schemas'; +export * from './json-schema-dto'; +export { ControlPreviewIssueTypeEnum } from './control-preview-issue-type.enum'; diff --git a/packages/shared/src/dto/step-schemas/json-schema-dto.ts b/packages/shared/src/dto/step-schemas/json-schema-dto.ts new file mode 100644 index 00000000000..b92311149f7 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/json-schema-dto.ts @@ -0,0 +1,74 @@ +export type JSONSchemaTypeName = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; + +export type JSONSchemaType = string | number | boolean | JSONSchemaObject | JSONSchemaArray | null; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface JSONSchemaObject { + [key: string]: JSONSchemaType; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface JSONSchemaArray extends Array {} + +export type JSONSchemaVersion = string; + +export type JSONSchemaDefinition = JSONSchemaDto | boolean; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface JSONSchemaDto { + type?: JSONSchemaTypeName | JSONSchemaTypeName[] | undefined; + enum?: JSONSchemaType[] | undefined; + const?: JSONSchemaType | undefined; + multipleOf?: number | undefined; + maximum?: number | undefined; + exclusiveMaximum?: number | undefined; + minimum?: number | undefined; + exclusiveMinimum?: number | undefined; + maxLength?: number | undefined; + minLength?: number | undefined; + pattern?: string | undefined; + items?: JSONSchemaDefinition | JSONSchemaDefinition[] | undefined; + additionalItems?: JSONSchemaDefinition | undefined; + maxItems?: number | undefined; + minItems?: number | undefined; + uniqueItems?: boolean | undefined; + contains?: JSONSchemaDefinition | undefined; + maxProperties?: number | undefined; + minProperties?: number | undefined; + required?: string[] | undefined; + properties?: + | { + [key: string]: JSONSchemaDefinition; + } + | undefined; + patternProperties?: + | { + [key: string]: JSONSchemaDefinition; + } + | undefined; + additionalProperties?: JSONSchemaDefinition | undefined; + dependencies?: + | { + [key: string]: JSONSchemaDefinition | string[]; + } + | undefined; + propertyNames?: JSONSchemaDefinition | undefined; + if?: JSONSchemaDefinition | undefined; + then?: JSONSchemaDefinition | undefined; + else?: JSONSchemaDefinition | undefined; + allOf?: JSONSchemaDefinition[] | undefined; + anyOf?: JSONSchemaDefinition[] | undefined; + oneOf?: JSONSchemaDefinition[] | undefined; + not?: JSONSchemaDefinition | undefined; + definitions?: + | { + [key: string]: JSONSchemaDefinition; + } + | undefined; + title?: string | undefined; + description?: string | undefined; + default?: JSONSchemaType | undefined; + readOnly?: boolean | undefined; + writeOnly?: boolean | undefined; + examples?: JSONSchemaType | undefined; +} diff --git a/packages/shared/src/dto/workflows/create-workflow-dto.ts b/packages/shared/src/dto/workflows/create-workflow-dto.ts index 45b844f9ec8..d2c18b0c40c 100644 --- a/packages/shared/src/dto/workflows/create-workflow-dto.ts +++ b/packages/shared/src/dto/workflows/create-workflow-dto.ts @@ -1,5 +1,5 @@ -import { WorkflowCreationSourceEnum } from '../../types'; import { PreferencesRequestDto, StepCreateDto, WorkflowCommonsFields } from './workflow-commons-fields'; +import { WorkflowCreationSourceEnum } from '../../types'; export type CreateWorkflowDto = Omit & { steps: StepCreateDto[]; diff --git a/packages/shared/src/dto/workflows/get-list-query-params.ts b/packages/shared/src/dto/workflows/get-list-query-params.ts index 8c3c0c85631..094433aecce 100644 --- a/packages/shared/src/dto/workflows/get-list-query-params.ts +++ b/packages/shared/src/dto/workflows/get-list-query-params.ts @@ -1,5 +1,5 @@ -import { LimitOffsetPaginationDto } from '../../types'; import { WorkflowResponseDto } from './workflow-response-dto'; +import { LimitOffsetPaginationDto } from '../../types'; export class GetListQueryParams extends LimitOffsetPaginationDto { query?: string; diff --git a/packages/shared/src/dto/workflows/index.ts b/packages/shared/src/dto/workflows/index.ts index 4ffb50ade83..e3f8c1371ff 100644 --- a/packages/shared/src/dto/workflows/index.ts +++ b/packages/shared/src/dto/workflows/index.ts @@ -1,8 +1,9 @@ -export * from './create-workflow-dto'; -export * from './update-workflow-dto'; export * from './create-workflow-deprecated.dto'; export * from './update-workflow-deprecated.dto'; +export * from './workflow.dto'; +export * from './create-workflow-dto'; +export * from './update-workflow-dto'; export * from './workflow-commons-fields'; export * from './workflow-response-dto'; -export * from './workflow.dto'; export * from './workflow-status-enum'; +export * from './get-list-query-params'; diff --git a/packages/shared/src/dto/workflows/workflow-commons-fields.ts b/packages/shared/src/dto/workflows/workflow-commons-fields.ts index 267722c55ed..247877ffb7c 100644 --- a/packages/shared/src/dto/workflows/workflow-commons-fields.ts +++ b/packages/shared/src/dto/workflows/workflow-commons-fields.ts @@ -11,6 +11,8 @@ export class ControlsSchema { export type StepResponseDto = StepDto & { stepUuid: string; stepId: string; + slug: string; + controls: ControlsSchema; }; export type StepUpdateDto = StepDto & { diff --git a/packages/shared/src/dto/workflows/workflow-response-dto.ts b/packages/shared/src/dto/workflows/workflow-response-dto.ts index b62d0ca000b..c207c8a861e 100644 --- a/packages/shared/src/dto/workflows/workflow-response-dto.ts +++ b/packages/shared/src/dto/workflows/workflow-response-dto.ts @@ -1,34 +1,19 @@ -import { IsArray, IsDefined, IsEnum, IsObject, IsString } from 'class-validator'; import { PreferencesResponseDto, StepResponseDto, WorkflowCommonsFields } from './workflow-commons-fields'; -import { WorkflowOriginEnum, WorkflowTypeEnum } from '../../types'; import { WorkflowStatusEnum } from './workflow-status-enum'; +import { WorkflowOriginEnum, WorkflowTypeEnum } from '../../types'; export class WorkflowResponseDto extends WorkflowCommonsFields { - @IsString() - @IsDefined() updatedAt: string; - @IsString() - @IsDefined() createdAt: string; - @IsArray() - @IsDefined() steps: StepResponseDto[]; - @IsEnum(WorkflowOriginEnum) - @IsDefined() origin: WorkflowOriginEnum; - @IsObject() - @IsDefined() preferences: PreferencesResponseDto; - @IsEnum(WorkflowStatusEnum) - @IsDefined() status: WorkflowStatusEnum; - @IsEnum(WorkflowTypeEnum) - @IsDefined() type: WorkflowTypeEnum; } diff --git a/packages/shared/src/types/notification-templates/index.ts b/packages/shared/src/types/notification-templates/index.ts index 11aa29aad48..9be2d43bb30 100644 --- a/packages/shared/src/types/notification-templates/index.ts +++ b/packages/shared/src/types/notification-templates/index.ts @@ -50,5 +50,4 @@ export enum WorkflowOriginEnum { NOVU_CLOUD = 'novu-cloud', EXTERNAL = 'external', } - export * from './workflow-creation-source.enum'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac934e28bd0..89996780d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,6 +359,9 @@ importers: '@google-cloud/storage': specifier: ^6.2.3 version: 6.9.5(encoding@0.1.13) + '@maily-to/render': + specifier: ^0.0.12 + version: 0.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@nestjs/axios': specifier: 3.0.3 version: 3.0.3(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(axios@1.6.8)(rxjs@7.8.1) @@ -467,6 +470,9 @@ importers: envalid: specifier: ^8.0.0 version: 8.0.0 + faker: + specifier: ^6.6.6 + version: 6.6.6 handlebars: specifier: ^4.7.7 version: 4.7.7 @@ -548,6 +554,12 @@ importers: uuid: specifier: ^8.3.2 version: 8.3.2 + zod: + specifier: ^3.23.8 + version: 3.23.8 + zod-to-json-schema: + specifier: ^3.23.3 + version: 3.23.3(zod@3.23.8) optionalDependencies: '@novu/ee-auth': specifier: workspace:* @@ -589,6 +601,9 @@ importers: '@types/express': specifier: 4.17.17 version: 4.17.17 + '@types/faker': + specifier: ^6.6.9 + version: 6.6.9 '@types/mocha': specifier: ^10.0.8 version: 10.0.8 @@ -9528,6 +9543,12 @@ packages: resolution: {integrity: sha512-SaNFseFPSDQlOYM9JTyYY6wauMu6qJ8eExo+jssFyb20ZaVvxKX1eTb3Gm5aW/4aWuxn6nofU+02sCk51//wdw==} engines: {node: '>=10.0.0'} + '@maily-to/render@0.0.12': + resolution: {integrity: sha512-IpLb5g6JJ7aBuZrVyUmUcOfRj1rc/DrDQS0uk8200xXD2Wr3V+mschMGUeJghW0IMEbk/Q14jbLTNfvTg3G42w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.3.1 + '@mantine/code-highlight@7.12.1': resolution: {integrity: sha512-ZYe8aubMXSDql5Zh+HTM/JeFulaJEkpy4eFzwnKzQa6SqofoeG78CAf6cJrdSKSHmxLPMUqYhRwX+p59oQW6RQ==} peerDependencies: @@ -10792,6 +10813,9 @@ packages: '@octokit/types@9.0.0': resolution: {integrity: sha512-LUewfj94xCMH2rbD5YJ+6AQ4AVjFYTgpp6rboWM5T7N3IsIF65SBEOVcYMGAEzO/kKNiNaW4LoWtoThOhH06gw==} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@opencensus/web-types@0.0.7': resolution: {integrity: sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g==} engines: {node: '>=6.0'} @@ -12949,10 +12973,135 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' + '@react-email/body@0.0.10': + resolution: {integrity: sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/button@0.0.17': + resolution: {integrity: sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-block@0.0.9': + resolution: {integrity: sha512-Zrhc71VYrSC1fVXJuaViKoB/dBjxLw6nbE53Bm/eUuZPdnnZ1+ZUIh8jfaRKC5MzMjgnLGQTweGXVnfIrhyxtQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-inline@0.0.4': + resolution: {integrity: sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/column@0.0.12': + resolution: {integrity: sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/components@0.0.25': + resolution: {integrity: sha512-lnfVVrThEcET5NPoeaXvrz9UxtWpGRcut2a07dLbyKgNbP7vj/cXTI5TuHtanCvhCddFpMDnElNRghDOfPzwUg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/container@0.0.14': + resolution: {integrity: sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/font@0.0.8': + resolution: {integrity: sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/head@0.0.11': + resolution: {integrity: sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/heading@0.0.14': + resolution: {integrity: sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/hr@0.0.10': + resolution: {integrity: sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/html@0.0.10': + resolution: {integrity: sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/img@0.0.10': + resolution: {integrity: sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/link@0.0.10': + resolution: {integrity: sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/markdown@0.0.12': + resolution: {integrity: sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/preview@0.0.11': + resolution: {integrity: sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/render@0.0.9': resolution: {integrity: sha512-nrim7wiACnaXsGtL7GF6jp3Qmml8J6vAjAH88jkC8lIbfNZaCyuPQHANjyYIXlvQeAbsWADQJFZgOHUqFqjh/A==} engines: {node: '>=18.0.0'} + '@react-email/render@1.0.1': + resolution: {integrity: sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/row@0.0.10': + resolution: {integrity: sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/section@0.0.14': + resolution: {integrity: sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/tailwind@0.1.0': + resolution: {integrity: sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/text@0.0.10': + resolution: {integrity: sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@remirror/core-constants@2.0.2': resolution: {integrity: sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==} @@ -16216,6 +16365,10 @@ packages: '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + '@types/faker@6.6.9': + resolution: {integrity: sha512-Y9YYm5L//8ooiiknO++4Gr539zzdI0j3aXnOBjo1Vk+kTvffY10GuE2wn78AFPECwZ5MYGTjiDVw1naLLdDimw==} + deprecated: This is a stub types definition. faker provides its own type definitions, so you do not need this installed. + '@types/find-cache-dir@3.2.1': resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==} @@ -17610,6 +17763,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -20800,6 +20957,11 @@ packages: resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==} hasBin: true + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -21906,6 +22068,9 @@ packages: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} + faker@6.6.6: + resolution: {integrity: sha512-9tCqYEDHI5RYFQigXFwF1hnCwcWCOJl/hmll0lr5D2Ljjb0o4wphb69wikeJDz5qCEzXCoPvG6ss5SDP6IfOdg==} + fast-copy@3.0.1: resolution: {integrity: sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==} @@ -24561,6 +24726,11 @@ packages: engines: {node: '>=10'} hasBin: true + js-beautify@1.15.1: + resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} + engines: {node: '>=14'} + hasBin: true + js-cookie@3.0.1: resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==} engines: {node: '>=12'} @@ -25692,6 +25862,11 @@ packages: engines: {node: '>= 12'} hasBin: true + marked@7.0.4: + resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} + engines: {node: '>= 16'} + hasBin: true + marked@9.1.6: resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} engines: {node: '>= 16'} @@ -25700,6 +25875,11 @@ packages: material-colors@1.2.6: resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} + md-to-react-email@5.0.2: + resolution: {integrity: sha512-x6kkpdzIzUhecda/yahltfEl53mH26QdWu4abUF9+S0Jgam8P//Ciro8cdhyMHnT5MQUJYrIbO6ORM2UxPiNNA==} + peerDependencies: + react: 18.x + mdast-util-definitions@4.0.0: resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==} @@ -26201,6 +26381,10 @@ packages: resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} engines: {node: '>=10'} + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -26827,6 +27011,11 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} hasBin: true + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -29629,6 +29818,9 @@ packages: react: ^16.8.0 || ^17 || ^18 react-dom: ^16.8.0 || ^17 || ^18 + react-promise-suspense@0.3.4: + resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} + react-proptype-conditional-require@1.0.4: resolution: {integrity: sha512-nopsRn7KnGgazBe2c3H2+Kf+Csp6PGDRLiBkYEDMKY8o/EIgft/WnIm/OnAKTawZiLnJXHAqhpFBddvs6NiXlw==} @@ -34212,6 +34404,11 @@ packages: peerDependencies: zod: ^3.23.3 + zod-to-json-schema@3.23.3: + resolution: {integrity: sha512-TYWChTxKQbRJp5ST22o/Irt9KC5nj7CdBKYB/AosCRdj/wxEMvv4NNaj9XVUHDOIp53ZxArGhnw5HMZziPFjog==} + peerDependencies: + zod: ^3.23.3 + zod-validation-error@3.3.0: resolution: {integrity: sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==} engines: {node: '>=18.0.0'} @@ -44930,6 +45127,14 @@ snapshots: transitivePeerDependencies: - debug + '@maily-to/render@0.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-email/components': 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/render': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - react-dom + '@mantine/code-highlight@7.12.1(@mantine/core@7.12.1(@mantine/hooks@7.12.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.12.1(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@mantine/core': 7.12.1(@mantine/hooks@7.12.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -46841,6 +47046,8 @@ snapshots: dependencies: '@octokit/openapi-types': 16.0.0 + '@one-ini/wasm@0.1.1': {} + '@opencensus/web-types@0.0.7': {} '@opentelemetry/api-logs@0.46.0': @@ -49841,6 +50048,94 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@react-email/body@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/button@0.0.17(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/code-block@0.0.9(react@18.3.1)': + dependencies: + prismjs: 1.29.0 + react: 18.3.1 + + '@react-email/code-inline@0.0.4(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/column@0.0.12(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/components@0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-email/body': 0.0.10(react@18.3.1) + '@react-email/button': 0.0.17(react@18.3.1) + '@react-email/code-block': 0.0.9(react@18.3.1) + '@react-email/code-inline': 0.0.4(react@18.3.1) + '@react-email/column': 0.0.12(react@18.3.1) + '@react-email/container': 0.0.14(react@18.3.1) + '@react-email/font': 0.0.8(react@18.3.1) + '@react-email/head': 0.0.11(react@18.3.1) + '@react-email/heading': 0.0.14(react@18.3.1) + '@react-email/hr': 0.0.10(react@18.3.1) + '@react-email/html': 0.0.10(react@18.3.1) + '@react-email/img': 0.0.10(react@18.3.1) + '@react-email/link': 0.0.10(react@18.3.1) + '@react-email/markdown': 0.0.12(react@18.3.1) + '@react-email/preview': 0.0.11(react@18.3.1) + '@react-email/render': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/row': 0.0.10(react@18.3.1) + '@react-email/section': 0.0.14(react@18.3.1) + '@react-email/tailwind': 0.1.0(react@18.3.1) + '@react-email/text': 0.0.10(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - react-dom + + '@react-email/container@0.0.14(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/font@0.0.8(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/head@0.0.11(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/heading@0.0.14(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/hr@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/html@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/img@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/link@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/markdown@0.0.12(react@18.3.1)': + dependencies: + md-to-react-email: 5.0.2(react@18.3.1) + react: 18.3.1 + + '@react-email/preview@0.0.11(react@18.3.1)': + dependencies: + react: 18.3.1 + '@react-email/render@0.0.9': dependencies: html-to-text: 9.0.5 @@ -49848,6 +50143,30 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + '@react-email/render@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + html-to-text: 9.0.5 + js-beautify: 1.15.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-promise-suspense: 0.3.4 + + '@react-email/row@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/section@0.0.14(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/tailwind@0.1.0(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@react-email/text@0.0.10(react@18.3.1)': + dependencies: + react: 18.3.1 + '@remirror/core-constants@2.0.2': {} '@remix-run/router@1.19.2': {} @@ -55509,6 +55828,10 @@ snapshots: '@types/qs': 6.9.7 '@types/serve-static': 1.15.1 + '@types/faker@6.6.9': + dependencies: + faker: 6.6.6 + '@types/find-cache-dir@3.2.1': {} '@types/geojson@7946.0.10': {} @@ -57758,6 +58081,8 @@ snapshots: abbrev@1.1.1: {} + abbrev@2.0.0: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -62130,6 +62455,13 @@ snapshots: semver: 5.7.2 sigmund: 1.0.1 + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.6.3 + ee-first@1.1.1: {} ejs@3.1.10: @@ -63958,6 +64290,8 @@ snapshots: eyes@0.1.8: {} + faker@6.6.6: {} + fast-copy@3.0.1: {} fast-deep-equal@2.0.1: {} @@ -68009,6 +68343,14 @@ snapshots: glob: 8.1.0 nopt: 6.0.0 + js-beautify@1.15.1: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.4.5 + js-cookie: 3.0.5 + nopt: 7.2.1 + js-cookie@3.0.1: {} js-cookie@3.0.5: {} @@ -69389,10 +69731,17 @@ snapshots: marked@4.3.0: {} + marked@7.0.4: {} + marked@9.1.6: {} material-colors@1.2.6: {} + md-to-react-email@5.0.2(react@18.3.1): + dependencies: + marked: 7.0.4 + react: 18.3.1 + mdast-util-definitions@4.0.0: dependencies: unist-util-visit: 2.0.3 @@ -70421,6 +70770,10 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@9.0.3: dependencies: brace-expansion: 2.0.1 @@ -71253,6 +71606,10 @@ snapshots: dependencies: abbrev: 1.1.1 + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -74890,6 +75247,10 @@ snapshots: react-fast-compare: 3.2.1 warning: 4.0.3 + react-promise-suspense@0.3.4: + dependencies: + fast-deep-equal: 2.0.1 + react-proptype-conditional-require@1.0.4: {} react-refresh@0.11.0: {} @@ -81651,6 +82012,10 @@ snapshots: dependencies: zod: 3.23.8 + zod-to-json-schema@3.23.3(zod@3.23.8): + dependencies: + zod: 3.23.8 + zod-validation-error@3.3.0(zod@3.23.8): dependencies: zod: 3.23.8 From 830df1e11d27bb6b11019d095cb6ae0934a3edcc Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 08:58:10 +0200 Subject: [PATCH 02/17] fix(api): pr changes generate passing --- .../email-output-renderer.usecase.ts | 2 +- ...-usecase.ts => in-app-output-renderer.usecase.ts} | 0 .../usecases/output-renderers/index.ts | 12 ++++++------ ...er-usecase.ts => push-output-renderer.usecase.ts} | 0 ...rer-usecase.ts => sms-output-renderer.usecase.ts} | 0 .../generate-preview/generate-preview.usecase.ts | 4 ++-- .../create-default-payload-for-email-editor.spec.ts | 2 +- .../payload-preview-value-generator.usecase.ts | 2 +- apps/api/src/app/workflows-v2/workflow.controller.ts | 2 +- apps/api/src/app/workflows-v2/workflow.module.ts | 8 ++++---- 10 files changed, 16 insertions(+), 16 deletions(-) rename apps/api/src/app/environments-v1/usecases/output-renderers/{in-app-output-renderer-usecase.ts => in-app-output-renderer.usecase.ts} (100%) rename apps/api/src/app/environments-v1/usecases/output-renderers/{push-output-renderer-usecase.ts => push-output-renderer.usecase.ts} (100%) rename apps/api/src/app/environments-v1/usecases/output-renderers/{sms-output-renderer-usecase.ts => sms-output-renderer.usecase.ts} (100%) diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts index e2a3c7856ad..bef8f4008a8 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts @@ -3,7 +3,7 @@ import { render } from '@maily-to/render'; import { z } from 'zod'; import { Injectable } from '@nestjs/common'; import { RenderCommand } from './render-command'; -import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander-usecase'; +import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander.usecase'; @Injectable() export class EmailOutputRendererUsecase { 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 similarity index 100% rename from apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer-usecase.ts rename to apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts index 1f3f9a37d4f..af36daae8c5 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts @@ -1,7 +1,7 @@ -export * from './chat-output-renderer-usecase'; -export * from './email-output-renderer-usecase'; +export * from './chat-output-renderer.usecase'; +export * from './email-output-renderer.usecase'; export * from './render-command'; -export * from './push-output-renderer-usecase'; -export * from './sms-output-renderer-usecase'; -export * from './in-app-output-renderer-usecase'; -export * from './email-schema-expander-usecase'; +export * from './push-output-renderer.usecase'; +export * from './sms-output-renderer.usecase'; +export * from './in-app-output-renderer.usecase'; +export * from './email-schema-expander.usecase'; diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer-usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer.usecase.ts similarity index 100% rename from apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer-usecase.ts rename to apps/api/src/app/environments-v1/usecases/output-renderers/push-output-renderer.usecase.ts diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer-usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer.usecase.ts similarity index 100% rename from apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer-usecase.ts rename to apps/api/src/app/environments-v1/usecases/output-renderers/sms-output-renderer.usecase.ts 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 b73ea5b7009..1988ebe4eb7 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 @@ -15,9 +15,9 @@ import { difference, isArray, isObject, reduce } from 'lodash'; import { GeneratePreviewCommand } from './generate-preview-command'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; import { GetWorkflowUseCase } from '../get-workflow/get-workflow.usecase'; -import { CreateMockPayloadUseCase } from '../placeholder-enrichment/payload-preview-value-generator-usecase'; +import { CreateMockPayloadUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase'; import { StepNotFoundException } from '../../exceptions/step-not-found-exception'; -import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults-usecase'; +import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase'; @Injectable() export class GeneratePreviewUsecase { diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts index fde9bd18745..3fd878c7902 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts @@ -4,7 +4,7 @@ import { CollectPlaceholdersFromTipTapSchemaUsecase, PlaceholderMap, } from './collect-placeholders-from-tip-tap-schema.usecase'; -import { TransformPlaceholderMapUseCase } from './transform-placeholder-usecase'; +import { TransformPlaceholderMapUseCase } from './transform-placeholder.usecase'; describe('default paylaod creator for email editor', () => { it('should collect placeholders from multiple for nodes, show nodes, and regular placeholders', () => { diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts index 4f18a372695..de47fa3acca 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { TipTapNode } from '@novu/shared'; -import { TransformPlaceholderMapUseCase } from './transform-placeholder-usecase'; +import { TransformPlaceholderMapUseCase } from './transform-placeholder.usecase'; import { CollectPlaceholdersFromTipTapSchemaUsecase, extractPlaceholders, diff --git a/apps/api/src/app/workflows-v2/workflow.controller.ts b/apps/api/src/app/workflows-v2/workflow.controller.ts index 56f6e068e5d..b962336219a 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.ts @@ -35,7 +35,7 @@ import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.us import { ListWorkflowsCommand } from './usecases/list-workflows/list-workflows.command'; import { DeleteWorkflowUseCase } from './usecases/delete-workflow/delete-workflow.usecase'; import { DeleteWorkflowCommand } from './usecases/delete-workflow/delete-workflow.command'; -import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview-usecase'; +import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview-command'; @ApiCommonResponses() diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index 4b32390b92d..d011c0480da 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -19,11 +19,11 @@ import { DeleteWorkflowUseCase } from './usecases/delete-workflow/delete-workflo import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-workflow-by-ids.usecase'; import { GetStepSchemaUseCase } from '../step-schemas/usecases/get-step-schema/get-step-schema.usecase'; import { BridgeModule } from '../bridge'; -import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview-usecase'; -import { CreateMockPayloadUseCase } from './usecases/placeholder-enrichment/payload-preview-value-generator-usecase'; -import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schema/extract-defaults-usecase'; +import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; +import { CreateMockPayloadUseCase } from './usecases/placeholder-enrichment/payload-preview-value-generator.usecase'; +import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schema/extract-defaults.usecase'; import { CollectPlaceholdersFromTipTapSchemaUsecase } from './usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase'; -import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichment/transform-placeholder-usecase'; +import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichment/transform-placeholder.usecase'; @Module({ imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule], From 95ae692e2b6e526a2c7cfdd23ad051a93bb8aa83 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:08:33 +0200 Subject: [PATCH 03/17] fix(api): pr changes generate passing --- apps/api/src/app/workflows-v2/workflow.controller.e2e.ts | 2 +- packages/shared/src/dto/workflows/workflow-commons-fields.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) 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 a29aee24803..1ec0941c218 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -244,7 +244,7 @@ async function createWorkflowAndValidate(nameSuffix: string = ''): Promise - removeFields(step, 'stepUuid', 'slug', 'controls','stepId') + removeFields(step, 'stepUuid', 'slug', 'controls', 'stepId') ); expect(createdWorkflowWithoutUpdateDate).to.deep.equal( removeFields(createWorkflowDto, '__source'), diff --git a/packages/shared/src/dto/workflows/workflow-commons-fields.ts b/packages/shared/src/dto/workflows/workflow-commons-fields.ts index 247877ffb7c..7b93fc67e8c 100644 --- a/packages/shared/src/dto/workflows/workflow-commons-fields.ts +++ b/packages/shared/src/dto/workflows/workflow-commons-fields.ts @@ -42,9 +42,6 @@ export class StepDto { @IsDefined() type: StepTypeEnum; - @IsObject() - controls: ControlsSchema; - @IsObject() controlValues: Record; } From ff3b577c7e13ff81e72c8e4f9ee5ed52e6815265 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:14:03 +0200 Subject: [PATCH 04/17] fix(api): fix github security issues --- .../transform-placeholder.usecase.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts index b6fa5f142a0..d6b61f7e844 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts @@ -13,7 +13,7 @@ export class TransformPlaceholderMapUseCase { let current = obj; keys.forEach((key, index) => { - if (!current[key]) { + if (!current.hasOwnProperty(key)) { current[key] = index === keys.length - 1 ? value : {}; } current = current[key]; @@ -23,11 +23,15 @@ export class TransformPlaceholderMapUseCase { this.processFor(command.input, setNestedValue, defaultPayload); for (const key in command.input.show) { - setNestedValue(defaultPayload, key, 'true'); + if (command.input.show.hasOwnProperty(key)) { + setNestedValue(defaultPayload, key, 'true'); + } } for (const key in command.input.regular) { - setNestedValue(defaultPayload, key, `{{${key}}}`); + if (command.input.regular.hasOwnProperty(key)) { + setNestedValue(defaultPayload, key, `{{${key}}}`); + } } return defaultPayload; @@ -39,15 +43,17 @@ export class TransformPlaceholderMapUseCase { defaultPayload: Record ) { for (const key in input.for) { - const items = input.for[key]; - const finalValue = [{}, {}]; - setNestedValue(defaultPayload, key, finalValue); - items.forEach((item) => { - const extractedKey = item.replace('item.', ''); - const valueFunc = (suffix) => `{#${item}#}-${suffix}`; - setNestedValue(finalValue[0], extractedKey, valueFunc('1')); - setNestedValue(finalValue[1], extractedKey, valueFunc('2')); - }); + if (input.for.hasOwnProperty(key)) { + const items = input.for[key]; + const finalValue = [{}, {}]; + setNestedValue(defaultPayload, key, finalValue); + items.forEach((item) => { + const extractedKey = item.replace('item.', ''); + const valueFunc = (suffix) => `{#${item}#}-${suffix}`; + setNestedValue(finalValue[0], extractedKey, valueFunc('1')); + setNestedValue(finalValue[1], extractedKey, valueFunc('2')); + }); + } } } } From 9933764934cb1eed76062a4d6384b0ee53e666ff Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:18:12 +0200 Subject: [PATCH 05/17] fix(api): fix github security issues --- .../placeholder-enrichment/transform-placeholder.usecase.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts index d6b61f7e844..ed9a9418942 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts @@ -49,6 +49,7 @@ export class TransformPlaceholderMapUseCase { setNestedValue(defaultPayload, key, finalValue); items.forEach((item) => { const extractedKey = item.replace('item.', ''); + // TODO: extract to const @sokratis const valueFunc = (suffix) => `{#${item}#}-${suffix}`; setNestedValue(finalValue[0], extractedKey, valueFunc('1')); setNestedValue(finalValue[1], extractedKey, valueFunc('2')); From 21d126d80fa585b96c102fc67b943a96c8a7662e Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:19:37 +0200 Subject: [PATCH 06/17] fix(api): fix github security issues --- .../placeholder-enrichment/transform-placeholder.usecase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts index ed9a9418942..7000216ab19 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts @@ -49,7 +49,7 @@ export class TransformPlaceholderMapUseCase { setNestedValue(defaultPayload, key, finalValue); items.forEach((item) => { const extractedKey = item.replace('item.', ''); - // TODO: extract to const @sokratis + // TODO: extract to const const valueFunc = (suffix) => `{#${item}#}-${suffix}`; setNestedValue(finalValue[0], extractedKey, valueFunc('1')); setNestedValue(finalValue[1], extractedKey, valueFunc('2')); From e9865fa10bab7a1ab3930f257a4ad90dbb3a0788 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:29:12 +0200 Subject: [PATCH 07/17] fix(api): fix github security issues --- .../collect-placeholders-from-tip-tap-schema.usecase.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts index 03b8d7ee7c6..a38836713b3 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts @@ -47,7 +47,9 @@ export class CollectPlaceholdersFromTipTapSchemaUsecase { } else if (node.type === 'show' && node.attr && node.attr.when) { this.handleShowTraversal(node, placeholders); } else if (node.type === 'text' && node.text) { - const regularPlaceholders = extractPlaceholders(node.text).filter((x) => !x.startsWith('item')); + const regularPlaceholders = extractPlaceholders(node.text).filter( + (placeholder) => !placeholder.startsWith('item') + ); for (const regularPlaceholder of regularPlaceholders) { placeholders.regular[regularPlaceholder] = []; } From 598e5bb30e24a8fd7d73944aeee5651d226c60a5 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:33:20 +0200 Subject: [PATCH 08/17] fix(api): fix github security issues --- apps/api/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/package.json b/apps/api/package.json index cf6c4790fbb..de1878fc5df 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -69,7 +69,6 @@ "date-fns": "^2.29.2", "dotenv": "^16.4.5", "envalid": "^8.0.0", - "faker": "^6.6.6", "handlebars": "^4.7.7", "helmet": "^6.0.1", "i18next": "^23.7.6", From 563d203ce89954064cb7c1f1d6fc8be23026a44e Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:33:27 +0200 Subject: [PATCH 09/17] fix(api): fix github security issues --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89996780d7a..e9287ca2e34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -470,9 +470,6 @@ importers: envalid: specifier: ^8.0.0 version: 8.0.0 - faker: - specifier: ^6.6.6 - version: 6.6.6 handlebars: specifier: ^4.7.7 version: 4.7.7 From 284b44ec7fad61aea1be46a4ff537e3967cc22b2 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:48:22 +0200 Subject: [PATCH 10/17] fix(api): revert pr blockers --- .../src/app/shared/commands/base.command.ts | 5 +- .../app/workflows-v2/generate-preview.e2e.ts | 4 +- ...e-default-payload-for-email-editor.spec.ts | 149 ------------------ .../src/commands/base.command.ts | 5 +- .../commands/commandValidationException.ts | 4 +- 5 files changed, 8 insertions(+), 159 deletions(-) delete mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts diff --git a/apps/api/src/app/shared/commands/base.command.ts b/apps/api/src/app/shared/commands/base.command.ts index 7371d99b2f3..914867a7361 100644 --- a/apps/api/src/app/shared/commands/base.command.ts +++ b/apps/api/src/app/shared/commands/base.command.ts @@ -2,8 +2,7 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { addBreadcrumb } from '@sentry/node'; -import { flatten } from '@nestjs/common'; -import { CommandValidationException } from './commandValidationException'; +import { BadRequestException, flatten } from '@nestjs/common'; export abstract class BaseCommand { static create(this: new (...args: any[]) => T, data: T): T { @@ -20,7 +19,7 @@ export abstract class BaseCommand { data: mappedErrors, }); - throw new CommandValidationException(mappedErrors); + throw new BadRequestException(mappedErrors); } return convertedObject; 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 4428da63e95..a47d3428f06 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -17,7 +17,7 @@ import { InAppOutput } from '@novu/framework'; import { createWorkflowClient, HttpError, NovuRestResult } from './clients'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; -describe('Control Schema', () => { +describe('Generate Preview', () => { let session: UserSession; let workflowsClient: ReturnType; @@ -232,7 +232,7 @@ function buildInAppControlValues(): InAppOutput { label: 'Secondary Action', redirect: { target: RedirectTargetEnum.BLANK, - url: 'https://www.example.com/secondary-action', + url: '{{payload.secondaryUrl}}', }, }, data: { diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts deleted file mode 100644 index 3fd878c7902..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/create-default-payload-for-email-editor.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { expect } from 'chai'; -import { TipTapNode } from '@novu/shared'; -import { - CollectPlaceholdersFromTipTapSchemaUsecase, - PlaceholderMap, -} from './collect-placeholders-from-tip-tap-schema.usecase'; -import { TransformPlaceholderMapUseCase } from './transform-placeholder.usecase'; - -describe('default paylaod creator for email editor', () => { - it('should collect placeholders from multiple for nodes, show nodes, and regular placeholders', () => { - const node: TipTapNode = { - type: 'doc', - content: [ - { - type: 'for', - attr: { - each: '{{payload.comments}}', - }, - content: [ - { - type: 'h1', - content: [ - { - type: 'text', - text: '{{item.subject}}-{{item.body}}', - }, - ], - }, - ], - }, - { - type: 'paragraph', - content: [ - { - type: 'text', - text: '{{payload.intro}} This is an introduction paragraph.', - }, - ], - }, - { - type: 'for', - attr: { - each: '{{payload.comment2}}', - }, - content: [ - { - type: 'h2', - content: [ - { - type: 'text', - text: '{{item.body2}}', - }, - ], - }, - ], - }, - { - type: 'show', - attr: { - when: '{{payload.isPremiumPlan}}', - }, - content: [], - }, - { - type: 'show', - attr: { - when: '{{payload.isBetaUser}}', - }, - content: [], - }, - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'This is a regular text without placeholders.', - }, - ], - }, - { - type: 'paragraph', - content: [ - { - type: 'text', - text: '{{payload.footer}} This is the footer text.', - }, - ], - }, - ], - }; - - const result = new CollectPlaceholdersFromTipTapSchemaUsecase().execute({ node }); - - expect(result).to.deep.equal({ - for: { - 'payload.comments': ['item.subject', 'item.body'], - 'payload.comment2': ['item.body2'], - }, - show: { - 'payload.isPremiumPlan': [], - 'payload.isBetaUser': [], - }, - regular: { - 'payload.intro': [], - 'payload.footer': [], - }, - }); - }); -}); - -describe('transformPlaceholderMap', () => { - it('should transform the PlaceholderMap into a nested JSON structure', () => { - const input: PlaceholderMap = { - for: { - 'payload.comments': ['item.field1', 'item.field2'], - }, - show: { - 'payload.isPremiumPlan': [], - 'payload.isBetaUser': [], - }, - regular: { - 'payload.intro': [], - 'payload.footer': [], - }, - }; - - const expectedOutput = { - payload: { - comments: [ - { - field1: '{{item.field1}}-1', - field2: '{{item.field2}}-1', - }, - { - field1: '{{item.field1}}-2', - field2: '{{item.field2}}-2', - }, - ], - isPremiumPlan: 'true', - isBetaUser: 'true', - intro: '{{payload.intro}}', - footer: '{{payload.footer}}', - }, - }; - - const output = new TransformPlaceholderMapUseCase().execute({ input }); - expect(output).to.deep.equal(expectedOutput); - }); -}); diff --git a/libs/application-generic/src/commands/base.command.ts b/libs/application-generic/src/commands/base.command.ts index b077443b002..c96a1d5bdc2 100644 --- a/libs/application-generic/src/commands/base.command.ts +++ b/libs/application-generic/src/commands/base.command.ts @@ -2,8 +2,7 @@ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { addBreadcrumb } from '@sentry/node'; -import { flatten } from '@nestjs/common'; -import { CommandValidationException } from './commandValidationException'; +import { BadRequestException, flatten } from '@nestjs/common'; export abstract class BaseCommand { static create( @@ -25,7 +24,7 @@ export abstract class BaseCommand { data: mappedErrors, }); - throw new CommandValidationException(mappedErrors); + throw new BadRequestException(mappedErrors); } return convertedObject; diff --git a/libs/application-generic/src/commands/commandValidationException.ts b/libs/application-generic/src/commands/commandValidationException.ts index fd2c983c2e1..625421c424a 100644 --- a/libs/application-generic/src/commands/commandValidationException.ts +++ b/libs/application-generic/src/commands/commandValidationException.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; export class CommandValidationException extends BadRequestException { - constructor(public mappedErrors: unknown[]) { - super(`Command validation failed: ${JSON.stringify(mappedErrors)}`); + constructor(private mappedErrors: string[]) { + super({ message: 'Validation failed', errors: mappedErrors }); } } From 989b7329dedb7b0e2e7f15d847ae47b6e69959d5 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:50:19 +0200 Subject: [PATCH 11/17] fix(api): revert pr blockers --- apps/api/src/app/workflows-v2/generate-preview.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a47d3428f06..4428da63e95 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -17,7 +17,7 @@ import { InAppOutput } from '@novu/framework'; import { createWorkflowClient, HttpError, NovuRestResult } from './clients'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; -describe('Generate Preview', () => { +describe('Control Schema', () => { let session: UserSession; let workflowsClient: ReturnType; @@ -232,7 +232,7 @@ function buildInAppControlValues(): InAppOutput { label: 'Secondary Action', redirect: { target: RedirectTargetEnum.BLANK, - url: '{{payload.secondaryUrl}}', + url: 'https://www.example.com/secondary-action', }, }, data: { From 1126866b113b63719f520478c2fae9543fa96fb4 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 10:06:13 +0200 Subject: [PATCH 12/17] fix(api): fix commandException --- apps/api/src/app/shared/commands/commandValidationException.ts | 2 +- .../src/commands/commandValidationException.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/shared/commands/commandValidationException.ts b/apps/api/src/app/shared/commands/commandValidationException.ts index 625421c424a..3084bfbb29d 100644 --- a/apps/api/src/app/shared/commands/commandValidationException.ts +++ b/apps/api/src/app/shared/commands/commandValidationException.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; export class CommandValidationException extends BadRequestException { - constructor(private mappedErrors: string[]) { + constructor(public mappedErrors: string[]) { super({ message: 'Validation failed', errors: mappedErrors }); } } diff --git a/libs/application-generic/src/commands/commandValidationException.ts b/libs/application-generic/src/commands/commandValidationException.ts index 625421c424a..3084bfbb29d 100644 --- a/libs/application-generic/src/commands/commandValidationException.ts +++ b/libs/application-generic/src/commands/commandValidationException.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; export class CommandValidationException extends BadRequestException { - constructor(private mappedErrors: string[]) { + constructor(public mappedErrors: string[]) { super({ message: 'Validation failed', errors: mappedErrors }); } } From 5a34bd833fc035566ca30dae59362c88b4c8247d Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 10:44:59 +0200 Subject: [PATCH 13/17] fix(api): fix commandException --- .../app/workflows-v2/generate-preview.e2e.ts | 2 +- .../generate-preview.usecase.ts | 30 +++++++++++++++++++ ...oad-based-on-hydration-strategy-command.ts | 3 +- ...payload-preview-value-generator.usecase.ts | 6 ++-- 4 files changed, 35 insertions(+), 6 deletions(-) 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 4428da63e95..21bb926e0e0 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -17,7 +17,7 @@ import { InAppOutput } from '@novu/framework'; import { createWorkflowClient, HttpError, NovuRestResult } from './clients'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; -describe('Control Schema', () => { +describe('Generate Preview', () => { let session: UserSession; let workflowsClient: ReturnType; 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 1988ebe4eb7..b90799339c2 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 @@ -250,3 +250,33 @@ function getDotNotationKeys(input: NestedRecord, parentKey: string = '', keys: s return keys; } +function flattenJson(obj, parentKey = '', result = {}) { + // eslint-disable-next-line guard-for-in + for (const key in obj) { + // Construct the new key using dot notation + const newKey = parentKey ? `${parentKey}.${key}` : key; + + // Check if the value is an object (and not null or an array) + if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + // Recursively flatten the object + flattenJson(obj[key], newKey, result); + } else if (Array.isArray(obj[key])) { + // Handle arrays by flattening each item + obj[key].forEach((item, index) => { + const arrayKey = `${newKey}[${index}]`; + if (typeof item === 'object' && item !== null) { + flattenJson(item, arrayKey, result); + } else { + // eslint-disable-next-line no-param-reassign + result[arrayKey] = item; + } + }); + } else { + // Assign the value to the result with the new key + // eslint-disable-next-line no-param-reassign + result[newKey] = obj[key]; + } + } + + return result; +} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts index 7234a7fed19..8a86684f971 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts @@ -1,7 +1,6 @@ import { BaseCommand } from '@novu/application-generic'; -import { GeneratePreviewRequestDto } from '@novu/shared'; export class AddKeysToPayloadBasedOnHydrationStrategyCommand extends BaseCommand { - dto: GeneratePreviewRequestDto; + controlValues: Record; controlValueKey: string; } diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts index de47fa3acca..f27123948e3 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts @@ -15,13 +15,13 @@ export class CreateMockPayloadUseCase { ) {} public execute(command: AddKeysToPayloadBasedOnHydrationStrategyCommand): Record { - const { dto, controlValueKey } = command; + const { controlValues, controlValueKey } = command; - if (!dto.controlValues) { + if (!controlValues) { return {}; } - const controlValue = dto.controlValues[controlValueKey]; + const controlValue = controlValues[controlValueKey]; if (typeof controlValue === 'object') { return this.buildPayloadForEmailEditor(controlValue); } From 5381d962217492ac83378aaf09bd991dfac5f173 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 10:45:23 +0200 Subject: [PATCH 14/17] fix(api): fix commandException --- .../src/app/workflows-v2/generate-preview.e2e.ts | 2 +- .../generate-preview/generate-preview.usecase.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) 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 21bb926e0e0..ee84b026f76 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -222,7 +222,7 @@ function buildInAppControlValues(): InAppOutput { body: 'Hello, World! {{payload.placeholder.body}}', avatar: 'https://www.example.com/avatar.png', primaryAction: { - label: 'Primary Action', + label: '{{payload.secondaryUrl}}', redirect: { target: RedirectTargetEnum.BLANK, url: 'https://www.example.com/primary-action', 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 b90799339c2..9453fb9b6c4 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 @@ -153,9 +153,18 @@ export class GeneratePreviewUsecase { let aggregatedDefaultValues = {}; const aggregatedDefaultValuesForControl: Record> = {}; - for (const controlValueKey in dto.controlValues) { - if (dto.controlValues.hasOwnProperty(controlValueKey)) { - const defaultValuesForSingleControlValue = this.createMockPayloadUseCase.execute({ dto, controlValueKey }); + const flattenedValues = flattenJson(dto.controlValues); + console.log('flattenedValues', flattenedValues); + for (const controlValueKey in flattenedValues) { + if (flattenedValues.hasOwnProperty(controlValueKey)) { + console.log('controlValueKey', controlValueKey); + + const defaultValuesForSingleControlValue = this.createMockPayloadUseCase.execute({ + controlValues: flattenedValues, + controlValueKey, + }); + console.log('defaultValuesForSingleControlValue', defaultValuesForSingleControlValue); + if (defaultValuesForSingleControlValue) { aggregatedDefaultValuesForControl[controlValueKey] = defaultValuesForSingleControlValue; } From 7a59054de03de455e1b16df007ee2f090a1cacbc Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:14:25 +0200 Subject: [PATCH 15/17] fix(api): fix commandException --- .../src/dto/step-schemas/control-schemas.ts | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/packages/shared/src/dto/step-schemas/control-schemas.ts b/packages/shared/src/dto/step-schemas/control-schemas.ts index 7e1ce35ecd4..2b0226c96f6 100644 --- a/packages/shared/src/dto/step-schemas/control-schemas.ts +++ b/packages/shared/src/dto/step-schemas/control-schemas.ts @@ -23,46 +23,7 @@ export const EmailStepControlSchema: JSONSchema = { properties: { emailEditor: { type: 'object', - properties: { - type: { - type: 'string', - }, - content: { - type: 'array', - items: { - type: 'object', - properties: { - type: { - type: 'string', - }, - content: { - type: 'array', - items: { - $ref: '#/properties/emailEditor/properties/content/items', - }, - }, - text: { - type: 'string', - }, - attr: { - type: 'object', - additionalProperties: true, - }, - }, - required: ['type'], - additionalProperties: false, - }, - }, - text: { - type: 'string', - }, - attr: { - type: 'object', - additionalProperties: true, - }, - }, - required: ['type'], - additionalProperties: false, + additionalProperties: true, // Allows any properties in emailEditor }, subject: { type: 'string', From 48b595daaa944fc285ef66bf5dab713261c5cf8e Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:22:38 +0200 Subject: [PATCH 16/17] fix(api): fix commandException --- apps/api/src/app/workflows-v2/generate-preview.e2e.ts | 3 --- .../usecases/generate-preview/generate-preview.usecase.ts | 4 ---- 2 files changed, 7 deletions(-) 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 ee84b026f76..c5d7c87208d 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -40,7 +40,6 @@ describe('Generate Preview', () => { const { stepUuid, workflowId } = await createWorkflowAndReturnId(type); const requestDto = buildDtoWithPayload(type); const previewResponseDto = await generatePreview(workflowId, stepUuid, requestDto, description); - console.log('previewResponseDto', JSON.stringify(previewResponseDto)); expect(previewResponseDto.result!.preview).to.exist; const expectedRenderedResult = buildInAppControlValues(); expectedRenderedResult.subject = buildInAppControlValues().subject!.replace( @@ -66,7 +65,6 @@ describe('Generate Preview', () => { const previewResponseDto = await generatePreview(workflowId, stepUuid, requestDto, description); expect(previewResponseDto.result!.preview).to.exist; expect(previewResponseDto.issues).to.exist; - console.log('previewResponseDto.issues', JSON.stringify(previewResponseDto.issues)); if (type !== StepTypeEnum.EMAIL) { expect(previewResponseDto.result!.preview).to.deep.equal(stepTypeTo[type]); @@ -107,7 +105,6 @@ describe('Generate Preview', () => { dto: GeneratePreviewRequestDto, description: string ): Promise { - console.log('dto', JSON.stringify(dto, null, 2)); const novuRestResult = await workflowsClient.generatePreview(workflowId, stepUuid, dto); if (novuRestResult.isSuccessResult()) { return novuRestResult.value; 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 9453fb9b6c4..72f73431794 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 @@ -154,16 +154,12 @@ export class GeneratePreviewUsecase { let aggregatedDefaultValues = {}; const aggregatedDefaultValuesForControl: Record> = {}; const flattenedValues = flattenJson(dto.controlValues); - console.log('flattenedValues', flattenedValues); for (const controlValueKey in flattenedValues) { if (flattenedValues.hasOwnProperty(controlValueKey)) { - console.log('controlValueKey', controlValueKey); - const defaultValuesForSingleControlValue = this.createMockPayloadUseCase.execute({ controlValues: flattenedValues, controlValueKey, }); - console.log('defaultValuesForSingleControlValue', defaultValuesForSingleControlValue); if (defaultValuesForSingleControlValue) { aggregatedDefaultValuesForControl[controlValueKey] = defaultValuesForSingleControlValue; From 614b01e61af59c62e3016e5b608626dc4e7790ae Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:48:42 +0200 Subject: [PATCH 17/17] fix(api): fix get schamas testing --- .../step-schemas/e2e/get-step-schema.e2e.ts | 165 +----------------- 1 file changed, 7 insertions(+), 158 deletions(-) diff --git a/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts b/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts index 427c2fbe5f2..7a719bf4c18 100644 --- a/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts +++ b/apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { UserSession } from '@novu/testing'; -import { StepTypeEnum, WorkflowResponseDto } from '@novu/shared'; +import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared'; describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId&stepType=:stepType (GET)', async () => { let session: UserSession; @@ -10,9 +10,8 @@ describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId& beforeEach(async () => { session = new UserSession(); await session.initialize(); - createdWorkflow = await createWorkflow(session, createdWorkflow); }); - + // todo: need to add test for variable logic. describe('Get Control Schema with stepType', () => { it('should get step schema for in app step type', async function () { const { data } = (await getStepSchema({ session, stepType: StepTypeEnum.IN_APP })).body; @@ -84,180 +83,30 @@ describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId& expect(response.body.message).to.include( 'stepType must be one of the following values: email, sms, push, chat, in_app, digest, delay, custom' ); - expect(response.body.error).to.equal('Bad Request'); - expect(response.body.statusCode).to.equal(400); - }); - }); - - describe('Get Control Schema exiting step', () => { - it('should get step schema for existing step', async function () { - const { data } = ( - await getStepSchema({ - session, - workflowId: createdWorkflow._id, - stepId: createdWorkflow.steps[0].stepUuid, - }) - ).body; - - expect(data.controls.type).to.equal('object'); - expect(data.controls.properties).to.have.property('codeFirstTitle'); - expect(data.controls.properties.codeFirstTitle.type).to.equal('string'); - expect(Object.keys(data.controls.properties).length).to.equal(1); - expect(data.controls).to.not.have.property('required'); - expect(data.controls).to.not.have.property('additionalProperties'); - expect(data.controls.description).to.not.be.empty; - - expect(data.variables.type).to.equal('object'); - expect(data.variables.description).to.not.be.empty; - expect(data.variables.properties).to.have.property('subscriber'); - expect(data.variables.properties.subscriber.type).to.equal('object'); - expect(data.variables.properties.subscriber.description).to.not.be.empty; - expect(data.variables.properties.subscriber.properties).to.have.property('firstName'); - expect(data.variables.properties.subscriber.properties.firstName.type).to.equal('string'); - expect(data.variables.properties.subscriber.properties).to.have.property('lastName'); - expect(data.variables.properties.subscriber.properties.lastName.type).to.equal('string'); - expect(data.variables.properties.subscriber.properties).to.have.property('email'); - expect(data.variables.properties.subscriber.properties.email.type).to.equal('string'); - expect(data.variables.properties.subscriber.required).to.deep.equal([ - 'firstName', - 'lastName', - 'email', - 'subscriberId', - ]); - expect(data.variables.properties).to.have.property('steps'); - expect(data.variables.properties.steps.type).to.equal('object'); - expect(data.variables.properties.steps.description).to.not.be.empty; - expect(data.variables.required).to.deep.equal(['subscriber']); - expect(data.variables.additionalProperties).to.be.false; - }); - - it('should get step schema for existing step no previous steps', async function () { - const { data } = ( - await getStepSchema({ - session, - workflowId: createdWorkflow._id, - stepId: createdWorkflow.steps[0].stepUuid, - }) - ).body; - - expect(data.variables.properties.steps.properties).to.be.an('object').that.is.empty; - }); - - it('should get step schema for existing step with previous steps', async function () { - const { data } = ( - await getStepSchema({ - session, - stepType: StepTypeEnum.IN_APP, - workflowId: createdWorkflow._id, - stepId: createdWorkflow.steps[1].stepUuid, - }) - ).body; - - expect(data.variables.properties.steps.type).to.equal('object'); - const variableStepKeys = Object.keys(data.variables.properties.steps.properties); - expect(variableStepKeys).to.have.length(1); - const variableStepKey = variableStepKeys[0]; - const createdWorkflowPreviousSteps = createdWorkflow.steps.slice( - 0, - createdWorkflow.steps.findIndex((stepItem) => stepItem.stepUuid === createdWorkflow.steps[1].stepUuid) - ); - const variableStepKeyFoundInCreatedWorkflow = createdWorkflowPreviousSteps.find( - (step) => step.stepId === variableStepKey - ); - const isValidVariableStepKey = !!variableStepKeyFoundInCreatedWorkflow; - expect(isValidVariableStepKey).to.be.true; - expect(data.variables.properties.steps.properties).to.have.property(variableStepKey); - expect(data.variables.properties.steps.properties[variableStepKey].type).to.equal('object'); - expect(data.variables.properties.steps.properties[variableStepKey].properties).to.have.all.keys( - 'seen', - 'read', - 'lastSeenDate', - 'lastReadDate' - ); - expect(data.variables.properties.steps.properties[variableStepKey].required).to.deep.equal([ - 'seen', - 'read', - 'lastSeenDate', - 'lastReadDate', - ]); - expect(data.variables.properties.steps.properties[variableStepKey].additionalProperties).to.be.false; - expect(data.variables.properties.steps.required).to.be.an('array').that.is.empty; - expect(data.variables.properties.steps.additionalProperties).to.be.false; - expect(data.variables.properties.steps.description).to.not.be.empty; - }); - - it('should get error for invalid step id', async function () { - const invalidStepUuid = `${createdWorkflow.steps[0].stepUuid}0`; - - const response = await getStepSchema({ - session, - workflowId: createdWorkflow._id, - stepId: invalidStepUuid, - }); - - expect(response.status).to.equal(400); - expect(response.body.message).to.equal('No step found'); - expect(response.body.stepId).to.equal(invalidStepUuid); - expect(response.body.workflowId).to.equal(createdWorkflow._id); - expect(response.body.statusCode).to.equal(400); - }); - - it('should get error for invalid workflow id', async function () { - const invalidWorkflowId = createdWorkflow.steps[0].stepUuid; - - const response = await getStepSchema({ - session, - workflowId: invalidWorkflowId, - stepId: createdWorkflow.steps[0].stepUuid, - }); - - expect(response.status).to.equal(400); - expect(response.body.message).to.equal('No workflow found'); - expect(response.body.workflowId).to.equal(invalidWorkflowId); expect(response.body.statusCode).to.equal(400); }); }); }); async function createWorkflow(session: UserSession, createdWorkflow: WorkflowResponseDto) { - const workflowObject = { - _organizationId: session.organization._id, - _environmentId: session.environment._id, + const workflowObject: CreateWorkflowDto = { + __source: WorkflowCreationSourceEnum.ONBOARDING_IN_APP, name: 'test api template', + workflowId: 'test-trigger-api', description: 'This is a test description', tags: ['test-tag-api'], - notificationGroupId: session.notificationGroups[0]._id, steps: [ { name: 'In-App Test Step', type: StepTypeEnum.IN_APP, - controls: { - schema: { - type: 'object', - properties: { - codeFirstTitle: { - type: 'string', - }, - }, - }, - }, + controlValues: {}, }, { name: 'SMS Test Step', type: StepTypeEnum.SMS, - controls: { - schema: { - type: 'object', - properties: { - codeFirstSmsTitle: { - type: 'string', - }, - }, - }, - }, + controlValues: {}, }, ], - triggers: [{ identifier: 'test-trigger-api' }], }; const workflowDataRes = await session.testAgent.post(`/v2/workflows`).send(workflowObject);