From a86306a452a49fad471f0cdf75fabb60c9dabff5 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Fri, 13 Mar 2026 15:37:44 +0100 Subject: [PATCH 1/2] feat(api-service,dashboard,novu): extend step resolver to all steps fixes NV-7187 (#10271) --- .../dtos/deploy-step-resolver-request.dto.ts | 23 +- .../disconnect-step-resolver-request.dto.ts | 8 + apps/api/src/app/step-resolvers/dtos/index.ts | 1 + .../step-resolvers.controller.ts | 38 ++- .../deploy-step-resolver.command.ts | 6 + .../deploy-step-resolver.usecase.ts | 73 ++-- ...nc-step-resolver-to-environment.usecase.ts | 9 +- .../usecases/preview/preview.usecase.ts | 14 +- .../workflows-v2/workflow.controller.e2e.ts | 2 - apps/dashboard/src/api/step-resolvers.ts | 17 + .../src/components/email-editor-select.tsx | 28 +- .../welcome/framework-guides.instructions.tsx | 2 +- .../steps/chat/chat-editor.tsx | 3 - .../workflow-editor/steps/component-utils.tsx | 5 - .../steps/context/step-editor-context.tsx | 20 +- .../steps/controls/custom-step-controls.tsx | 5 +- .../steps/controls/select-widget.tsx | 3 +- .../steps/controls/text-widget.tsx | 110 +++--- .../steps/editor/step-editor-factory.tsx | 32 +- .../steps/email/email-body-react-email.tsx | 19 -- .../steps/email/email-body.tsx | 17 - .../steps/email/email-editor.tsx | 72 +--- .../steps/email/email-renderer-select.tsx | 100 ------ .../steps/email/use-react-email-step-hint.tsx | 27 -- .../steps/preview/step-preview-factory.tsx | 8 - .../steps/shared/step-editor-mode-toggle.tsx | 75 +++++ .../shared/step-resolver-active-panel.tsx | 17 + .../step-resolver-not-published.tsx} | 131 +++----- .../steps/shared/use-step-resolver-hint.tsx | 21 ++ .../steps/step-editor-layout.tsx | 31 +- .../src/context/customer-io/index.ts | 2 +- .../src/hooks/use-disconnect-step-resolver.ts | 38 +++ ...olling.ts => use-step-resolver-polling.ts} | 15 +- apps/dashboard/src/routes/root.tsx | 2 +- apps/dashboard/src/utils/customer-io.ts | 2 +- .../workflow/controls/email-control.dto.ts | 11 +- .../schemas/control/email-control.schema.ts | 5 - .../build-step-issues.usecase.ts | 4 +- .../disconnect-step-resolver.command.ts | 7 +- .../disconnect-step-resolver.usecase.ts | 29 +- .../execute-step-resolver-request.usecase.ts | 33 +- .../src/usecases/preview/preview.usecase.ts | 11 +- .../preview/utils/preview-error-handler.ts | 95 ++++-- .../upsert-workflow.usecase.ts | 42 +-- .../src/utils/step-resolver-control-state.ts | 64 +--- .../src/models/components/emailcontroldto.ts | 24 -- .../models/components/emailstepresponsedto.ts | 21 -- packages/framework/src/index.ts | 9 +- .../src/resources/step-resolver/step.ts | 168 +++++++++- packages/framework/src/step-resolver.ts | 10 +- packages/novu/src/commands/email/index.ts | 1 - .../__snapshots__/step-file.spec.ts.snap | 81 ----- .../src/commands/email/templates/index.ts | 1 - .../email/templates/step-file.spec.ts | 31 -- .../src/commands/email/templates/step-file.ts | 36 -- .../templates/no-default-export.tsx | 0 .../__fixtures__/templates/no-react-email.tsx | 0 .../templates/should-be-ignored.test.tsx | 0 .../__fixtures__/templates/test-file.test.tsx | 0 .../__fixtures__/templates/test-template.tsx | 0 .../__fixtures__/templates/valid-template.tsx | 0 .../commands/{email => step}/api/client.ts | 21 ++ .../src/commands/{email => step}/api/index.ts | 0 .../{email => step}/bundler/bundler.spec.ts | 0 .../{email => step}/bundler/bundler.ts | 0 .../{email => step}/bundler/config.spec.ts | 0 .../{email => step}/bundler/config.ts | 0 .../commands/{email => step}/bundler/index.ts | 0 .../bundler/schema-extractor.ts | 0 .../commands/{email => step}/config/index.ts | 0 .../commands/{email => step}/config/loader.ts | 0 .../{email => step}/config/schema.spec.ts | 0 .../commands/{email => step}/config/schema.ts | 0 .../discovery/email-template-discovery.ts | 0 .../{email => step}/discovery/index.ts | 0 .../discovery/step-discovery.spec.ts | 27 +- .../discovery/step-discovery.ts | 21 +- packages/novu/src/commands/step/index.ts | 1 + .../src/commands/{email => step}/publish.ts | 315 +++++++++++------- .../__snapshots__/step-file.spec.ts.snap | 109 ++++++ .../__snapshots__/worker-wrapper.spec.ts.snap | 89 ++++- .../novu/src/commands/step/templates/index.ts | 9 + .../commands/step/templates/step-file.spec.ts | 84 +++++ .../src/commands/step/templates/step-file.ts | 94 ++++++ .../templates/worker-wrapper.spec.ts | 0 .../templates/worker-wrapper.ts | 40 ++- .../src/commands/{email => step}/types.ts | 1 + .../{email => step}/utils/environment.ts | 0 .../{email => step}/utils/file-paths.ts | 16 + .../commands/{email => step}/utils/index.ts | 0 .../{email => step}/utils/package-manager.ts | 0 .../commands/{email => step}/utils/spinner.ts | 0 .../commands/{email => step}/utils/table.ts | 0 packages/novu/src/index.ts | 17 +- packages/shared/src/dto/workflows/step.dto.ts | 1 - pnpm-lock.yaml | 235 ++++++------- 96 files changed, 1644 insertions(+), 1095 deletions(-) create mode 100644 apps/api/src/app/step-resolvers/dtos/disconnect-step-resolver-request.dto.ts create mode 100644 apps/dashboard/src/api/step-resolvers.ts delete mode 100644 apps/dashboard/src/components/workflow-editor/steps/email/email-body-react-email.tsx delete mode 100644 apps/dashboard/src/components/workflow-editor/steps/email/email-renderer-select.tsx delete mode 100644 apps/dashboard/src/components/workflow-editor/steps/email/use-react-email-step-hint.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/shared/step-editor-mode-toggle.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-active-panel.tsx rename apps/dashboard/src/components/workflow-editor/steps/{email/react-email-not-published.tsx => shared/step-resolver-not-published.tsx} (52%) create mode 100644 apps/dashboard/src/components/workflow-editor/steps/shared/use-step-resolver-hint.tsx create mode 100644 apps/dashboard/src/hooks/use-disconnect-step-resolver.ts rename apps/dashboard/src/hooks/{use-react-email-polling.ts => use-step-resolver-polling.ts} (78%) delete mode 100644 packages/novu/src/commands/email/index.ts delete mode 100644 packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap delete mode 100644 packages/novu/src/commands/email/templates/index.ts delete mode 100644 packages/novu/src/commands/email/templates/step-file.spec.ts delete mode 100644 packages/novu/src/commands/email/templates/step-file.ts rename packages/novu/src/commands/{email => step}/__fixtures__/templates/no-default-export.tsx (100%) rename packages/novu/src/commands/{email => step}/__fixtures__/templates/no-react-email.tsx (100%) rename packages/novu/src/commands/{email => step}/__fixtures__/templates/should-be-ignored.test.tsx (100%) rename packages/novu/src/commands/{email => step}/__fixtures__/templates/test-file.test.tsx (100%) rename packages/novu/src/commands/{email => step}/__fixtures__/templates/test-template.tsx (100%) rename packages/novu/src/commands/{email => step}/__fixtures__/templates/valid-template.tsx (100%) rename packages/novu/src/commands/{email => step}/api/client.ts (93%) rename packages/novu/src/commands/{email => step}/api/index.ts (100%) rename packages/novu/src/commands/{email => step}/bundler/bundler.spec.ts (100%) rename packages/novu/src/commands/{email => step}/bundler/bundler.ts (100%) rename packages/novu/src/commands/{email => step}/bundler/config.spec.ts (100%) rename packages/novu/src/commands/{email => step}/bundler/config.ts (100%) rename packages/novu/src/commands/{email => step}/bundler/index.ts (100%) rename packages/novu/src/commands/{email => step}/bundler/schema-extractor.ts (100%) rename packages/novu/src/commands/{email => step}/config/index.ts (100%) rename packages/novu/src/commands/{email => step}/config/loader.ts (100%) rename packages/novu/src/commands/{email => step}/config/schema.spec.ts (100%) rename packages/novu/src/commands/{email => step}/config/schema.ts (100%) rename packages/novu/src/commands/{email => step}/discovery/email-template-discovery.ts (100%) rename packages/novu/src/commands/{email => step}/discovery/index.ts (100%) rename packages/novu/src/commands/{email => step}/discovery/step-discovery.spec.ts (90%) rename packages/novu/src/commands/{email => step}/discovery/step-discovery.ts (92%) create mode 100644 packages/novu/src/commands/step/index.ts rename packages/novu/src/commands/{email => step}/publish.ts (70%) create mode 100644 packages/novu/src/commands/step/templates/__snapshots__/step-file.spec.ts.snap rename packages/novu/src/commands/{email => step}/templates/__snapshots__/worker-wrapper.spec.ts.snap (73%) create mode 100644 packages/novu/src/commands/step/templates/index.ts create mode 100644 packages/novu/src/commands/step/templates/step-file.spec.ts create mode 100644 packages/novu/src/commands/step/templates/step-file.ts rename packages/novu/src/commands/{email => step}/templates/worker-wrapper.spec.ts (100%) rename packages/novu/src/commands/{email => step}/templates/worker-wrapper.ts (76%) rename packages/novu/src/commands/{email => step}/types.ts (97%) rename packages/novu/src/commands/{email => step}/utils/environment.ts (100%) rename packages/novu/src/commands/{email => step}/utils/file-paths.ts (69%) rename packages/novu/src/commands/{email => step}/utils/index.ts (100%) rename packages/novu/src/commands/{email => step}/utils/package-manager.ts (100%) rename packages/novu/src/commands/{email => step}/utils/spinner.ts (100%) rename packages/novu/src/commands/{email => step}/utils/table.ts (100%) diff --git a/apps/api/src/app/step-resolvers/dtos/deploy-step-resolver-request.dto.ts b/apps/api/src/app/step-resolvers/dtos/deploy-step-resolver-request.dto.ts index 2b785f23084..5ab5711a83d 100644 --- a/apps/api/src/app/step-resolvers/dtos/deploy-step-resolver-request.dto.ts +++ b/apps/api/src/app/step-resolvers/dtos/deploy-step-resolver-request.dto.ts @@ -1,7 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { parseSlugId } from '@novu/application-generic'; +import { StepTypeEnum } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; export class DeployStepResolverManifestStepDto { @ApiProperty({ @@ -21,6 +31,15 @@ export class DeployStepResolverManifestStepDto { @IsNotEmpty() stepId: string; + @ApiProperty({ + description: 'Channel step type', + enum: StepTypeEnum, + example: StepTypeEnum.EMAIL, + }) + @IsEnum(StepTypeEnum) + @IsNotEmpty() + stepType: StepTypeEnum; + @ApiPropertyOptional({ description: 'JSON Schema describing the control inputs for this step', type: 'object', @@ -46,7 +65,7 @@ export class DeployStepResolverManifestDto { export class DeployStepResolverRequestDto { @ApiProperty({ description: 'JSON-serialized step resolver manifest', - example: '{"steps":[{"workflowId":"welcome-email","stepId":"welcome"}]}', + example: '{"steps":[{"workflowId":"welcome-email","stepId":"welcome","stepType":"email"}]}', }) @IsString() @IsNotEmpty() diff --git a/apps/api/src/app/step-resolvers/dtos/disconnect-step-resolver-request.dto.ts b/apps/api/src/app/step-resolvers/dtos/disconnect-step-resolver-request.dto.ts new file mode 100644 index 00000000000..e4c0a96656a --- /dev/null +++ b/apps/api/src/app/step-resolvers/dtos/disconnect-step-resolver-request.dto.ts @@ -0,0 +1,8 @@ +import { StepTypeEnum } from '@novu/shared'; +import { IsEnum, IsNotEmpty } from 'class-validator'; + +export class DisconnectStepResolverRequestDto { + @IsEnum(StepTypeEnum) + @IsNotEmpty() + stepType: StepTypeEnum; +} diff --git a/apps/api/src/app/step-resolvers/dtos/index.ts b/apps/api/src/app/step-resolvers/dtos/index.ts index 0438d91284c..4712c4c0094 100644 --- a/apps/api/src/app/step-resolvers/dtos/index.ts +++ b/apps/api/src/app/step-resolvers/dtos/index.ts @@ -1,2 +1,3 @@ export * from './deploy-step-resolver-request.dto'; export * from './deploy-step-resolver-response.dto'; +export * from './disconnect-step-resolver-request.dto'; diff --git a/apps/api/src/app/step-resolvers/step-resolvers.controller.ts b/apps/api/src/app/step-resolvers/step-resolvers.controller.ts index 47ab635f993..6a4d2232419 100644 --- a/apps/api/src/app/step-resolvers/step-resolvers.controller.ts +++ b/apps/api/src/app/step-resolvers/step-resolvers.controller.ts @@ -3,20 +3,32 @@ import { Body, ClassSerializerInterceptor, Controller, + Delete, + Param, Post, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiExcludeController } from '@nestjs/swagger'; -import { ExternalApiAccessible, RequirePermissions } from '@novu/application-generic'; +import { + DisconnectStepResolverCommand, + DisconnectStepResolverUsecase, + ExternalApiAccessible, + RequirePermissions, +} from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; import { plainToInstance } from 'class-transformer'; import { ValidationError, validateSync } from 'class-validator'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { UserSession } from '../shared/framework/user.decorator'; -import { DeployStepResolverManifestDto, DeployStepResolverRequestDto, DeployStepResolverResponseDto } from './dtos'; +import { + DeployStepResolverManifestDto, + DeployStepResolverRequestDto, + DeployStepResolverResponseDto, + DisconnectStepResolverRequestDto, +} from './dtos'; import { DeployStepResolverCommand, DeployStepResolverUsecase } from './usecases/deploy-step-resolver'; interface UploadedBundleFile { @@ -32,7 +44,10 @@ interface UploadedBundleFile { @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @RequireAuthentication() export class StepResolversController { - constructor(private deployStepResolverUsecase: DeployStepResolverUsecase) {} + constructor( + private deployStepResolverUsecase: DeployStepResolverUsecase, + private disconnectStepResolverUsecase: DisconnectStepResolverUsecase + ) {} @Post('/deploy') @ExternalApiAccessible() @@ -68,6 +83,23 @@ export class StepResolversController { }) ); } + + @Delete('/:stepInternalId/disconnect') + @ExternalApiAccessible() + @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) + async disconnect( + @UserSession() user: UserSessionData, + @Param('stepInternalId') stepInternalId: string, + @Body() body: DisconnectStepResolverRequestDto + ): Promise { + await this.disconnectStepResolverUsecase.execute( + DisconnectStepResolverCommand.create({ + stepInternalId, + stepType: body.stepType, + user, + }) + ); + } } function parseManifestOrThrow(rawManifest: string): DeployStepResolverManifestDto { diff --git a/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.command.ts b/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.command.ts index 7d88735ed4a..fadf89abcdf 100644 --- a/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.command.ts +++ b/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.command.ts @@ -1,9 +1,11 @@ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; +import { StepTypeEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, IsDefined, + IsEnum, IsNotEmpty, IsObject, IsOptional, @@ -20,6 +22,10 @@ export class DeployStepResolverManifestStepCommand { @IsNotEmpty() stepId: string; + @IsEnum(StepTypeEnum) + @IsNotEmpty() + stepType: StepTypeEnum; + @IsOptional() @IsObject() controlSchema?: Record; diff --git a/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.usecase.ts b/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.usecase.ts index 4986d369e76..e9243218ff4 100644 --- a/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.usecase.ts +++ b/apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.usecase.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, ForbiddenException, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { FeatureFlagsService, GetWorkflowByIdsCommand, @@ -6,12 +6,10 @@ import { getStepResolverControlSchema, InstrumentUsecase, PinoLogger, - REACT_EMAIL_STEP_RESOLVER_DEFAULTS, reconcileStepResolverControlValues, - STEP_RESOLVER_EMAIL_UI_SCHEMA, } from '@novu/application-generic'; import { ClientSession, ControlValuesEntity, ControlValuesRepository, MessageTemplateRepository } from '@novu/dal'; -import { ControlValuesLevelEnum, FeatureFlagsKeysEnum } from '@novu/shared'; +import { ControlValuesLevelEnum, FeatureFlagsKeysEnum, StepTypeEnum } from '@novu/shared'; import { createHash } from 'crypto'; import { DeployStepResolverResponseDto } from '../../dtos'; import { CloudflareStepResolverDeployService } from '../../services/cloudflare-step-resolver-deploy.service'; @@ -23,12 +21,22 @@ const MAX_BUNDLE_SIZE_BYTES = 10 * 1024 * 1024; const STEP_RESOLVER_HASH_ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; const STEP_RESOLVER_HASH_LENGTH = 10; +const SUPPORTED_STEP_RESOLVER_TYPES = new Set([ + StepTypeEnum.EMAIL, + StepTypeEnum.SMS, + StepTypeEnum.CHAT, + StepTypeEnum.PUSH, + StepTypeEnum.IN_APP, +]); + interface ResolvedManifestStep { workflowId: string; workflowInternalId: string; stepId: string; stepInternalId: string; + stepType: StepTypeEnum; controlSchema: Record; + existingStepResolverHash: string | undefined; existingControlValues: ControlValuesEntity | null; } @@ -61,8 +69,6 @@ export class DeployStepResolverUsecase { const resolvedManifestSteps = await this.resolveManifestSteps(command, command.manifestSteps); - this.assertNoRendererConflicts(resolvedManifestSteps); - const stepResolverHash = this.generateStepResolverHash(command.bundleBuffer); const workerId = generateStepResolverWorkerId(command.user.organizationId, stepResolverHash); @@ -131,12 +137,32 @@ export class DeployStepResolverUsecase { }); } + const actualStepType = step.template?.type; + + if (!actualStepType || !SUPPORTED_STEP_RESOLVER_TYPES.has(actualStepType)) { + throw new BadRequestException({ + message: `Step type '${actualStepType ?? 'unknown'}' is not supported for step resolvers`, + workflowId: manifestStep.workflowId, + stepId: manifestStep.stepId, + }); + } + + if (actualStepType !== manifestStep.stepType) { + throw new BadRequestException({ + message: `Manifest stepType '${manifestStep.stepType}' does not match the actual step type '${actualStepType}'`, + workflowId: manifestStep.workflowId, + stepId: manifestStep.stepId, + }); + } + partialSteps.push({ workflowId: manifestStep.workflowId, workflowInternalId: String(workflow._id), stepId: manifestStep.stepId, stepInternalId: String(step._templateId), + stepType: actualStepType, controlSchema: getStepResolverControlSchema(manifestStep.controlSchema), + existingStepResolverHash: step.template?.stepResolverHash ?? undefined, }); } @@ -180,11 +206,10 @@ export class DeployStepResolverUsecase { session: ClientSession | null ): Promise { for (const step of resolvedSteps) { - // Keep values that still match the current resolver schema and drop stale ones from previous deploys. - const mergedControls = { - ...reconcileStepResolverControlValues(this.readControlObject(step.existingControlValues), step.controlSchema), - ...REACT_EMAIL_STEP_RESOLVER_DEFAULTS, - }; + const mergedControls = reconcileStepResolverControlValues( + this.readControlObject(step.existingControlValues), + step.controlSchema + ); if (step.existingControlValues) { await this.controlValuesRepository.update( @@ -223,12 +248,7 @@ export class DeployStepResolverUsecase { for (const step of resolvedSteps) { await this.messageTemplateRepository.update( { _id: step.stepInternalId, _environmentId: command.user.environmentId }, - { - $set: { - 'controls.schema': step.controlSchema, - 'controls.uiSchema': STEP_RESOLVER_EMAIL_UI_SCHEMA, - }, - }, + { $set: { 'controls.schema': step.controlSchema }, $unset: { 'controls.uiSchema': 1 } }, { session } ); } @@ -271,25 +291,6 @@ export class DeployStepResolverUsecase { return output; } - private assertNoRendererConflicts(resolvedSteps: ResolvedManifestStep[]): void { - const conflictingSteps = resolvedSteps.filter((step) => { - if (!step.existingControlValues) return false; - - const controls = step.existingControlValues.controls; - if (!isPlainObject(controls)) return true; - - return controls.rendererType !== 'react-email'; - }); - - if (conflictingSteps.length === 0) return; - - throw new ConflictException({ - message: `Publishing blocked: ${conflictingSteps.length} step(s) are not using React Email. To protect existing email content from being overwritten, switch each affected step to React Email in the Novu dashboard before publishing.`, - errorCode: 'STEP_RENDERER_CONFLICT', - conflictingSteps: conflictingSteps.map((s) => ({ workflowId: s.workflowId, stepId: s.stepId })), - }); - } - private assertBundleSize(bundleBuffer: Buffer): void { if (bundleBuffer.byteLength <= MAX_BUNDLE_SIZE_BYTES) { return; diff --git a/apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/sync-step-resolver-to-environment.usecase.ts b/apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/sync-step-resolver-to-environment.usecase.ts index 0b3d20b8a4b..7944ee1b7a5 100644 --- a/apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/sync-step-resolver-to-environment.usecase.ts +++ b/apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/sync-step-resolver-to-environment.usecase.ts @@ -1,10 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - getStepResolverControlSchema, - InstrumentUsecase, - STEP_RESOLVER_EMAIL_UI_SCHEMA, - stepTypeToControlSchema, -} from '@novu/application-generic'; +import { getStepResolverControlSchema, InstrumentUsecase, stepTypeToControlSchema } from '@novu/application-generic'; import { ClientSession, MessageTemplateRepository } from '@novu/dal'; import { StepResolverSourceData, @@ -72,8 +67,8 @@ export class SyncStepResolverToEnvironmentUsecase { $set: { stepResolverHash: sourceStep.stepResolverHash, 'controls.schema': getStepResolverControlSchema(sourceStep.controlSchema), - 'controls.uiSchema': STEP_RESOLVER_EMAIL_UI_SCHEMA, }, + $unset: { 'controls.uiSchema': 1 }, }, { session } ); diff --git a/apps/api/src/app/workflows-v2/usecases/preview/preview.usecase.ts b/apps/api/src/app/workflows-v2/usecases/preview/preview.usecase.ts index 49dd0464312..c1c1f892759 100644 --- a/apps/api/src/app/workflows-v2/usecases/preview/preview.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/preview/preview.usecase.ts @@ -9,7 +9,7 @@ import { GetWorkflowByIdsUseCase, Instrument, InstrumentUsecase, - isStepResolverEmailStep, + isStepResolverActive, PinoLogger, PreviewCommand, PreviewErrorHandler, @@ -20,7 +20,7 @@ import { StepResponseDto, } from '@novu/application-generic'; import { ContextResolved } from '@novu/framework/internal'; -import { ChannelTypeEnum, ResourceOriginEnum } from '@novu/shared'; +import { ChannelTypeEnum, ResourceOriginEnum, StepTypeEnum } from '@novu/shared'; import { PayloadMergerService } from './services/payload-merger.service'; @Injectable() @@ -43,9 +43,10 @@ export class PreviewUsecase { const context = await this.initializePreviewContext(command); const stepResolverHash = typeof context.stepData.stepResolverHash === 'string' ? context.stepData.stepResolverHash : undefined; - const isStepResolverEmail = isStepResolverEmailStep(context.stepData.type, stepResolverHash); + const isStepResolver = isStepResolverActive(stepResolverHash); + const isStepResolverEmail = isStepResolver && context.stepData.type === StepTypeEnum.EMAIL; - const sanitizedControls = isStepResolverEmail + const sanitizedControls = isStepResolver ? context.controlValues : this.controlValueSanitizer.sanitizeControlsForPreview( context.controlValues, @@ -94,7 +95,12 @@ export class PreviewUsecase { * but with an empty preview result. * For step resolver email steps, since its a runtime error, surface the error * as HTML rendered in the preview panel. + * For all other resolver step types, log the error so it's visible in server logs. */ + if (isStepResolver) { + this.logger.error({ error, stepType: context.stepData.type }, 'Step resolver preview execution failed'); + } + const previewResult = isStepResolverEmail ? { subject: '', body: this.errorHandler.buildPreviewErrorHtml(error) } : {}; 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 b05f6ee850b..1e49044d4ab 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -683,7 +683,6 @@ describe('Workflow Controller E2E API Testing #novu-v2', () => { editorType: 'html', subject: 'Example subject', disableOutputSanitization: false, - rendererType: 'html', }, }), id: devWorkflow.steps[0].id, @@ -729,7 +728,6 @@ describe('Workflow Controller E2E API Testing #novu-v2', () => { subject: 'Example subject', disableOutputSanitization: false, editorType: 'html', - rendererType: 'html', }); // Verify new created step diff --git a/apps/dashboard/src/api/step-resolvers.ts b/apps/dashboard/src/api/step-resolvers.ts new file mode 100644 index 00000000000..ef8fb53a920 --- /dev/null +++ b/apps/dashboard/src/api/step-resolvers.ts @@ -0,0 +1,17 @@ +import { IEnvironment, StepTypeEnum } from '@novu/shared'; +import { delV2 } from './api.client'; + +export const disconnectStepResolver = async ({ + environment, + stepInternalId, + stepType, +}: { + environment: IEnvironment; + stepInternalId: string; + stepType: StepTypeEnum; +}): Promise => { + await delV2(`/step-resolvers/${stepInternalId}/disconnect`, { + environment, + body: { stepType }, + }); +}; diff --git a/apps/dashboard/src/components/email-editor-select.tsx b/apps/dashboard/src/components/email-editor-select.tsx index 274d8552c16..6cabe3739c2 100644 --- a/apps/dashboard/src/components/email-editor-select.tsx +++ b/apps/dashboard/src/components/email-editor-select.tsx @@ -19,11 +19,10 @@ export const EmailEditorSelect = ({ }) => Promise; disabled?: boolean; }) => { - const { control, setValue } = useFormContext(); + const { control } = useFormContext(); const [isSwitchingToHtml, setIsSwitchingToHtml] = useState(false); const [isSwitchingToBlock, setIsSwitchingToBlock] = useState(false); const body = useWatch({ name: 'body', control }); - const rendererType = useWatch({ name: 'rendererType', control }); return ( { - if (value === 'block' && rendererType === 'react-email') { - // react-email has no "body" field to check - setIsSwitchingToBlock(true); - return; - } - - // allow freely switching if the body is empty string or maily json if (!body || body === '' || isEmptyMailyJson(body)) { field.onChange(value); + return; } if (value === 'html') { setIsSwitchingToHtml(true); + return; } @@ -71,7 +65,7 @@ export const EmailEditorSelect = ({ { + onConfirm={() => { field.onChange('html'); saveForm?.({ editorType: 'html', onSuccess: () => setIsSwitchingToHtml(false) }); }} @@ -84,20 +78,12 @@ export const EmailEditorSelect = ({ open={isSwitchingToBlock} onOpenChange={setIsSwitchingToBlock} onConfirm={() => { - if (rendererType === 'react-email') { - setValue('rendererType', 'html', { shouldDirty: true }); - } - field.onChange('block'); saveForm?.({ editorType: 'block', onSuccess: () => setIsSwitchingToBlock(false) }); }} - title={rendererType === 'react-email' ? 'Disconnect React Email template?' : 'Are you sure?'} - description={ - rendererType === 'react-email' - ? "Switching away will remove the connection to your deployed React Email template. You'll need to run novu email publish again to reconnect." - : "Switching to visual mode will reset your code. You'll start fresh with blocks. Sure you want to do that?" - } - confirmButtonText={rendererType === 'react-email' ? 'Disconnect' : 'Proceed'} + title="Are you sure?" + description="Switching to visual mode will reset your code. You'll start fresh with blocks. Sure you want to do that?" + confirmButtonText="Proceed" isLoading={isLoading} /> diff --git a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx index 7482ed8f8d7..2f24d49b906 100644 --- a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx +++ b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx @@ -1,6 +1,6 @@ +import { RiAngularjsFill, RiJavascriptFill, RiNextjsFill, RiReactjsFill, RiRemixRunFill } from 'react-icons/ri'; import { API_HOSTNAME, IS_EU } from '@/config'; import { apiHostnameManager } from '@/utils/api-hostname-manager'; -import { RiAngularjsFill, RiJavascriptFill, RiNextjsFill, RiReactjsFill, RiRemixRunFill } from 'react-icons/ri'; import { Language } from '../primitives/code-block'; import { getFrameworkPrompt } from './ai-prompts/simple-prompt-getter'; diff --git a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx index e9eb907e2f0..a854a3975c5 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx @@ -1,10 +1,7 @@ import { EnvironmentTypeEnum, type UiSchema } from '@novu/shared'; - import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { TabsSection } from '@/components/workflow-editor/steps/tabs-section'; import { useEnvironment } from '@/context/environment/hooks'; - -import { cn } from '../../../../utils/ui'; import { StepEditorUnavailable } from '../step-editor-unavailable'; type ChatEditorProps = { uiSchema: UiSchema }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx index 1313f19bb7b..25805e37e19 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx @@ -22,7 +22,6 @@ import { useWorkflow } from '../workflow-provider'; import { BaseBody } from './base/base-body'; import { BaseSubject } from './base/base-subject'; import { DataObject } from './base/data-object'; -import { EmailRendererSelect } from './email/email-renderer-select'; import { LayoutSelect } from './email/layout-select'; import { useSaveForm } from './save-form-context'; import { BypassSanitizationSwitch } from './shared/bypass-sanitization-switch'; @@ -128,10 +127,6 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum return ; } - case UiComponentEnum.EMAIL_RENDERER_SELECT: { - return ; - } - case UiComponentEnum.LAYOUT_SELECT: { return ; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/context/step-editor-context.tsx b/apps/dashboard/src/components/workflow-editor/steps/context/step-editor-context.tsx index 2725538913d..769f26ac518 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/context/step-editor-context.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/context/step-editor-context.tsx @@ -5,7 +5,7 @@ import { StepResponseDto, WorkflowResponseDto, } from '@novu/shared'; -import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useEditorPreview } from '@/components/workflow-editor/steps/use-editor-preview'; import { useFetchOrganizationSettings } from '@/hooks/use-fetch-organization-settings'; @@ -22,6 +22,8 @@ type StepEditorContextType = { isSubsequentLoad: boolean; isNovuCloud: boolean; isStepEditable: boolean; + isPendingResolverActivation: boolean; + setIsPendingResolverActivation: (value: boolean) => void; selectedLocale: string; setSelectedLocale: (locale: string) => void; }; @@ -42,6 +44,15 @@ export function StepEditorProvider({ children, workflow, step }: StepEditorProvi // Only initialize selectedLocale when organization settings are loaded const organizationDefaultLocale = organizationSettings?.data?.defaultLocale || DEFAULT_LOCALE; const [selectedLocale, setSelectedLocale] = useState(organizationDefaultLocale); + const [isPendingResolverActivation, setIsPendingResolverActivationState] = useState(false); + + const setIsPendingResolverActivation = useCallback((value: boolean) => { + setIsPendingResolverActivationState(value); + }, []); + + useEffect(() => { + setIsPendingResolverActivationState(false); + }, [workflow.workflowId, step.stepId]); // Update locale when organization settings first load useEffect(() => { @@ -59,7 +70,8 @@ export function StepEditorProvider({ children, workflow, step }: StepEditorProvi const { uiSchema } = step.controls; const isNovuCloud = workflow.origin === ResourceOriginEnum.NOVU_CLOUD && Boolean(uiSchema); const isExternal = workflow.origin === ResourceOriginEnum.EXTERNAL; - const isStepEditable = isExternal || (isNovuCloud && Boolean(uiSchema)); + const isStepEditable = + isExternal || (isNovuCloud && Boolean(uiSchema)) || Boolean(step.stepResolverHash) || isPendingResolverActivation; const isInitialLoad = isPreviewPending; const isSubsequentLoad = isFetching && !isPreviewPending; @@ -77,6 +89,8 @@ export function StepEditorProvider({ children, workflow, step }: StepEditorProvi isSubsequentLoad, isNovuCloud, isStepEditable, + isPendingResolverActivation, + setIsPendingResolverActivation, selectedLocale, setSelectedLocale, }), @@ -92,6 +106,8 @@ export function StepEditorProvider({ children, workflow, step }: StepEditorProvi isSubsequentLoad, isNovuCloud, isStepEditable, + isPendingResolverActivation, + setIsPendingResolverActivation, selectedLocale, setSelectedLocale, ] diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx index ffccadd6fb3..4e6baec23d6 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx @@ -3,6 +3,7 @@ import { RJSFSchema } from '@rjsf/utils'; import isEqual from 'lodash.isequal'; import { motion } from 'motion/react'; import { useEffect, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; import { RiBookMarkedLine, RiInputField, RiQuestionLine } from 'react-icons/ri'; import { Link } from 'react-router-dom'; @@ -33,6 +34,8 @@ export const CustomStepControls = (props: CustomStepControlsProps) => { const [isRestoreDefaultModalOpen, setIsRestoreDefaultModalOpen] = useState(false); const { step, workflow, update } = useWorkflow(); const { saveForm } = useSaveForm(); + const { control } = useFormContext(); + const watchedValues = useWatch({ control }); const dataSchemaDefaults = buildDefaultValuesOfDataSchema(step?.controls.dataSchema ?? {}); const dbValues = step?.controls.values ?? {}; @@ -168,7 +171,7 @@ export const CustomStepControls = (props: CustomStepControlsProps) => { !isOverridden && 'opacity-60 pointer-events-none' )} > - + diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/select-widget.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/select-widget.tsx index b8738e05bce..7c387339500 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/select-widget.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/select-widget.tsx @@ -8,7 +8,7 @@ import { capitalize } from '@/utils/string'; import { getFieldName } from './template-utils'; export function SelectWidget(props: WidgetProps) { - const { label, required, readonly, options, disabled, id } = props; + const { label, required, readonly, options, disabled, id, value: rjsfValue } = props; const data = useMemo( () => @@ -29,6 +29,7 @@ export function SelectWidget(props: WidgetProps) { ( {capitalize(label)} diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx index 1cb4abb0767..66faf17dff9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx @@ -10,7 +10,7 @@ import { capitalize } from '@/utils/string'; import { getFieldName } from './template-utils'; export function TextWidget(props: WidgetProps) { - const { label, readonly, disabled, id, required } = props; + const { label, readonly, disabled, id, required, value: rjsfValue, onChange: rjsfOnChange } = props; const { control } = useFormContext(); const { step, digestStepBeforeCurrent } = useWorkflow(); const { variables, isAllowedVariable } = useParseVariables(step?.variables, digestStepBeforeCurrent?.stepId); @@ -22,53 +22,69 @@ export function TextWidget(props: WidgetProps) { ( - - {capitalize(label)} - - {isNumberType ? ( - { - if (e.target.value === '') { - field.onChange(''); - return; - } + defaultValue={rjsfValue ?? ''} + render={({ field, fieldState }) => { + let stringValue = ''; - const val = Number(e.target.value); - const isNaN = Number.isNaN(val); - const finalValue = isNaN ? '' : val; - field.onChange(finalValue); - }} - required={required} - readOnly={readonly} - disabled={disabled} - placeholder={capitalize(label)} - /> - ) : ( - - - - - - )} - - - - )} + if (typeof field.value === 'string') { + stringValue = field.value; + } else if (typeof rjsfValue === 'string') { + stringValue = rjsfValue; + } + + return ( + + {capitalize(label)} + + {isNumberType ? ( + { + if (e.target.value === '') { + field.onChange(''); + rjsfOnChange(''); + return; + } + + const val = Number(e.target.value); + const isNaN = Number.isNaN(val); + const finalValue = isNaN ? '' : val; + field.onChange(finalValue); + rjsfOnChange(finalValue); + }} + required={required} + readOnly={readonly} + disabled={disabled} + placeholder={capitalize(label)} + /> + ) : ( + + + { + field.onChange(val); + rjsfOnChange(val); + }} + variables={variables} + isAllowedVariable={isAllowedVariable} + size="sm" + readOnly={readonly} + disabled={disabled} + /> + + + )} + + + + ); + }} /> ); } diff --git a/apps/dashboard/src/components/workflow-editor/steps/editor/step-editor-factory.tsx b/apps/dashboard/src/components/workflow-editor/steps/editor/step-editor-factory.tsx index cb0a28a12b6..35db15010b5 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/editor/step-editor-factory.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/editor/step-editor-factory.tsx @@ -1,4 +1,5 @@ -import { ResourceOriginEnum, StepTypeEnum } from '@novu/shared'; +import { FeatureFlagsKeysEnum, ResourceOriginEnum, StepTypeEnum } from '@novu/shared'; +import { useCallback } from 'react'; import { ChatEditor } from '@/components/workflow-editor/steps/chat/chat-editor'; import { useStepEditor } from '@/components/workflow-editor/steps/context/step-editor-context'; import { CustomStepControls } from '@/components/workflow-editor/steps/controls/custom-step-controls'; @@ -6,18 +7,43 @@ import { EmailEditor } from '@/components/workflow-editor/steps/email/email-edit import { HttpRequestEditor } from '@/components/workflow-editor/steps/http-request/http-request-editor'; import { InAppEditor } from '@/components/workflow-editor/steps/in-app/in-app-editor'; import { PushEditor } from '@/components/workflow-editor/steps/push/push-editor'; +import { StepResolverActivePanel } from '@/components/workflow-editor/steps/shared/step-resolver-active-panel'; +import { StepResolverNotPublished } from '@/components/workflow-editor/steps/shared/step-resolver-not-published'; import { SmsEditor } from '@/components/workflow-editor/steps/sms/sms-editor'; import { ThrottleEditor } from '@/components/workflow-editor/steps/throttle/throttle-editor'; -import { STEP_TYPE_LABELS } from '@/utils/constants'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { useStepResolverPolling } from '@/hooks/use-step-resolver-polling'; +import { STEP_TYPE_LABELS, TEMPLATE_CONFIGURABLE_STEP_TYPES } from '@/utils/constants'; function NoEditorAvailable({ message }: { message: string }) { return
{message}
; } export function StepEditorFactory() { - const { workflow, step, isStepEditable } = useStepEditor(); + const { workflow, step, isStepEditable, isPendingResolverActivation } = useStepEditor(); + const { refetch } = useWorkflow(); + const isStepResolverEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED); const { dataSchema, uiSchema } = step.controls || {}; + const onHashChange = useCallback(() => { + refetch(); + }, [refetch]); + + useStepResolverPolling({ + enabled: isStepResolverEnabled && TEMPLATE_CONFIGURABLE_STEP_TYPES.includes(step.type), + stepResolverHash: step.stepResolverHash, + onHashChange, + }); + + if (step.stepResolverHash) { + return ; + } + + if (isPendingResolverActivation) { + return ; + } + if (!isStepEditable) { return ; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-body-react-email.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-body-react-email.tsx deleted file mode 100644 index ab6197d0ec1..00000000000 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-body-react-email.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ResourceOriginEnum } from '@/utils/enums'; -import { useWorkflow } from '../../workflow-provider'; -import { CustomStepControls } from '../controls/custom-step-controls'; -import { ReactEmailNotPublished } from './react-email-not-published'; - -export const EmailBodyReactEmail = () => { - const { step, workflow } = useWorkflow(); - - if (!step?.stepResolverHash) { - return ; - } - - // Step executes remotely via a deployed Cloudflare Worker — treat as EXTERNAL to show the override toggle UI. - return ( -
- -
- ); -}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-body.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-body.tsx index 931f703bc6f..73100ba34c5 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-body.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-body.tsx @@ -1,27 +1,10 @@ -import { FeatureFlagsKeysEnum } from '@novu/shared'; import { useFormContext, useWatch } from 'react-hook-form'; -import { useFeatureFlag } from '@/hooks/use-feature-flag'; -import { useReactEmailPolling } from '@/hooks/use-react-email-polling'; -import { useWorkflow } from '../../workflow-provider'; import { EmailBodyHtml } from './email-body-html'; import { EmailBodyMaily } from './email-body-maily'; -import { EmailBodyReactEmail } from './email-body-react-email'; export const EmailBody = () => { const { control } = useFormContext(); - const { step } = useWorkflow(); const editorType = useWatch({ name: 'editorType', control }); - const rendererType = useWatch({ name: 'rendererType', control }); - const isStepResolverEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED); - - useReactEmailPolling({ - stepResolverHash: step?.stepResolverHash, - isReactEmailMode: editorType === 'html' && rendererType === 'react-email' && isStepResolverEnabled, - }); - - if (editorType === 'html' && rendererType === 'react-email' && isStepResolverEnabled) { - return ; - } if (editorType === 'html') { return ; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx index 1d40f8781ee..c9ad5a9d87b 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx @@ -1,58 +1,24 @@ -import { - EnvironmentTypeEnum, - FeatureFlagsKeysEnum, - UiComponentEnum, - type UiSchema, - UiSchemaGroupEnum, -} from '@novu/shared'; +import { EnvironmentTypeEnum, UiComponentEnum, type UiSchema, UiSchemaGroupEnum } from '@novu/shared'; import { useState } from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; -import { RiReactjsFill } from 'react-icons/ri'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { EmailPreviewHeader } from '@/components/workflow-editor/steps/email/email-preview'; import { SenderConfigDrawer } from '@/components/workflow-editor/steps/email/sender-config-drawer'; import { useEnvironment } from '@/context/environment/hooks'; -import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { cn } from '../../../../utils/ui'; -import { useSaveForm } from '../save-form-context'; import { StepEditorUnavailable } from '../step-editor-unavailable'; type EmailEditorProps = { uiSchema: UiSchema; isEditorV2?: boolean }; export const EmailEditor = (props: EmailEditorProps) => { - const { currentEnvironment, readOnly } = useEnvironment(); + const { currentEnvironment } = useEnvironment(); const { uiSchema, isEditorV2 = false } = props; const [senderDrawerOpen, setSenderDrawerOpen] = useState(false); - const { control, setValue } = useFormContext(); - const { saveForm } = useSaveForm(); - const editorTypeValue = useWatch({ name: 'editorType', control }); - const rendererType = useWatch({ name: 'rendererType', control }); - const isStepResolverEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED); - - const isCodeEditor = editorTypeValue === 'html'; - const isReactEmail = isCodeEditor && isStepResolverEnabled && rendererType === 'react-email'; - const showReactEmailHint = - isCodeEditor && isStepResolverEnabled && !isReactEmail && currentEnvironment?.type === EnvironmentTypeEnum.DEV; - const canSwitchToReactEmail = currentEnvironment?.type === EnvironmentTypeEnum.DEV && !readOnly; - - const handleSwitchToReactEmail = () => { - setValue('rendererType', 'react-email'); - saveForm({ forceSubmit: true }); - }; if (uiSchema.group !== UiSchemaGroupEnum.EMAIL) { return null; } - const { - body, - subject, - disableOutputSanitization, - editorType, - rendererType: rendererTypeSchema, - layoutId, - } = uiSchema.properties ?? {}; + const { body, subject, disableOutputSanitization, editorType, layoutId } = uiSchema.properties ?? {}; return ( <> @@ -68,34 +34,14 @@ export const EmailEditor = (props: EmailEditorProps) => { - {subject && !isReactEmail && ( + {subject && (
{getComponentByType({ component: subject.component })}
)} -
- {isCodeEditor && - isStepResolverEnabled && - getComponentByType({ component: rendererTypeSchema?.component ?? UiComponentEnum.EMAIL_RENDERER_SELECT })} - {!isReactEmail && getComponentByType({ component: layoutId?.component ?? UiComponentEnum.LAYOUT_SELECT })} - {showReactEmailHint && ( - - - - - - Manage this email in code with React Email. - {canSwitchToReactEmail && ' Click to switch.'} - - - )} -
+ {layoutId && ( +
+ {getComponentByType({ component: layoutId.component ?? UiComponentEnum.LAYOUT_SELECT })} +
+ )} {currentEnvironment?.type === EnvironmentTypeEnum.DEV ? ( getComponentByType({ component: body.component }) diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-renderer-select.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-renderer-select.tsx deleted file mode 100644 index d64c7e3cf1f..00000000000 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-renderer-select.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { EnvironmentTypeEnum } from '@novu/shared'; -import { useState } from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; -import { RiGitCommitFill, RiHtml5Fill, RiReactjsFill } from 'react-icons/ri'; -import { ConfirmationModal } from '@/components/confirmation-modal'; -import { Badge, BadgeIcon } from '@/components/primitives/badge'; -import { FormField } from '@/components/primitives/form/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; -import { useEnvironment } from '@/context/environment/hooks'; -import { useWorkflow } from '../../workflow-provider'; -import { useSaveForm } from '../save-form-context'; - -export const EmailRendererSelect = () => { - const { control, setValue } = useFormContext(); - const { currentEnvironment, readOnly } = useEnvironment(); - const { step } = useWorkflow(); - const { saveForm } = useSaveForm(); - const editorType = useWatch({ name: 'editorType', control }); - const rendererType = useWatch({ name: 'rendererType', control }); - const stepResolverHash = step?.stepResolverHash; - const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false); - const [pendingValue, setPendingValue] = useState(null); - - if (editorType !== 'html') { - return null; - } - - return ( - <> - { - const handleValueChange = (value: string) => { - if (field.value === 'react-email' && value !== 'react-email') { - setPendingValue(value); - setIsDisconnectModalOpen(true); - - return; - } - - field.onChange(value); - saveForm({ forceSubmit: true }); - }; - - return ( - - ); - }} - /> - { - if (pendingValue) { - setValue('rendererType', pendingValue as 'html' | 'react-email'); - saveForm({ forceSubmit: true }); - } - - setIsDisconnectModalOpen(false); - setPendingValue(null); - }} - title="Disconnect React Email template?" - description="Switching away will remove the connection to your deployed React Email template. You'll need to run novu email publish again to reconnect." - confirmButtonText="Disconnect" - /> - {rendererType === 'react-email' && stepResolverHash && ( - - - {stepResolverHash} - - )} - - ); -}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/use-react-email-step-hint.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/use-react-email-step-hint.tsx deleted file mode 100644 index d894fe05151..00000000000 --- a/apps/dashboard/src/components/workflow-editor/steps/email/use-react-email-step-hint.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { ExternalLink } from '@/components/shared/external-link'; -import { useStepEditor } from '@/components/workflow-editor/steps/context/step-editor-context'; -import { useFeatureFlag } from '@/hooks/use-feature-flag'; -import { useWorkflow } from '../../workflow-provider'; - -const REACT_EMAIL_DOCS_LINK = 'https://docs.novu.co/framework/content/react-email'; - -export function useReactEmailStepHint(): React.ReactNode { - const { controlValues } = useStepEditor(); - const { step } = useWorkflow(); - const isStepResolverEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED); - const values = (controlValues ?? {}) as Record; - const rendererType = values.rendererType; - - if (!isStepResolverEnabled || rendererType !== 'react-email') return undefined; - - if (!step?.stepResolverHash) { - return 'Publish your React Email workflow via the CLI to enable editing.'; - } - - return ( - <> - Step content is managed externally. Learn more - - ); -} diff --git a/apps/dashboard/src/components/workflow-editor/steps/preview/step-preview-factory.tsx b/apps/dashboard/src/components/workflow-editor/steps/preview/step-preview-factory.tsx index 5728392b99a..9903f827939 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/preview/step-preview-factory.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/preview/step-preview-factory.tsx @@ -9,7 +9,6 @@ import { PushPreview } from '@/components/workflow-editor/steps/push/push-previe import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; import { STEP_TYPE_LABELS } from '@/utils/constants'; import { EmailCorePreview } from './previews/email-preview-wrapper'; -import { ReactEmailPreviewPlaceholder } from './previews/react-email-preview-placeholder'; const NoPreviewAvailable = memo(({ stepType }: { stepType: StepTypeEnum }) => { return ( @@ -43,13 +42,6 @@ export function StepPreviewFactory() { switch (step.type) { case StepTypeEnum.EMAIL: { - const isReactEmailUnpublished = - controlValues?.rendererType === 'react-email' && controlValues?.editorType === 'html' && !step.stepResolverHash; - - if (isReactEmailUnpublished) { - return ; - } - return ( { + if (value === mode) return; + + if (value === 'code') { + setIsPendingResolverActivation(true); + } else if (isActive) { + setIsDisconnectModalOpen(true); + } else { + setIsPendingResolverActivation(false); + } + }; + + return ( + <> + { + try { + await disconnectStepResolver({ stepInternalId: step._id, stepType: step.type }); + } catch (error) { + console.error('Failed to disconnect step resolver', error); + } finally { + setIsPendingResolverActivation(false); + setIsDisconnectModalOpen(false); + } + }} + title="Switch back to Novu editor?" + description="This will remove the link to your deployed step resolver and restore native editing for this step." + confirmButtonText="Disconnect" + isLoading={isDisconnecting} + /> + + + + + Novu + + + Code + + + + + ); +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-active-panel.tsx b/apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-active-panel.tsx new file mode 100644 index 00000000000..730b9da2f1c --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-active-panel.tsx @@ -0,0 +1,17 @@ +import { ResourceOriginEnum } from '@/utils/enums'; +import { useWorkflow } from '../../workflow-provider'; +import { CustomStepControls } from '../controls/custom-step-controls'; + +export function StepResolverActivePanel() { + const { step } = useWorkflow(); + + if (!step?.stepResolverHash) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/react-email-not-published.tsx b/apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-not-published.tsx similarity index 52% rename from apps/dashboard/src/components/workflow-editor/steps/email/react-email-not-published.tsx rename to apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-not-published.tsx index 041efb9256d..7d796e8254a 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/react-email-not-published.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/shared/step-resolver-not-published.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { RiCheckLine, RiExternalLinkLine, RiFileCopyLine, RiLoaderLine } from 'react-icons/ri'; +import { RiCheckLine, RiCodeSSlashLine, RiFileCopyLine, RiLoaderLine } from 'react-icons/ri'; import { Skeleton } from '@/components/primitives/skeleton'; import { useFetchApiKeys } from '@/hooks/use-fetch-api-keys'; import { apiHostnameManager } from '@/utils/api-hostname-manager'; @@ -28,7 +28,7 @@ function buildPublishCommand({ if (multiline) { const lines = [ - `npx novu email publish \\`, + `npx novu step publish \\`, ` --workflow=${workflowId} \\`, ` --step=${stepId} \\`, ` --secret-key=${maskedKey}${apiUrlFlag ? ' \\' : ''}`, @@ -45,7 +45,7 @@ function buildPublishCommand({ ...(apiUrlFlag ? [apiUrlFlag] : []), ]; - return `npx novu email publish ${flags.join(' ')}`; + return `npx novu step publish ${flags.join(' ')}`; } function CodeBlock({ displayCommand, copyCommand }: { displayCommand: string; copyCommand: string }) { @@ -94,119 +94,86 @@ function CodeBlock({ displayCommand, copyCommand }: { displayCommand: string; co ); } -type Step = { - label: string; - description: React.ReactNode; - displayCommand: string; - copyCommand: string; -}; - -type ReactEmailNotPublishedProps = { +type StepResolverNotPublishedProps = { workflowId: string; stepId: string; }; -export const ReactEmailNotPublished = ({ workflowId, stepId }: ReactEmailNotPublishedProps) => { +export const StepResolverNotPublished = ({ workflowId, stepId }: StepResolverNotPublishedProps) => { const apiKeysQuery = useFetchApiKeys(); const secretKey = apiKeysQuery.data?.data?.[0]?.key; const currentApiUrl = apiHostnameManager.getHostname(); const apiUrl = currentApiUrl !== CLI_DEFAULT_API_URL ? currentApiUrl : null; - const fallbackPublishDisplay = [ - `npx novu email publish \\`, + const fallbackDisplay = [ + `npx novu step publish \\`, ` --workflow=${workflowId} \\`, ` --step=${stepId} \\`, ` --secret-key=${apiUrl ? ' \\' : ''}`, ...(apiUrl ? [` --api-url=${apiUrl}`] : []), ].join('\n'); - const fallbackPublishCopy = `npx novu email publish --workflow=${workflowId} --step=${stepId} --secret-key=${apiUrl ? ` --api-url=${apiUrl}` : ''}`; - - const steps: Step[] = [ - { - label: 'New to React Email?', - description: ( - <> - Scaffold a starter project, or{' '} - - browse templates - - - . - - ), - displayCommand: 'npx create-email@latest', - copyCommand: 'npx create-email@latest', - }, - { - label: 'Link your React Email template to this step', - description: ( - <> - Run this from your project root — you'll be prompted to choose a React Email template. A step file will be - scaffolded at{' '} - - novu/{workflowId}/{stepId}.step.tsx - {' '} - — customize the resolver logic there anytime and re-run to redeploy. -
-
💡 This bundles your template, links it to this step, and deploys it to our{' '} - managed infrastructure. - - ), - displayCommand: secretKey - ? buildPublishCommand({ secretKey, workflowId, stepId, apiUrl, multiline: true }) - : fallbackPublishDisplay, - copyCommand: secretKey - ? buildPublishCommand({ secretKey, workflowId, stepId, apiUrl, multiline: false }) - : fallbackPublishCopy, - }, - ]; + const fallbackCopy = `npx novu step publish --workflow=${workflowId} --step=${stepId} --secret-key=${apiUrl ? ` --api-url=${apiUrl}` : ''}`; return (
- {/* Timeline steps */}
- {steps.map((step, index) => ( -
-
-
- {index + 1} -
-
+
+
+
+
-
-
-

{step.label}

-

{step.description}

-
- {index === 1 && apiKeysQuery.isLoading ? ( - - ) : ( - - )} +
+
+
+
+

Deploy your step resolver

+

+ Run this from your project root. A step file will be scaffolded at{' '} + + novu/{workflowId}/{stepId}.step.ts + {' '} + — customize the resolver logic there anytime and re-run to redeploy. +
+
💡 This bundles your handler, links it to this step, and deploys it to our{' '} + + managed infrastructure + + . +

+ {apiKeysQuery.isLoading ? ( + + ) : ( + + )}
- ))} +
- {/* Status footer */}
- Waiting for React Email template... + Waiting for step resolver...

- Once your React Email template is linked, you'll be able to preview it here and trigger your first + Once your step resolver is deployed, you'll be able to preview it here and trigger your first notification.

diff --git a/apps/dashboard/src/components/workflow-editor/steps/shared/use-step-resolver-hint.tsx b/apps/dashboard/src/components/workflow-editor/steps/shared/use-step-resolver-hint.tsx new file mode 100644 index 00000000000..9e8230677b8 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/shared/use-step-resolver-hint.tsx @@ -0,0 +1,21 @@ +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { ExternalLink } from '@/components/shared/external-link'; +import { useStepEditor } from '@/components/workflow-editor/steps/context/step-editor-context'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; + +const STEP_RESOLVER_DOCS_LINK = 'https://docs.novu.co/framework/content/step-resolvers'; + +export function useStepResolverHint(): React.ReactNode { + const { step } = useStepEditor(); + const isStepResolverEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED); + + if (!isStepResolverEnabled || !step.stepResolverHash) { + return undefined; + } + + return ( + <> + Step content is managed externally. Learn more + + ); +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx b/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx index 109bc506ac8..8a0cadef35e 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/step-editor-layout.tsx @@ -1,18 +1,20 @@ import { PermissionsEnum, StepResponseDto, WorkflowResponseDto } from '@novu/shared'; import { useState } from 'react'; -import { RiCodeBlock, RiEdit2Line, RiEyeLine, RiPlayCircleLine } from 'react-icons/ri'; +import { RiCodeBlock, RiEdit2Line, RiEyeLine, RiGitCommitFill, RiPlayCircleLine } from 'react-icons/ri'; import { useParams } from 'react-router-dom'; import { IssuesPanel } from '@/components/issues-panel'; +import { Badge, BadgeIcon } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { LocaleSelect } from '@/components/primitives/locale-select'; import { PreviewContextContainer } from '@/components/workflow-editor/steps/context/preview-context-container'; import { StepEditorProvider, useStepEditor } from '@/components/workflow-editor/steps/context/step-editor-context'; import { StepEditorFactory } from '@/components/workflow-editor/steps/editor/step-editor-factory'; -import { useReactEmailStepHint } from '@/components/workflow-editor/steps/email/use-react-email-step-hint'; import { HttpRequestTestProvider } from '@/components/workflow-editor/steps/http-request/http-request-test-context'; import { PanelHeader } from '@/components/workflow-editor/steps/layout/panel-header'; import { ResizableLayout } from '@/components/workflow-editor/steps/layout/resizable-layout'; import { StepPreviewFactory } from '@/components/workflow-editor/steps/preview/step-preview-factory'; +import { StepEditorModeToggle } from '@/components/workflow-editor/steps/shared/step-editor-mode-toggle'; +import { useStepResolverHint } from '@/components/workflow-editor/steps/shared/use-step-resolver-hint'; import { parseJsonValue } from '@/components/workflow-editor/steps/utils/preview-context.utils'; import { getEditorTitle } from '@/components/workflow-editor/steps/utils/step-utils'; import { TestWorkflowDrawer } from '@/components/workflow-editor/test-workflow/test-workflow-drawer'; @@ -32,7 +34,7 @@ type StepEditorLayoutProps = { function StepEditorContent() { const { step, isSubsequentLoad, editorValue, workflow, selectedLocale, setSelectedLocale } = useStepEditor(); - const emailStepHint = useReactEmailStepHint(); + const stepResolverHint = useStepResolverHint(); const editorTitle = getEditorTitle(step.type); const { workflowSlug = '' } = useParams<{ workflowSlug: string }>(); const [isTestDrawerOpen, setIsTestDrawerOpen] = useState(false); @@ -88,12 +90,21 @@ function StepEditorContent() { } title={editorTitle} className="min-h-[45px] py-2"> - +
+ + {step.stepResolverHash && ( + + + {step.stepResolverHash} + + )} + +
@@ -134,7 +145,7 @@ function StepEditorContent() { diff --git a/apps/dashboard/src/context/customer-io/index.ts b/apps/dashboard/src/context/customer-io/index.ts index 010bb746950..ba0ecbab2e0 100644 --- a/apps/dashboard/src/context/customer-io/index.ts +++ b/apps/dashboard/src/context/customer-io/index.ts @@ -1,2 +1,2 @@ -export * from './hooks'; export * from './customer-io-provider'; +export * from './hooks'; diff --git a/apps/dashboard/src/hooks/use-disconnect-step-resolver.ts b/apps/dashboard/src/hooks/use-disconnect-step-resolver.ts new file mode 100644 index 00000000000..aefad976658 --- /dev/null +++ b/apps/dashboard/src/hooks/use-disconnect-step-resolver.ts @@ -0,0 +1,38 @@ +import { UseMutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'; +import { disconnectStepResolver } from '@/api/step-resolvers'; +import { useEnvironment } from '@/context/environment/hooks'; +import { QueryKeys } from '@/utils/query-keys'; +import { OmitEnvironmentFromParameters } from '@/utils/types'; + +type DisconnectStepResolverParameters = OmitEnvironmentFromParameters; + +export const useDisconnectStepResolver = ( + options?: UseMutationOptions +) => { + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + + const { mutateAsync, ...rest } = useMutation({ + mutationFn: (args: DisconnectStepResolverParameters) => { + if (!currentEnvironment) { + return Promise.reject(new Error('No environment loaded')); + } + + return disconnectStepResolver({ environment: currentEnvironment, ...args }); + }, + ...options, + onSuccess: async (data, variables, ctx) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchWorkflow] }), + queryClient.invalidateQueries({ queryKey: [QueryKeys.previewStep] }), + queryClient.invalidateQueries({ queryKey: [QueryKeys.diffEnvironments] }), + ]); + options?.onSuccess?.(data, variables, ctx); + }, + }); + + return { + ...rest, + disconnectStepResolver: mutateAsync, + }; +}; diff --git a/apps/dashboard/src/hooks/use-react-email-polling.ts b/apps/dashboard/src/hooks/use-step-resolver-polling.ts similarity index 78% rename from apps/dashboard/src/hooks/use-react-email-polling.ts rename to apps/dashboard/src/hooks/use-step-resolver-polling.ts index 473f9c7a0c5..e88180b07c1 100644 --- a/apps/dashboard/src/hooks/use-react-email-polling.ts +++ b/apps/dashboard/src/hooks/use-step-resolver-polling.ts @@ -5,19 +5,23 @@ import { QueryKeys } from '@/utils/query-keys'; const POLL_INTERVAL_MS = 3_000; -export function useReactEmailPolling({ +export function useStepResolverPolling({ + enabled, stepResolverHash, - isReactEmailMode, + onHashChange, }: { + enabled: boolean; stepResolverHash?: string | null; - isReactEmailMode: boolean; + onHashChange?: () => void; }) { const queryClient = useQueryClient(); const { formState } = useFormContext(); const prevHashRef = useRef(stepResolverHash); + const onHashChangeRef = useRef(onHashChange); + onHashChangeRef.current = onHashChange; useEffect(() => { - if (!isReactEmailMode) return; + if (!enabled) return; const interval = setInterval(() => { if (formState.isDirty) return; @@ -25,7 +29,7 @@ export function useReactEmailPolling({ }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [isReactEmailMode, queryClient, formState.isDirty]); + }, [enabled, queryClient, formState.isDirty]); useEffect(() => { if (stepResolverHash && stepResolverHash !== prevHashRef.current) { @@ -33,6 +37,7 @@ export function useReactEmailPolling({ queryClient.invalidateQueries({ queryKey: [QueryKeys.previewStep] }); queryClient.invalidateQueries({ queryKey: [QueryKeys.diffEnvironments] }); prevHashRef.current = stepResolverHash; + onHashChangeRef.current?.(); } } else { prevHashRef.current = stepResolverHash; diff --git a/apps/dashboard/src/routes/root.tsx b/apps/dashboard/src/routes/root.tsx index 5571c74ccb8..2c6cd5f94e6 100644 --- a/apps/dashboard/src/routes/root.tsx +++ b/apps/dashboard/src/routes/root.tsx @@ -7,11 +7,11 @@ import { showToast } from '@/components/primitives/sonner-helpers'; import { TooltipProvider } from '@/components/primitives/tooltip'; import { IS_SELF_HOSTED } from '@/config'; import { AuthProvider } from '@/context/auth/auth-provider'; +import { CustomerIoProvider } from '@/context/customer-io'; import { EEAuthProvider as ClerkProvider } from '@/context/ee-auth-provider'; import { EscapeKeyManagerProvider } from '@/context/escape-key-manager/escape-key-manager'; import { IdentityProvider } from '@/context/identity-provider'; import { RegionProvider } from '@/context/region'; -import { CustomerIoProvider } from '@/context/customer-io'; import { SegmentProvider } from '@/context/segment'; const queryClient = new QueryClient({ diff --git a/apps/dashboard/src/utils/customer-io.ts b/apps/dashboard/src/utils/customer-io.ts index 9fa49b7ddbb..603c1f6c018 100644 --- a/apps/dashboard/src/utils/customer-io.ts +++ b/apps/dashboard/src/utils/customer-io.ts @@ -1,5 +1,5 @@ -import type { IUserEntity } from '@novu/shared'; import { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'; +import type { IUserEntity } from '@novu/shared'; import { CUSTOMER_IO_WRITE_KEY } from '@/config'; export class CustomerIoService { diff --git a/libs/application-generic/src/dtos/workflow/controls/email-control.dto.ts b/libs/application-generic/src/dtos/workflow/controls/email-control.dto.ts index 3983e83b421..24ac62dd411 100644 --- a/libs/application-generic/src/dtos/workflow/controls/email-control.dto.ts +++ b/libs/application-generic/src/dtos/workflow/controls/email-control.dto.ts @@ -20,20 +20,11 @@ export class EmailControlDto extends SkipControlDto { enum: ['block', 'html'], default: 'block', }) + @IsIn(['block', 'html']) @IsString() @IsOptional() editorType?: 'block' | 'html' = 'block'; - @ApiPropertyOptional({ - description: 'Type of renderer to use (raw HTML or React Email step resolver)', - enum: ['html', 'react-email'], - default: 'html', - }) - @IsString() - @IsIn(['html', 'react-email']) - @IsOptional() - rendererType?: 'html' | 'react-email' = 'html'; - @ApiPropertyOptional({ description: 'Disable sanitization of the output.', default: false }) @IsBoolean() @IsOptional() diff --git a/libs/application-generic/src/schemas/control/email-control.schema.ts b/libs/application-generic/src/schemas/control/email-control.schema.ts index 0902e37410e..e6223db4bbb 100644 --- a/libs/application-generic/src/schemas/control/email-control.schema.ts +++ b/libs/application-generic/src/schemas/control/email-control.schema.ts @@ -9,7 +9,6 @@ export const emailControlZodSchema = z skip: skipZodSchema, body: z.string().optional().default(''), editorType: z.enum(['block', 'html']).optional().default('block'), - rendererType: z.enum(['html', 'react-email']).optional().default('html'), subject: z.string().min(1), disableOutputSanitization: z.boolean().optional(), layoutId: z.string().nullish(), @@ -46,10 +45,6 @@ export const emailUiSchema: UiSchema = { component: UiComponentEnum.DISABLE_SANITIZATION_SWITCH, placeholder: false, }, - rendererType: { - component: UiComponentEnum.EMAIL_RENDERER_SELECT, - placeholder: 'html', - }, layoutId: { component: UiComponentEnum.LAYOUT_SELECT, }, diff --git a/libs/application-generic/src/usecases/build-step-issues/build-step-issues.usecase.ts b/libs/application-generic/src/usecases/build-step-issues/build-step-issues.usecase.ts index a2567a0070c..c7805b97e8a 100644 --- a/libs/application-generic/src/usecases/build-step-issues/build-step-issues.usecase.ts +++ b/libs/application-generic/src/usecases/build-step-issues/build-step-issues.usecase.ts @@ -18,7 +18,7 @@ import { QueryIssueTypeEnum, QueryValidatorService } from '../../services/query- import { dashboardSanitizeControlValues } from '../../utils'; import { ControlIssues, processControlValuesByLiquid, processControlValuesBySchema } from '../../utils/issues'; import { parseStepVariables } from '../../utils/parse-step-variables'; -import { isStepResolverEmailStep } from '../../utils/step-resolver-control-state'; +import { isStepResolverActive } from '../../utils/step-resolver-control-state'; import { BuildVariableSchemaCommand, BuildVariableSchemaUsecase } from '../build-variable-schema'; import { TierRestrictionsValidateCommand, TierRestrictionsValidateUsecase } from '../tier-restrictions-validate'; import { BuildStepIssuesCommand } from './build-step-issues.command'; @@ -131,7 +131,7 @@ export class BuildStepIssuesUsecase { (step) => step._id === stepInternalId || step._templateId === stepInternalId ); - return isStepResolverEmailStep(currentStep?.template?.type, currentStep?.template?.stepResolverHash); + return isStepResolverActive(currentStep?.template?.stepResolverHash); } @Instrument() diff --git a/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.command.ts b/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.command.ts index ef50a739bbf..6956010bbd0 100644 --- a/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.command.ts +++ b/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.command.ts @@ -1,8 +1,13 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { StepTypeEnum } from '@novu/shared'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { EnvironmentWithUserObjectCommand } from '../../commands'; export class DisconnectStepResolverCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsNotEmpty() stepInternalId: string; + + @IsEnum(StepTypeEnum) + @IsNotEmpty() + stepType: StepTypeEnum; } diff --git a/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.usecase.ts b/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.usecase.ts index 3172d4dd790..5e283976842 100644 --- a/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.usecase.ts +++ b/libs/application-generic/src/usecases/disconnect-step-resolver/disconnect-step-resolver.usecase.ts @@ -1,26 +1,39 @@ import { Injectable } from '@nestjs/common'; -import { MessageTemplateRepository } from '@novu/dal'; +import { ControlValuesRepository, MessageTemplateRepository } from '@novu/dal'; +import { ControlValuesLevelEnum } from '@novu/shared'; import { InstrumentUsecase } from '../../instrumentation'; -import { emailControlSchema, emailUiSchema } from '../../schemas/control'; +import { stepTypeToControlSchema } from '../../utils/step-type-to-control.mapper'; import { DisconnectStepResolverCommand } from './disconnect-step-resolver.command'; @Injectable() export class DisconnectStepResolverUsecase { - constructor(private messageTemplateRepository: MessageTemplateRepository) {} + constructor( + private messageTemplateRepository: MessageTemplateRepository, + private controlValuesRepository: ControlValuesRepository + ) {} @InstrumentUsecase() async execute(command: DisconnectStepResolverCommand): Promise { + const controlSchemas = stepTypeToControlSchema[command.stepType]; + await this.messageTemplateRepository.update( { _id: command.stepInternalId, _environmentId: command.user.environmentId }, { - $unset: { - stepResolverHash: 1, - }, + $unset: { stepResolverHash: 1 }, $set: { - 'controls.schema': emailControlSchema, - 'controls.uiSchema': emailUiSchema, + 'controls.schema': controlSchemas?.schema, + 'controls.uiSchema': controlSchemas?.uiSchema, }, } ); + + // Instead of resetting control values to their defaults, we simply remove them. + // This allows new control values in the correct shape to be generated automatically as users input content. + await this.controlValuesRepository.deleteMany({ + _environmentId: command.user.environmentId, + _organizationId: command.user.organizationId, + _stepId: command.stepInternalId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }); } } diff --git a/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts b/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts index 2f351b3c44c..dcd9432bd68 100644 --- a/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts +++ b/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts @@ -63,10 +63,7 @@ class StepResolverRequestError extends HttpException { } } -interface StepResolverResponse { - subject: string; - body: string; -} +type StepResolverResponse = Record; @Injectable() export class ExecuteStepResolverRequest { @@ -142,10 +139,7 @@ export class ExecuteStepResolverRequest { private transformToExecuteOutput(response: StepResolverResponse, duration: number): ExecuteOutput { return { - outputs: { - subject: response.subject, - body: response.body, - }, + outputs: { ...response }, options: { skip: false, }, @@ -203,6 +197,22 @@ export class ExecuteStepResolverRequest { if (error instanceof HTTPError) { const statusCode = error.response.statusCode; + if (statusCode === 400) { + const parsedBody = this.tryParseBody(error.response.body); + + if (parsedBody?.error === 'INVALID_CONTROLS') { + return { + url, + code: 'STEP_RESOLVER_INVALID_CONTROLS', + message: + typeof parsedBody.message === 'string' ? parsedBody.message : 'Step controls failed schema validation', + statusCode, + data: parsedBody.details ?? error.response.body, + cause: error, + }; + } + } + if (statusCode === 500) { const parsedBody = this.tryParseBody(error.response.body); @@ -210,7 +220,8 @@ export class ExecuteStepResolverRequest { return { url, code: 'STEP_HANDLER_ERROR', - message: parsedBody.message ?? 'An error occurred in your template code', + message: + typeof parsedBody.message === 'string' ? parsedBody.message : 'An error occurred in your template code', statusCode, cause: error, }; @@ -248,12 +259,12 @@ export class ExecuteStepResolverRequest { }; } - private tryParseBody(body: unknown): Record | null { + private tryParseBody(body: unknown): Record | null { try { const parsed = typeof body === 'string' ? JSON.parse(body) : body; if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record; + return parsed as Record; } return null; diff --git a/libs/application-generic/src/usecases/preview/preview.usecase.ts b/libs/application-generic/src/usecases/preview/preview.usecase.ts index 2b5a51bcba8..1aa935af35c 100644 --- a/libs/application-generic/src/usecases/preview/preview.usecase.ts +++ b/libs/application-generic/src/usecases/preview/preview.usecase.ts @@ -8,7 +8,7 @@ import { Instrument, InstrumentUsecase } from '../../instrumentation'; import { ControlValueSanitizerService } from '../../services/control-value-sanitizer.service'; import { shouldIncludeBody, toBodyRecord } from '../../services/http-client/http-request.utils'; import { buildNovuSignatureHeader } from '../../utils/hmac'; -import { isStepResolverEmailStep } from '../../utils/step-resolver-control-state'; +import { isStepResolverActive } from '../../utils/step-resolver-control-state'; import { BuildStepDataUsecase } from '../build-step-data'; import { CreateVariablesObjectCommand } from '../create-variables-object/create-variables-object.command'; import { CreateVariablesObject } from '../create-variables-object/create-variables-object.usecase'; @@ -40,9 +40,9 @@ export class PreviewUsecase { const context = await this.initializePreviewContext(command); const stepResolverHash = typeof context.stepData.stepResolverHash === 'string' ? context.stepData.stepResolverHash : undefined; - const isStepResolverEmail = isStepResolverEmailStep(context.stepData.type, stepResolverHash); + const isStepResolver = isStepResolverActive(stepResolverHash); - const sanitizedControls = isStepResolverEmail + const sanitizedControls = isStepResolver ? context.controlValues : this.controlValueSanitizer.sanitizeControlsForPreview( context.controlValues, @@ -96,10 +96,9 @@ export class PreviewUsecase { /* * If preview execution fails, still return valid schema and payload example * but with an empty preview result. - * For step resolver email steps, since its a runtime error, surface the error - * as HTML rendered in the preview panel. + * For step resolver steps, surface the error as HTML rendered in the preview panel. */ - const previewResult = isStepResolverEmail + const previewResult = isStepResolver ? { subject: '', body: this.errorHandler.buildPreviewErrorHtml(error) } : {}; diff --git a/libs/application-generic/src/usecases/preview/utils/preview-error-handler.ts b/libs/application-generic/src/usecases/preview/utils/preview-error-handler.ts index 89917b30c1e..f0610221bca 100644 --- a/libs/application-generic/src/usecases/preview/utils/preview-error-handler.ts +++ b/libs/application-generic/src/usecases/preview/utils/preview-error-handler.ts @@ -5,19 +5,67 @@ import { GeneratePreviewResponseDto } from '../../../dtos/workflow/generate-prev import { LOG_CONTEXT } from '../preview.constants'; import { FrameworkError, GeneratePreviewError } from '../preview.types'; -const PLATFORM_ERROR_MESSAGES: Record = { - STEP_RESOLVER_UNAVAILABLE: - 'Your email template code is unavailable. Try running "npx novu email publish" to redeploy.', - STEP_RESOLVER_NOT_FOUND: - 'No published email template code found. Run "npx novu email publish" to deploy your templates.', - STEP_RESOLVER_AUTHENTICATION_FAILED: - 'Preview failed due to an authentication error. Please contact support if this persists.', - STEP_RESOLVER_PAYLOAD_TOO_LARGE: 'The preview payload is too large to process.', - STEP_RESOLVER_TIMEOUT: - 'Your email template took too long to render. Check for slow operations in your template code.', - STEP_RESOLVER_ERROR: 'Failed to reach your email template code. Try running "npx novu email publish" to redeploy.', - STEP_RESOLVER_HTTP_ERROR: - 'An unexpected error occurred while rendering your email template. Please contact support if this persists.', +type ErrorContent = { + title: string; + getMessage: (response: Record, fallback: string) => string; + hint: string; +}; + +const ERROR_CONTENT_MAPPINGS: Record = { + STEP_RESOLVER_INVALID_CONTROLS: { + title: 'Controls validation failed', + getMessage: (response, fallback) => { + const details = response.data; + + if (Array.isArray(details) && details.length > 0) { + return details.map((d: Record) => `• ${d.message ?? JSON.stringify(d)}`).join('\n'); + } + + return fallback; + }, + hint: 'The control values sent to your step handler did not pass schema validation. Update the controls in the dashboard to match your controlSchema.', + }, + STEP_HANDLER_ERROR: { + title: 'Template error', + getMessage: (_response, fallback) => fallback, + hint: 'Fix the error in your template code and run "npx novu step publish" to redeploy.', + }, + STEP_RESOLVER_UNAVAILABLE: { + title: 'Preview unavailable', + getMessage: () => 'Your step template code is unavailable. Try running "npx novu step publish" to redeploy.', + hint: 'This is not a problem with your template code.', + }, + STEP_RESOLVER_NOT_FOUND: { + title: 'Preview unavailable', + getMessage: () => 'No published step template code found. Run "npx novu step publish" to deploy your templates.', + hint: 'This is not a problem with your template code.', + }, + STEP_RESOLVER_AUTHENTICATION_FAILED: { + title: 'Preview unavailable', + getMessage: () => 'Preview failed due to an authentication error. Please contact support if this persists.', + hint: 'This is not a problem with your template code.', + }, + STEP_RESOLVER_PAYLOAD_TOO_LARGE: { + title: 'Preview unavailable', + getMessage: () => 'The preview payload is too large to process.', + hint: 'This is not a problem with your template code.', + }, + STEP_RESOLVER_TIMEOUT: { + title: 'Preview unavailable', + getMessage: () => 'Your step template took too long to render. Check for slow operations in your template code.', + hint: 'This is not a problem with your template code.', + }, + STEP_RESOLVER_ERROR: { + title: 'Preview unavailable', + getMessage: () => 'Failed to reach your step template code. Try running "npx novu step publish" to redeploy.', + hint: 'This is not a problem with your template code.', + }, + STEP_RESOLVER_HTTP_ERROR: { + title: 'Preview unavailable', + getMessage: () => + 'An unexpected error occurred while rendering your step template. Please contact support if this persists.', + hint: 'This is not a problem with your template code.', + }, }; @Injectable() @@ -99,23 +147,14 @@ export class PreviewErrorHandler { if (error instanceof HttpException) { const response = error.getResponse() as Record; const code = typeof response?.code === 'string' ? response.code : ''; - const message = typeof response?.message === 'string' ? response.message : error.message; - - if (code === 'STEP_HANDLER_ERROR') { - return { - title: 'Template error', - message, - hint: 'Fix the error in your template code and run "npx novu email publish" to redeploy.', - }; - } - - const platformMessage = PLATFORM_ERROR_MESSAGES[code]; + const fallbackMessage = typeof response?.message === 'string' ? response.message : error.message; + const mapping = ERROR_CONTENT_MAPPINGS[code]; - if (platformMessage) { + if (mapping) { return { - title: 'Preview unavailable', - message: platformMessage, - hint: 'This is not a problem with your template code.', + title: mapping.title, + message: mapping.getMessage(response, fallbackMessage), + hint: mapping.hint, }; } } diff --git a/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts b/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts index d8b14bbcf61..bde731d56d7 100644 --- a/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -30,12 +30,11 @@ import { EmailControlType } from '../../schemas/control'; import { AnalyticsService } from '../../services'; import { computeWorkflowStatus, removeBrandingFromHtml, shortId, stepTypeToControlSchema } from '../../utils'; import { isStringifiedMailyJSONContent } from '../../utils/maily-utils'; -import { isStepResolverEmailStep, REACT_EMAIL_STEP_RESOLVER_DEFAULTS } from '../../utils/step-resolver-control-state'; +import { isStepResolverActive } from '../../utils/step-resolver-control-state'; import { NotificationStep } from '../../value-objects'; import { SendWebhookMessage } from '../../webhooks'; import { BuildStepIssuesUsecase } from '../build-step-issues'; import { CreateWorkflowCommandV0, CreateWorkflowV0 } from '../create-workflow-v0'; -import { DisconnectStepResolverCommand, DisconnectStepResolverUsecase } from '../disconnect-step-resolver'; import { GetLayoutCommand, GetLayoutUseCase } from '../get-layout-v2'; import { GetWorkflowCommand, GetWorkflowUseCase } from '../get-workflow'; import { PreviewCommand, PreviewUsecase } from '../preview'; @@ -57,7 +56,6 @@ export class UpsertWorkflowUseCase { private upsertControlValuesUseCase: UpsertControlValuesUseCase, private previewUsecase: PreviewUsecase, private getLayoutUseCase: GetLayoutUseCase, - private disconnectStepResolverUsecase: DisconnectStepResolverUsecase, private analyticsService: AnalyticsService, private logger: PinoLogger, private sendWebhookMessage: SendWebhookMessage @@ -391,19 +389,15 @@ export class UpsertWorkflowUseCase { command: UpsertWorkflowCommand ) { if (shouldDelete) { - const resetControlValues = isStepResolverEmailStep(step.template?.type, step.template?.stepResolverHash) - ? REACT_EMAIL_STEP_RESOLVER_DEFAULTS - : {}; - - return this.upsertControlValuesUseCase.execute( - UpsertControlValuesCommand.create({ - organizationId: command.user.organizationId, - environmentId: command.user.environmentId, - stepId: step._templateId, - workflowId, + return this.controlValuesRepository.delete( + { + _environmentId: command.user.environmentId, + _organizationId: command.user.organizationId, + _workflowId: workflowId, + _stepId: step._templateId, level: ControlValuesLevelEnum.STEP_CONTROLS, - newControlValues: resetControlValues, - }) + }, + { session: command.session } ); } @@ -419,23 +413,7 @@ export class UpsertWorkflowUseCase { command.workflowDto.origin === ResourceOriginEnum.NOVU_CLOUD_V1) ) { const emailControlValues = newControlValues as EmailControlType; - const isStepResolver = isStepResolverEmailStep(step.template?.type, step.template?.stepResolverHash); - let shouldApplyStandardEmailProcessing = true; - const shouldDisconnectResolver = - isStepResolver && - emailControlValues.rendererType !== undefined && - emailControlValues.rendererType !== 'react-email'; - - if (shouldDisconnectResolver) { - await this.disconnectStepResolverUsecase.execute( - DisconnectStepResolverCommand.create({ - stepInternalId: step._templateId, - user: command.user, - }) - ); - } else if (isStepResolver) { - shouldApplyStandardEmailProcessing = false; - } + const shouldApplyStandardEmailProcessing = !isStepResolverActive(step.template?.stepResolverHash); if (shouldApplyStandardEmailProcessing && typeof emailControlValues.layoutId === 'string') { const layout = await this.getLayoutUseCase.execute( diff --git a/libs/application-generic/src/utils/step-resolver-control-state.ts b/libs/application-generic/src/utils/step-resolver-control-state.ts index 492a8e978e6..aff601381d0 100644 --- a/libs/application-generic/src/utils/step-resolver-control-state.ts +++ b/libs/application-generic/src/utils/step-resolver-control-state.ts @@ -1,4 +1,3 @@ -import { StepTypeEnum, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; import Ajv, { ErrorObject } from 'ajv'; import addFormats from 'ajv-formats'; import { cloneDeep } from 'es-toolkit/compat'; @@ -10,26 +9,6 @@ export const FRAMEWORK_EMPTY_STEP_RESOLVER_SCHEMA = { additionalProperties: false, } as const; -const STEP_RESOLVER_RESERVED_CONTROL_KEYS = ['editorType', 'rendererType'] as const; - -export const REACT_EMAIL_STEP_RESOLVER_DEFAULTS = { - editorType: 'html', - rendererType: 'react-email', -} as const; - -export const STEP_RESOLVER_EMAIL_UI_SCHEMA: UiSchema = { - group: UiSchemaGroupEnum.EMAIL, - properties: { - body: { - component: UiComponentEnum.EMAIL_BODY, - }, - rendererType: { - component: UiComponentEnum.EMAIL_RENDERER_SELECT, - placeholder: REACT_EMAIL_STEP_RESOLVER_DEFAULTS.rendererType, - }, - }, -}; - const FIELD_REMOVAL_KEYWORDS = new Set([ 'type', 'enum', @@ -55,18 +34,13 @@ export function isStepResolverActive(stepResolverHash?: string): boolean { return typeof stepResolverHash === 'string' && stepResolverHash.length > 0; } -export function isStepResolverEmailStep(stepType: StepTypeEnum | null | undefined, stepResolverHash?: string): boolean { - return stepType === StepTypeEnum.EMAIL && isStepResolverActive(stepResolverHash); -} - export function getStepResolverControlSchema(controlSchema?: Record | null): Record { return controlSchema ?? FRAMEWORK_EMPTY_STEP_RESOLVER_SCHEMA; } // When a step resolver is redeployed, the schema can change while old control values // still exist in the database. This removes values that no longer match the current -// schema so the resolver does not keep using hidden stale inputs. The dashboard-only -// editor fields are kept because they are not part of the resolver input schema. +// schema so the resolver does not keep using hidden stale inputs. export function reconcileStepResolverControlValues( controlValues: Record | null | undefined, controlSchema: Record @@ -80,8 +54,7 @@ export function reconcileStepResolverControlValues( addFormats(ajv); const validate = ajv.compile(controlSchema); - const { reservedControlValues, resolverControlValues } = splitStepResolverControlValues(controlValues); - let reconciledControlValues = cloneDeep(resolverControlValues); + let reconciledControlValues = cloneDeep(isPlainObject(controlValues) ? controlValues : {}); while (true) { const isValid = validate(reconciledControlValues); @@ -109,38 +82,7 @@ export function reconcileStepResolverControlValues( } } - return { - ...reconciledControlValues, - ...reservedControlValues, - }; -} - -function splitStepResolverControlValues(controlValues?: Record | null): { - reservedControlValues: Record; - resolverControlValues: Record; -} { - if (!isPlainObject(controlValues)) { - return { - reservedControlValues: {}, - resolverControlValues: {}, - }; - } - - const reservedControlValues: Record = {}; - const resolverControlValues: Record = {}; - - for (const [key, value] of Object.entries(controlValues)) { - if (STEP_RESOLVER_RESERVED_CONTROL_KEYS.includes(key as (typeof STEP_RESOLVER_RESERVED_CONTROL_KEYS)[number])) { - reservedControlValues[key] = value; - } else { - resolverControlValues[key] = value; - } - } - - return { - reservedControlValues, - resolverControlValues, - }; + return reconciledControlValues; } function getRemovableJsonPointer(error: ErrorObject): string | undefined { diff --git a/libs/internal-sdk/src/models/components/emailcontroldto.ts b/libs/internal-sdk/src/models/components/emailcontroldto.ts index 14c5d121274..78f20ac55fc 100644 --- a/libs/internal-sdk/src/models/components/emailcontroldto.ts +++ b/libs/internal-sdk/src/models/components/emailcontroldto.ts @@ -20,18 +20,6 @@ export const EmailControlDtoEditorType = { */ export type EmailControlDtoEditorType = ClosedEnum; -/** - * Type of renderer to use (raw HTML or React Email step resolver) - */ -export const RendererType = { - Html: 'html', - ReactEmail: 'react-email', -} as const; -/** - * Type of renderer to use (raw HTML or React Email step resolver) - */ -export type RendererType = ClosedEnum; - export type EmailControlDto = { /** * JSONLogic filter conditions for conditionally skipping the step execution. Supports complex logical operations with AND, OR, and comparison operators. See https://jsonlogic.com/ for full typing reference. @@ -49,10 +37,6 @@ export type EmailControlDto = { * Type of editor to use for the body. */ editorType?: EmailControlDtoEditorType | undefined; - /** - * Type of renderer to use (raw HTML or React Email step resolver) - */ - rendererType?: RendererType | undefined; /** * Disable sanitization of the output. */ @@ -70,18 +54,12 @@ export const EmailControlDtoEditorType$inboundSchema: z.ZodNativeEnum = EmailControlDtoEditorType$inboundSchema; -/** @internal */ -export const RendererType$inboundSchema: z.ZodNativeEnum = z.nativeEnum(RendererType); -/** @internal */ -export const RendererType$outboundSchema: z.ZodNativeEnum = RendererType$inboundSchema; - /** @internal */ export const EmailControlDto$inboundSchema: z.ZodType = z.object({ skip: z.record(z.any()).optional(), subject: z.string(), body: z.string().default(''), editorType: EmailControlDtoEditorType$inboundSchema.default('block'), - rendererType: RendererType$inboundSchema.default('html'), disableOutputSanitization: z.boolean().default(false), layoutId: z.nullable(z.string()).optional(), }); @@ -91,7 +69,6 @@ export type EmailControlDto$Outbound = { subject: string; body: string; editorType: string; - rendererType: string; disableOutputSanitization: boolean; layoutId?: string | null | undefined; }; @@ -103,7 +80,6 @@ export const EmailControlDto$outboundSchema: z.ZodType; -/** - * Type of renderer to use (raw HTML or React Email step resolver) - */ -export const EmailStepResponseDtoRendererType = { - Html: 'html', - ReactEmail: 'react-email', -} as const; -/** - * Type of renderer to use (raw HTML or React Email step resolver) - */ -export type EmailStepResponseDtoRendererType = ClosedEnum; - /** * Control values for the email step */ @@ -59,10 +47,6 @@ export type EmailStepResponseDtoControlValues = { * Type of editor to use for the body. */ editorType: EmailStepResponseDtoEditorType; - /** - * Type of renderer to use (raw HTML or React Email step resolver) - */ - rendererType: EmailStepResponseDtoRendererType; /** * Disable sanitization of the output. */ @@ -129,10 +113,6 @@ export type EmailStepResponseDto = { export const EmailStepResponseDtoEditorType$inboundSchema: z.ZodNativeEnum = z.nativeEnum(EmailStepResponseDtoEditorType); -/** @internal */ -export const EmailStepResponseDtoRendererType$inboundSchema: z.ZodNativeEnum = - z.nativeEnum(EmailStepResponseDtoRendererType); - /** @internal */ export const EmailStepResponseDtoControlValues$inboundSchema: z.ZodType< EmailStepResponseDtoControlValues, @@ -145,7 +125,6 @@ export const EmailStepResponseDtoControlValues$inboundSchema: z.ZodType< subject: z.string(), body: z.string().default(''), editorType: EmailStepResponseDtoEditorType$inboundSchema.default('block'), - rendererType: EmailStepResponseDtoRendererType$inboundSchema.default('html'), disableOutputSanitization: z.boolean().default(false), layoutId: z.nullable(z.string()).optional(), }) diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 6cf9829ae30..fcca7b3d49a 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -2,7 +2,14 @@ export { Client } from './client'; export { CronExpression } from './constants'; export { NovuRequestHandler, type ServeHandlerOptions } from './handler'; export { workflow } from './resources'; -export type { EmailStepResolver } from './resources/step-resolver/step'; +export type { + AnyStepResolver, + ChatStepResolver, + EmailStepResolver, + InAppStepResolver, + PushStepResolver, + SmsStepResolver, +} from './resources/step-resolver/step'; export { step } from './resources/step-resolver/step'; export { providerSchemas } from './schemas'; export { ClientOptions, SeverityLevelEnum, Workflow } from './types'; diff --git a/packages/framework/src/resources/step-resolver/step.ts b/packages/framework/src/resources/step-resolver/step.ts index b05083fa5f2..2cc49419289 100644 --- a/packages/framework/src/resources/step-resolver/step.ts +++ b/packages/framework/src/resources/step-resolver/step.ts @@ -1,4 +1,11 @@ import type { FromSchema, Schema } from '../../types'; +import type { + ChatOutputUnvalidated, + EmailOutputUnvalidated, + InAppOutputUnvalidated, + PushOutputUnvalidated, + SmsOutputUnvalidated, +} from '../../types/step.types'; type StepResolverContext = Record> = { payload: TPayload; @@ -9,6 +16,11 @@ type StepResolverContext = Record = T extends Schema ? FromSchema : Record; +type StepResolverOptions = { + controlSchema?: TControlSchema; + payloadSchema?: TPayloadSchema; +}; + export type EmailStepResolver< TControlSchema extends Schema | undefined = undefined, TPayloadSchema extends Schema | undefined = undefined, @@ -18,10 +30,74 @@ export type EmailStepResolver< resolve: ( controls: ResolveControls, ctx: StepResolverContext> - ) => Promise<{ subject: unknown; body: unknown }>; + ) => Promise; + controlSchema?: TControlSchema; + payloadSchema?: TPayloadSchema; +}; + +export type SmsStepResolver< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +> = { + type: 'sms'; + stepId: string; + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise; + controlSchema?: TControlSchema; + payloadSchema?: TPayloadSchema; +}; + +export type ChatStepResolver< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +> = { + type: 'chat'; + stepId: string; + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise; + controlSchema?: TControlSchema; + payloadSchema?: TPayloadSchema; +}; + +export type PushStepResolver< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +> = { + type: 'push'; + stepId: string; + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise; + controlSchema?: TControlSchema; + payloadSchema?: TPayloadSchema; +}; + +export type InAppStepResolver< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +> = { + type: 'in_app'; + stepId: string; + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise; controlSchema?: TControlSchema; + payloadSchema?: TPayloadSchema; }; +export type AnyStepResolver = + | EmailStepResolver + | SmsStepResolver + | ChatStepResolver + | PushStepResolver + | InAppStepResolver; + function email< TControlSchema extends Schema | undefined = undefined, TPayloadSchema extends Schema | undefined = undefined, @@ -30,18 +106,96 @@ function email< resolve: ( controls: ResolveControls, ctx: StepResolverContext> - ) => Promise<{ subject: unknown; body: unknown }>, - options?: { - controlSchema?: TControlSchema; - payloadSchema?: TPayloadSchema; - } + ) => Promise, + options?: StepResolverOptions ): EmailStepResolver { return { type: 'email', stepId, resolve: resolve as EmailStepResolver['resolve'], controlSchema: options?.controlSchema, + payloadSchema: options?.payloadSchema, + }; +} + +function sms< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +>( + stepId: string, + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise, + options?: StepResolverOptions +): SmsStepResolver { + return { + type: 'sms', + stepId, + resolve: resolve as SmsStepResolver['resolve'], + controlSchema: options?.controlSchema, + payloadSchema: options?.payloadSchema, + }; +} + +function chat< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +>( + stepId: string, + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise, + options?: StepResolverOptions +): ChatStepResolver { + return { + type: 'chat', + stepId, + resolve: resolve as ChatStepResolver['resolve'], + controlSchema: options?.controlSchema, + payloadSchema: options?.payloadSchema, + }; +} + +function push< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +>( + stepId: string, + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise, + options?: StepResolverOptions +): PushStepResolver { + return { + type: 'push', + stepId, + resolve: resolve as PushStepResolver['resolve'], + controlSchema: options?.controlSchema, + payloadSchema: options?.payloadSchema, + }; +} + +function inApp< + TControlSchema extends Schema | undefined = undefined, + TPayloadSchema extends Schema | undefined = undefined, +>( + stepId: string, + resolve: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Promise, + options?: StepResolverOptions +): InAppStepResolver { + return { + type: 'in_app', + stepId, + resolve: resolve as InAppStepResolver['resolve'], + controlSchema: options?.controlSchema, + payloadSchema: options?.payloadSchema, }; } -export const step = { email }; +export const step = { email, sms, chat, push, inApp }; diff --git a/packages/framework/src/step-resolver.ts b/packages/framework/src/step-resolver.ts index 8dd9d27f639..85232c0cd6d 100644 --- a/packages/framework/src/step-resolver.ts +++ b/packages/framework/src/step-resolver.ts @@ -1,2 +1,10 @@ -export type { EmailStepResolver } from './resources/step-resolver/step'; +export type { + AnyStepResolver, + ChatStepResolver, + EmailStepResolver, + InAppStepResolver, + PushStepResolver, + SmsStepResolver, +} from './resources/step-resolver/step'; export { step } from './resources/step-resolver/step'; +export { channelStepSchemas } from './schemas/steps/channels'; diff --git a/packages/novu/src/commands/email/index.ts b/packages/novu/src/commands/email/index.ts deleted file mode 100644 index dbffc35ca00..00000000000 --- a/packages/novu/src/commands/email/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { emailPublish } from './publish'; diff --git a/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap b/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap deleted file mode 100644 index 4f156279f4e..00000000000 --- a/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap +++ /dev/null @@ -1,81 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`generateStepFile > should match snapshot 1`] = ` -"import { step } from '@novu/framework/step-resolver'; -import { render } from '@react-email/components'; -import EmailTemplate from '../emails/welcome'; - -export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ - subject: controls.subject ?? payload.subject ?? 'No Subject', - body: await render( - - ), -})); -" -`; - -exports[`generateStepFile > should match snapshot with different import paths > nested-import 1`] = ` -"import { step } from '@novu/framework/step-resolver'; -import { render } from '@react-email/components'; -import EmailTemplate from '../../src/emails/welcome'; - -export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ - subject: controls.subject ?? payload.subject ?? 'No Subject', - body: await render( - - ), -})); -" -`; - -exports[`generateStepFile > should match snapshot with different import paths > relative-import 1`] = ` -"import { step } from '@novu/framework/step-resolver'; -import { render } from '@react-email/components'; -import EmailTemplate from './emails/welcome'; - -export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ - subject: controls.subject ?? payload.subject ?? 'No Subject', - body: await render( - - ), -})); -" -`; - -exports[`generateStepFile > should match snapshot with subject > with-subject 1`] = ` -"import { step } from '@novu/framework/step-resolver'; -import { render } from '@react-email/components'; -import EmailTemplate from '../emails/welcome'; - -export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ - subject: controls.subject ?? payload.subject ?? 'Welcome to Acme!', - body: await render( - - ), -})); -" -`; diff --git a/packages/novu/src/commands/email/templates/index.ts b/packages/novu/src/commands/email/templates/index.ts deleted file mode 100644 index 8363b974b3d..00000000000 --- a/packages/novu/src/commands/email/templates/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { generateStepFile } from './step-file'; diff --git a/packages/novu/src/commands/email/templates/step-file.spec.ts b/packages/novu/src/commands/email/templates/step-file.spec.ts deleted file mode 100644 index 123ee023c18..00000000000 --- a/packages/novu/src/commands/email/templates/step-file.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { generateStepFile } from './step-file'; - -describe('generateStepFile', () => { - const stepId = 'welcome-email'; - const baseConfig = { - template: 'emails/welcome.tsx', - }; - - it('should match snapshot', () => { - const result = generateStepFile(stepId, '../emails/welcome', baseConfig); - expect(result).toMatchSnapshot(); - }); - - it('should match snapshot with subject', () => { - const config = { - ...baseConfig, - subject: 'Welcome to Acme!', - }; - const result = generateStepFile(stepId, '../emails/welcome', config); - expect(result).toMatchSnapshot('with-subject'); - }); - - it('should match snapshot with different import paths', () => { - const result1 = generateStepFile(stepId, './emails/welcome', baseConfig); - expect(result1).toMatchSnapshot('relative-import'); - - const result2 = generateStepFile(stepId, '../../src/emails/welcome', baseConfig); - expect(result2).toMatchSnapshot('nested-import'); - }); -}); diff --git a/packages/novu/src/commands/email/templates/step-file.ts b/packages/novu/src/commands/email/templates/step-file.ts deleted file mode 100644 index e92f98d616b..00000000000 --- a/packages/novu/src/commands/email/templates/step-file.ts +++ /dev/null @@ -1,36 +0,0 @@ -type EmailStepConfig = { - template: string; - subject?: string; -}; - -function escapeString(value: string): string { - return value - .replace(/\\/g, '\\\\') - .replace(/'/g, "\\'") - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - .replace(/\u2028/g, '\\u2028') - .replace(/\u2029/g, '\\u2029'); -} - -export function generateStepFile(stepId: string, templateImportPath: string, emailConfig: EmailStepConfig): string { - const defaultSubject = emailConfig.subject || 'No Subject'; - - return `import { step } from '@novu/framework/step-resolver'; -import { render } from '@react-email/components'; -import EmailTemplate from '${escapeString(templateImportPath)}'; - -export default step.email('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ - subject: controls.subject ?? payload.subject ?? '${escapeString(defaultSubject)}', - body: await render( - - ), -})); -`; -} diff --git a/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx b/packages/novu/src/commands/step/__fixtures__/templates/no-default-export.tsx similarity index 100% rename from packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx rename to packages/novu/src/commands/step/__fixtures__/templates/no-default-export.tsx diff --git a/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx b/packages/novu/src/commands/step/__fixtures__/templates/no-react-email.tsx similarity index 100% rename from packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx rename to packages/novu/src/commands/step/__fixtures__/templates/no-react-email.tsx diff --git a/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx b/packages/novu/src/commands/step/__fixtures__/templates/should-be-ignored.test.tsx similarity index 100% rename from packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx rename to packages/novu/src/commands/step/__fixtures__/templates/should-be-ignored.test.tsx diff --git a/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx b/packages/novu/src/commands/step/__fixtures__/templates/test-file.test.tsx similarity index 100% rename from packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx rename to packages/novu/src/commands/step/__fixtures__/templates/test-file.test.tsx diff --git a/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx b/packages/novu/src/commands/step/__fixtures__/templates/test-template.tsx similarity index 100% rename from packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx rename to packages/novu/src/commands/step/__fixtures__/templates/test-template.tsx diff --git a/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx b/packages/novu/src/commands/step/__fixtures__/templates/valid-template.tsx similarity index 100% rename from packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx rename to packages/novu/src/commands/step/__fixtures__/templates/valid-template.tsx diff --git a/packages/novu/src/commands/email/api/client.ts b/packages/novu/src/commands/step/api/client.ts similarity index 93% rename from packages/novu/src/commands/email/api/client.ts rename to packages/novu/src/commands/step/api/client.ts index ed3596074f6..b37822b14f4 100644 --- a/packages/novu/src/commands/email/api/client.ts +++ b/packages/novu/src/commands/step/api/client.ts @@ -73,6 +73,27 @@ export class StepResolverClient { } } + async getStepType(workflowId: string, stepId: string): Promise { + try { + const response = await axios.get( + `${this.apiUrl}/v2/workflows/${encodeURIComponent(workflowId)}/steps/${encodeURIComponent(stepId)}`, + { + headers: this.getAuthHeaders(), + } + ); + + const type = response.data?.data?.type; + + return typeof type === 'string' && type.trim().length > 0 ? type.trim() : undefined; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return undefined; + } + + throw error; + } + } + async deployRelease( bundle: StepResolverReleaseBundle, manifestSteps: StepResolverManifestStep[] diff --git a/packages/novu/src/commands/email/api/index.ts b/packages/novu/src/commands/step/api/index.ts similarity index 100% rename from packages/novu/src/commands/email/api/index.ts rename to packages/novu/src/commands/step/api/index.ts diff --git a/packages/novu/src/commands/email/bundler/bundler.spec.ts b/packages/novu/src/commands/step/bundler/bundler.spec.ts similarity index 100% rename from packages/novu/src/commands/email/bundler/bundler.spec.ts rename to packages/novu/src/commands/step/bundler/bundler.spec.ts diff --git a/packages/novu/src/commands/email/bundler/bundler.ts b/packages/novu/src/commands/step/bundler/bundler.ts similarity index 100% rename from packages/novu/src/commands/email/bundler/bundler.ts rename to packages/novu/src/commands/step/bundler/bundler.ts diff --git a/packages/novu/src/commands/email/bundler/config.spec.ts b/packages/novu/src/commands/step/bundler/config.spec.ts similarity index 100% rename from packages/novu/src/commands/email/bundler/config.spec.ts rename to packages/novu/src/commands/step/bundler/config.spec.ts diff --git a/packages/novu/src/commands/email/bundler/config.ts b/packages/novu/src/commands/step/bundler/config.ts similarity index 100% rename from packages/novu/src/commands/email/bundler/config.ts rename to packages/novu/src/commands/step/bundler/config.ts diff --git a/packages/novu/src/commands/email/bundler/index.ts b/packages/novu/src/commands/step/bundler/index.ts similarity index 100% rename from packages/novu/src/commands/email/bundler/index.ts rename to packages/novu/src/commands/step/bundler/index.ts diff --git a/packages/novu/src/commands/email/bundler/schema-extractor.ts b/packages/novu/src/commands/step/bundler/schema-extractor.ts similarity index 100% rename from packages/novu/src/commands/email/bundler/schema-extractor.ts rename to packages/novu/src/commands/step/bundler/schema-extractor.ts diff --git a/packages/novu/src/commands/email/config/index.ts b/packages/novu/src/commands/step/config/index.ts similarity index 100% rename from packages/novu/src/commands/email/config/index.ts rename to packages/novu/src/commands/step/config/index.ts diff --git a/packages/novu/src/commands/email/config/loader.ts b/packages/novu/src/commands/step/config/loader.ts similarity index 100% rename from packages/novu/src/commands/email/config/loader.ts rename to packages/novu/src/commands/step/config/loader.ts diff --git a/packages/novu/src/commands/email/config/schema.spec.ts b/packages/novu/src/commands/step/config/schema.spec.ts similarity index 100% rename from packages/novu/src/commands/email/config/schema.spec.ts rename to packages/novu/src/commands/step/config/schema.spec.ts diff --git a/packages/novu/src/commands/email/config/schema.ts b/packages/novu/src/commands/step/config/schema.ts similarity index 100% rename from packages/novu/src/commands/email/config/schema.ts rename to packages/novu/src/commands/step/config/schema.ts diff --git a/packages/novu/src/commands/email/discovery/email-template-discovery.ts b/packages/novu/src/commands/step/discovery/email-template-discovery.ts similarity index 100% rename from packages/novu/src/commands/email/discovery/email-template-discovery.ts rename to packages/novu/src/commands/step/discovery/email-template-discovery.ts diff --git a/packages/novu/src/commands/email/discovery/index.ts b/packages/novu/src/commands/step/discovery/index.ts similarity index 100% rename from packages/novu/src/commands/email/discovery/index.ts rename to packages/novu/src/commands/step/discovery/index.ts diff --git a/packages/novu/src/commands/email/discovery/step-discovery.spec.ts b/packages/novu/src/commands/step/discovery/step-discovery.spec.ts similarity index 90% rename from packages/novu/src/commands/email/discovery/step-discovery.spec.ts rename to packages/novu/src/commands/step/discovery/step-discovery.spec.ts index 215bb09507c..78ea82d9f37 100644 --- a/packages/novu/src/commands/email/discovery/step-discovery.spec.ts +++ b/packages/novu/src/commands/step/discovery/step-discovery.spec.ts @@ -62,7 +62,7 @@ describe('step-discovery', () => { const invalidError = result.errors.find((error) => error.filePath.endsWith('invalid.step.tsx')); expect(invalidError).toBeDefined(); - expect(invalidError?.errors.some((error) => error.includes('stepId'))).toBe(true); + expect(invalidError?.errors.some((error) => error.includes('Missing step resolver'))).toBe(true); }); it('detects missing workflow folder', async () => { @@ -86,19 +86,36 @@ describe('step-discovery', () => { expect(result.valid).toBe(false); expect(result.steps).toHaveLength(0); expect(result.errors).toHaveLength(1); - expect(result.errors[0].errors).toContain( - "Missing step resolver: default export must be 'step.email(stepId, resolver, opts)'" + expect(result.errors[0].errors.some((error) => error.includes('Missing step resolver'))).toBe(true); + }); + + it('accepts all supported channel step types', async () => { + for (const type of ['email', 'sms', 'chat', 'push']) { + writeStepFile( + `onboarding/${type}-step.step.ts`, + createStepFileContent({ stepId: `${type}-step`, type, useJsx: false }) + ); + } + writeStepFile( + 'onboarding/inapp-step.step.ts', + createStepFileContent({ stepId: 'inapp-step', type: 'inApp', useJsx: false }) ); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.steps).toHaveLength(5); }); it('detects invalid step type', async () => { - writeStepFile('onboarding/invalid-type.step.tsx', createStepFileContent({ type: 'sms' })); + writeStepFile('onboarding/invalid-type.step.tsx', createStepFileContent({ type: 'custom' })); const result = await discoverStepFiles(tempDir); expect(result.valid).toBe(false); expect(result.steps).toHaveLength(0); - expect(result.errors[0].errors.some((error) => error.includes("must be 'email'"))).toBe(true); + expect(result.errors[0].errors.some((error) => error.includes('Invalid step type'))).toBe(true); }); it('detects missing default export', async () => { diff --git a/packages/novu/src/commands/email/discovery/step-discovery.ts b/packages/novu/src/commands/step/discovery/step-discovery.ts similarity index 92% rename from packages/novu/src/commands/email/discovery/step-discovery.ts rename to packages/novu/src/commands/step/discovery/step-discovery.ts index d177cf95b71..fd440bb1242 100644 --- a/packages/novu/src/commands/email/discovery/step-discovery.ts +++ b/packages/novu/src/commands/step/discovery/step-discovery.ts @@ -19,6 +19,16 @@ interface AnalyzedStepFile { const STEP_FILE_PATTERN = '**/*.step.{ts,tsx,js,jsx}'; +const METHOD_NAME_TO_TYPE: Record = { + email: 'email', + sms: 'sms', + chat: 'chat', + push: 'push', + inApp: 'in_app', +}; + +const VALID_STEP_TYPES = new Set(Object.values(METHOD_NAME_TO_TYPE)); + export async function discoverStepFiles(stepsDir: string): Promise { const matchedStepFiles = await fg([STEP_FILE_PATTERN], { cwd: stepsDir, @@ -148,7 +158,7 @@ function extractStepResolverCallMetadata(node: ts.Expression, metadata: StepMeta if (!firstArg || !ts.isStringLiteral(firstArg)) return; metadata.stepId = firstArg.text; - metadata.type = methodName; + metadata.type = METHOD_NAME_TO_TYPE[methodName] ?? methodName; } function hasDefaultExportInFile(sourceFile: ts.SourceFile): boolean { @@ -194,11 +204,14 @@ function buildValidationErrors(analysis: AnalyzedStepFile, workflowId: string | } if (!analysis.metadata.stepId) { - errors.push("Missing step resolver: default export must be 'step.email(stepId, resolver, opts)'"); + const validMethods = Object.keys(METHOD_NAME_TO_TYPE).map((k) => `step.${k}()`); + errors.push(`Missing step resolver: default export must call one of ${validMethods.join(', ')}`); } - if (analysis.metadata.type && analysis.metadata.type !== 'email') { - errors.push(`Invalid step type: '${analysis.metadata.type}' (must be 'email')`); + if (analysis.metadata.type && !VALID_STEP_TYPES.has(analysis.metadata.type)) { + errors.push( + `Invalid step type: '${analysis.metadata.type}' (must be one of: ${Array.from(VALID_STEP_TYPES).join(', ')})` + ); } return errors; diff --git a/packages/novu/src/commands/step/index.ts b/packages/novu/src/commands/step/index.ts new file mode 100644 index 00000000000..035c3451ffd --- /dev/null +++ b/packages/novu/src/commands/step/index.ts @@ -0,0 +1 @@ +export { stepPublish } from './publish'; diff --git a/packages/novu/src/commands/email/publish.ts b/packages/novu/src/commands/step/publish.ts similarity index 70% rename from packages/novu/src/commands/email/publish.ts rename to packages/novu/src/commands/step/publish.ts index 0ad1c9ed79e..e884fe45cff 100644 --- a/packages/novu/src/commands/email/publish.ts +++ b/packages/novu/src/commands/step/publish.ts @@ -3,14 +3,12 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { dim, green, red, yellow } from 'picocolors'; import prompts from 'prompts'; -import type { RendererConflictStep } from './api'; -import { RendererConflictError, StepResolverClient } from './api'; +import { StepResolverClient } from './api'; import { bundleRelease, formatBundleSize } from './bundler'; import { extractStepSchemas } from './bundler/schema-extractor'; import { loadConfig } from './config/loader'; -import type { DiscoveredTemplate } from './discovery'; import { discoverEmailTemplates, discoverStepFiles } from './discovery'; -import { generateStepFile } from './templates/step-file'; +import { generateReactEmailStepFile, generateStepFileForType } from './templates/step-file'; import type { DeploymentResult, DiscoveredStep, @@ -44,7 +42,11 @@ const DEFAULT_API_URL = 'https://api.novu.co'; const DEFAULT_STEPS_DIR = './novu'; const RELEASE_ARTIFACT_BASENAME = 'step-resolver-release'; -export async function emailPublish(options: PublishOptions): Promise { +type ScaffoldResult = { mode: 'react-email'; templatePath: string } | { mode: 'placeholder'; stepType: string }; + +const KNOWN_STEP_TYPES = new Set(['email', 'sms', 'push', 'chat', 'in_app']); + +export async function stepPublish(options: PublishOptions): Promise { try { const startTime = Date.now(); const rootDir = process.cwd(); @@ -64,12 +66,41 @@ export async function emailPublish(options: PublishOptions): Promise { assertTemplateRequiresWorkflowAndStep(options.template, options.workflow, options.step); const effectiveOutDir = options.out || config?.outDir; - const templatePath = options.template ?? (await resolveTemplateInteractively(options, rootDir, effectiveOutDir)); - if (templatePath) { + let scaffoldResult: ScaffoldResult | undefined; + if (options.template) { + const workflowIds = normalizeRequestedWorkflows(options.workflow); + const stepIds = normalizeRequestedWorkflows(options.step); + const remoteStepType = + workflowIds[0] && stepIds[0] ? await client.getStepType(workflowIds[0], stepIds[0]) : undefined; + + if (remoteStepType && remoteStepType !== 'email') { + console.error(''); + console.error( + red( + `❌ The --template flag is only supported for email steps, but step '${stepIds[0]}' is of type '${remoteStepType}'.` + ) + ); + console.error(''); + process.exit(1); + } + + scaffoldResult = { mode: 'react-email', templatePath: options.template }; + } else { + scaffoldResult = await resolveScaffoldInteractively(client, options, rootDir, effectiveOutDir); + } + + let isFirstTimeScaffold = false; + if (scaffoldResult) { const workflowIds = normalizeRequestedWorkflows(options.workflow); const stepIds = normalizeRequestedWorkflows(options.step); - await scaffoldStepFileIfNeeded(templatePath, workflowIds[0], stepIds[0], rootDir, effectiveOutDir); + isFirstTimeScaffold = await scaffoldStepFileIfNeeded( + scaffoldResult, + workflowIds[0], + stepIds[0], + rootDir, + effectiveOutDir + ); } const discoveredSteps = await discoverAndValidateSteps(stepsDir, stepsDirLabel); @@ -88,10 +119,11 @@ export async function emailPublish(options: PublishOptions): Promise { shouldMinifyBundles, config?.aliases ); - const manifestSteps = stepsWithSchemas.map((step) => ({ - workflowId: step.workflowId, - stepId: step.stepId, - ...(step.controlSchema && { controlSchema: step.controlSchema }), + const manifestSteps = stepsWithSchemas.map((s) => ({ + workflowId: s.workflowId, + stepId: s.stepId, + stepType: s.type, + ...(s.controlSchema && { controlSchema: s.controlSchema }), })); const bundleOutputDir = resolveBundleOutputDir(options.bundleOutDir, rootDir); @@ -104,16 +136,18 @@ export async function emailPublish(options: PublishOptions): Promise { return; } - try { - const deployment = await deployRelease(client, releaseBundle, manifestSteps); - printSuccessSummary(deployment, selectedSteps, startTime); - } catch (error) { - if (error instanceof RendererConflictError) { - printRendererConflictError(error); + if (process.stdout.isTTY) { + const confirmed = await confirmDeploy(selectedSteps.length, isFirstTimeScaffold); + if (!confirmed) { + console.log(''); + console.log(yellow('ℹ Publish cancelled.')); + console.log(''); return; } - throw error; } + + const deployment = await deployRelease(client, releaseBundle, manifestSteps); + printSuccessSummary(deployment, selectedSteps, startTime); } catch (error) { console.error(''); console.error(red('❌ Publish failed:'), error instanceof Error ? error.message : error); @@ -122,11 +156,12 @@ export async function emailPublish(options: PublishOptions): Promise { } } -async function resolveTemplateInteractively( +async function resolveScaffoldInteractively( + client: StepResolverClient, options: PublishOptions, rootDir: string, configOutDir?: string -): Promise { +): Promise { const workflowIds = normalizeRequestedWorkflows(options.workflow); const stepIds = normalizeRequestedWorkflows(options.step); @@ -137,101 +172,161 @@ async function resolveTemplateInteractively( const outDir = configOutDir || './novu'; const outDirPath = path.resolve(rootDir, outDir); const pathResolver = new StepFilePathResolver(rootDir, outDirPath); - const stepFilePath = pathResolver.getStepFilePath(workflowIds[0], stepIds[0]); - if (fsSync.existsSync(stepFilePath)) { + if (pathResolver.findExistingStepFilePath(workflowIds[0], stepIds[0])) { return undefined; } - if (!process.stdout.isTTY) { - console.log(yellow('ℹ No --template provided. Use --template= to scaffold a step file.')); - console.log(''); + const stepType = await client.getStepType(workflowIds[0], stepIds[0]); - return undefined; - } + if (stepType && KNOWN_STEP_TYPES.has(stepType)) { + if (stepType === 'email' && process.stdout.isTTY) { + return promptForEmailTemplate(rootDir); + } - const templates = await withSpinner('Discovering React Email templates...', () => discoverEmailTemplates(rootDir), { - successMessage: (found) => `Found ${found.length} ${found.length === 1 ? 'template' : 'templates'}`, - failMessage: 'Template discovery failed', - }); + return { mode: 'placeholder', stepType }; + } - if (templates.length === 0) { - console.log(yellow('ℹ No React Email templates found in this project.')); - console.log(''); - console.log( - ` Templates must import from ${yellow('@react-email/components')}, use JSX, and have a default export.` - ); - console.log(''); - console.log(` To specify a template path manually, re-run with:`); - console.log( - ` npx novu email publish --workflow=${workflowIds[0]} --step=${stepIds[0]} --template=` - ); + if (!process.stdout.isTTY) { + console.log(yellow('ℹ No step file found and step type could not be determined.')); + console.log(` Run with --workflow and --step once the workflow exists, or create the file manually.`); console.log(''); return undefined; } - return promptForTemplate(templates); + return promptForChannelType(rootDir); } -const MANUAL_ENTRY_VALUE = '__manual__'; - -async function promptForTemplate(templates: DiscoveredTemplate[]): Promise { - console.log(''); - - const selectResponse = await prompts( +async function promptForChannelType(rootDir: string): Promise { + const response = await prompts( { type: 'select', - name: 'template', - message: 'Select a React Email template for this step', + name: 'channelType', + message: 'What channel type is this step?', choices: [ - ...templates.map((t) => ({ title: t.relativePath, value: t.relativePath })), - { title: 'Enter path manually...', value: MANUAL_ENTRY_VALUE }, + { title: 'Email — HTML email', value: 'email' }, + { title: 'SMS — text message', value: 'sms' }, + { title: 'Push — mobile push notification', value: 'push' }, + { title: 'Chat — chat message (Slack, MS Teams, etc.)', value: 'chat' }, + { title: 'In-App — in-app notification', value: 'in_app' }, + { title: "Skip — I'll create the file myself", value: 'skip' }, ], }, { onCancel: () => { console.log(''); - console.log(yellow('ℹ Template selection cancelled. Step file will not be scaffolded.')); + console.log(yellow('ℹ Scaffolding cancelled.')); console.log(''); }, } ); - if (!selectResponse.template) { + if (!response.channelType || response.channelType === 'skip') { return undefined; } - if (selectResponse.template !== MANUAL_ENTRY_VALUE) { - console.log(''); - - return selectResponse.template; + if (response.channelType === 'email') { + return promptForEmailTemplate(rootDir); } - const textResponse = await prompts( + return { mode: 'placeholder', stepType: response.channelType }; +} + +async function promptForEmailTemplate(rootDir: string): Promise { + const templates = await withSpinner('Scanning for React Email templates...', () => discoverEmailTemplates(rootDir), { + successMessage: (tmpl) => + tmpl.length > 0 + ? `Found ${tmpl.length} React Email template${tmpl.length === 1 ? '' : 's'}` + : 'No React Email templates found — you can enter a path manually or scaffold a generic step', + failMessage: 'Template scan failed', + }); + + const MANUAL_ENTRY = '__manual__'; + const GENERIC_EMAIL = '__generic__'; + + const templateChoices = + templates.length > 0 ? templates.map((t) => ({ title: t.relativePath, value: t.relativePath })) : []; + const hasTemplates = templateChoices.length > 0; + + let selectCancelled = false; + const selectResponse = await prompts( { - type: 'text', + type: 'select', name: 'template', - message: 'Template path (relative to project root)', - initial: './emails/your-template.tsx', + message: hasTemplates + ? 'Select a React Email template:' + : 'No React Email templates detected. How would you like to scaffold this step?', + choices: [ + ...templateChoices, + { title: 'Enter path manually — provide a React Email template path', value: MANUAL_ENTRY }, + { title: 'Generic email step — scaffold a starter with plain HTML body', value: GENERIC_EMAIL }, + ], }, { onCancel: () => { + selectCancelled = true; console.log(''); - console.log(yellow('ℹ Template selection cancelled. Step file will not be scaffolded.')); + console.log(yellow('ℹ Scaffolding cancelled.')); console.log(''); }, } ); + if (selectCancelled) { + return undefined; + } + + if (selectResponse.template === GENERIC_EMAIL) { + return { mode: 'placeholder', stepType: 'email' }; + } + + if (selectResponse.template === MANUAL_ENTRY) { + let pathCancelled = false; + const pathResponse = await prompts( + { + type: 'text', + name: 'templatePath', + message: 'Path to your React Email template (relative to project root):', + initial: './emails/welcome.tsx', + }, + { + onCancel: () => { + pathCancelled = true; + console.log(''); + console.log(yellow('ℹ Scaffolding cancelled.')); + console.log(''); + }, + } + ); + + if (pathCancelled || !pathResponse.templatePath) { + return undefined; + } + + return { mode: 'react-email', templatePath: pathResponse.templatePath }; + } + + return { mode: 'react-email', templatePath: selectResponse.template }; +} + +async function confirmDeploy(stepCount: number, isFirstTimeScaffold: boolean): Promise { + const stepText = stepCount === 1 ? '1 step' : `${stepCount} steps`; console.log(''); - const manualTemplatePath = textResponse.template?.trim(); - if (!manualTemplatePath) { - return undefined; + if (isFirstTimeScaffold) { + console.log(yellow(`⚠ Publishing will override any existing editor content for ${stepText}.`)); + console.log(''); } - return manualTemplatePath; + const response = await prompts({ + type: 'confirm', + name: 'confirmed', + message: 'Continue?', + initial: true, + }); + + return Boolean(response.confirmed); } function assertTemplateRequiresWorkflowAndStep( @@ -249,9 +344,7 @@ function assertTemplateRequiresWorkflowAndStep( console.error(red('❌ --template requires exactly one --workflow')); console.error(''); console.error('Example:'); - console.error( - ' npx novu email publish --workflow=onboarding --step=welcome-email --template=./emails/welcome.tsx' - ); + console.error(' npx novu step publish --workflow=onboarding --step=welcome-email --template=./emails/welcome.tsx'); console.error(''); process.exit(1); } @@ -261,9 +354,7 @@ function assertTemplateRequiresWorkflowAndStep( console.error(red('❌ --template requires exactly one --step')); console.error(''); console.error('Example:'); - console.error( - ' npx novu email publish --workflow=onboarding --step=welcome-email --template=./emails/welcome.tsx' - ); + console.error(' npx novu step publish --workflow=onboarding --step=welcome-email --template=./emails/welcome.tsx'); console.error(''); process.exit(1); } @@ -295,12 +386,12 @@ async function installFrameworkPackageIfNeeded(rootDir: string): Promise { } async function scaffoldStepFileIfNeeded( - templatePath: string, + scaffoldResult: ScaffoldResult, workflowId: string, stepId: string, rootDir: string, configOutDir?: string -): Promise { +): Promise { const outDir = configOutDir || './novu'; const outDirPath = path.resolve(rootDir, outDir); const pathResolver = new StepFilePathResolver(rootDir, outDirPath); @@ -308,28 +399,34 @@ async function scaffoldStepFileIfNeeded( if (fsSync.existsSync(stepFilePath)) { const relPath = path.relative(rootDir, stepFilePath); - console.log(yellow(`ℹ ${relPath} already exists — --template flag ignored`)); + console.log(yellow(`ℹ ${relPath} already exists — scaffold skipped`)); console.log(''); - return; - } - - const templateAbsPath = path.resolve(rootDir, templatePath); - if (!fsSync.existsSync(templateAbsPath)) { - console.error(''); - console.error(red(`❌ Template not found: ${templatePath}`)); - console.error(''); - console.error(` Resolved to: ${templateAbsPath}`); - console.error(' Make sure the path is relative to your project root.'); - console.error(''); - process.exit(1); + return false; } const workflowDir = pathResolver.getWorkflowDir(workflowId); fsSync.mkdirSync(workflowDir, { recursive: true }); - const templateImportPath = pathResolver.getTemplateImportPath(workflowId, templatePath); - const stepFileContent = generateStepFile(stepId, templateImportPath, { template: templatePath }); + let stepFileContent: string; + + if (scaffoldResult.mode === 'react-email') { + const { templatePath } = scaffoldResult; + const templateAbsPath = path.resolve(rootDir, templatePath); + if (!fsSync.existsSync(templateAbsPath)) { + console.error(''); + console.error(red(`❌ Template not found: ${templatePath}`)); + console.error(''); + console.error(` Resolved to: ${templateAbsPath}`); + console.error(' Make sure the path is relative to your project root.'); + console.error(''); + process.exit(1); + } + const templateImportPath = pathResolver.getTemplateImportPath(workflowId, templatePath); + stepFileContent = generateReactEmailStepFile(stepId, templateImportPath); + } else { + stepFileContent = generateStepFileForType(stepId, scaffoldResult.stepType); + } fsSync.writeFileSync(stepFilePath, stepFileContent, 'utf8'); @@ -340,6 +437,8 @@ async function scaffoldStepFileIfNeeded( console.log(''); await installFrameworkPackageIfNeeded(rootDir); + + return true; } function assertNotProductionEnvironment(envInfo: EnvironmentInfo): void { @@ -361,7 +460,7 @@ function assertNotProductionEnvironment(envInfo: EnvironmentInfo): void { console.error(' https://docs.novu.co/platform/developer/environments#publish-changes-to-other-environments'); console.error(''); console.error(' Switch to a non-production environment by using its secret key:'); - console.error(' npx novu email publish --secret-key '); + console.error(' npx novu step publish --secret-key '); console.error(''); process.exit(1); } @@ -381,7 +480,7 @@ function assertStepRequiresWorkflow(stepOption?: string[] | string, workflowOpti ); console.error(''); console.error('Example:'); - console.error(' npx novu email publish --workflow=onboarding --step=welcome-email'); + console.error(' npx novu step publish --workflow=onboarding --step=welcome-email'); console.error(''); process.exit(1); } @@ -422,7 +521,7 @@ function assertSecretKey(secretKey?: string): asserts secretKey is string { console.error(red('❌ Authentication required')); console.error(''); console.error('Provide your API key via:'); - console.error(' 1. CLI flag: npx novu email publish --secret-key nv-xxx'); + console.error(' 1. CLI flag: npx novu step publish --secret-key nv-xxx'); console.error(' 2. Environment: export NOVU_SECRET_KEY=nv-xxx'); console.error(' 3. .env file: NOVU_SECRET_KEY=nv-xxx'); console.error(''); @@ -462,7 +561,7 @@ async function discoverAndValidateSteps(stepsDir: string, stepsDirLabel: string) console.error(''); console.error('Expected *.step.tsx, *.step.ts, *.step.jsx, or *.step.js files.'); console.error(''); - console.error(`Run 'npx novu email publish --workflow= --step=' to scaffold your first step handler.`); + console.error(`Run 'npx novu step publish --workflow= --step=' to scaffold your first step handler.`); console.error(''); throw new Error('No step files found'); } @@ -480,7 +579,7 @@ async function discoverAndValidateSteps(stepsDir: string, stepsDirLabel: string) console.error(''); } - console.error("Fix these errors and run 'npx novu email init --force' to regenerate step files."); + console.error("Fix these errors and re-run 'npx novu step publish' after correcting the handler files."); console.error(''); throw new Error('Step file validation failed'); } @@ -580,28 +679,6 @@ async function deployRelease( }); } -function printRendererConflictError(error: RendererConflictError): void { - const stepList = error.conflictingSteps - .map((s: RendererConflictStep) => ` • ${s.stepId} (workflow: ${s.workflowId})`) - .join('\n'); - - const isPlural = error.conflictingSteps.length > 1; - const stepWord = isPlural ? 'steps' : 'step'; - - console.error(''); - console.error(red(`❌ ${isPlural ? 'Some steps are' : 'This step is'} not set to React Email`)); - console.error(''); - console.error(` Affected ${stepWord}:`); - console.error(stepList); - console.error(''); - console.error(` Publishing is blocked to avoid accidentally overwriting existing email content.`); - console.error(''); - console.error(' To fix this, open each affected step in the Novu dashboard,'); - console.error(' go to the code editor, and select React Email.'); - console.error(''); - process.exit(1); -} - function printDryRunSummary( bundle: StepResolverReleaseBundle, selectedSteps: DiscoveredStep[], diff --git a/packages/novu/src/commands/step/templates/__snapshots__/step-file.spec.ts.snap b/packages/novu/src/commands/step/templates/__snapshots__/step-file.spec.ts.snap new file mode 100644 index 00000000000..f520c3de6f6 --- /dev/null +++ b/packages/novu/src/commands/step/templates/__snapshots__/step-file.spec.ts.snap @@ -0,0 +1,109 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateChatStepFile > should match snapshot 1`] = ` +"import { step } from '@novu/framework/step-resolver'; + +export default step.chat('send-chat', async (controls, { payload, subscriber, context, steps }) => ({ + body: \`Hello \${subscriber.firstName ?? 'there'}, a message for you.\`, +})); +" +`; + +exports[`generateEmailStepFile > should match snapshot 1`] = ` +"import { step } from '@novu/framework/step-resolver'; + +export default step.email('plain-email', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'No Subject', + body: \`

Hello \${subscriber.firstName ?? 'there'},

Your message here.

\`, +})); +" +`; + +exports[`generateInAppStepFile > should match snapshot 1`] = ` +"import { step } from '@novu/framework/step-resolver'; + +export default step.inApp('in-app-notify', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'New notification', + body: \`Hello \${subscriber.firstName ?? 'there'}, you have a new in-app notification.\`, +})); +" +`; + +exports[`generatePushStepFile > should match snapshot 1`] = ` +"import { step } from '@novu/framework/step-resolver'; + +export default step.push('send-push', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'New notification', + body: \`Hello \${subscriber.firstName ?? 'there'}, you have a new notification.\`, +})); +" +`; + +exports[`generateReactEmailStepFile > should match snapshot 1`] = ` +"import { step } from '@novu/framework/step-resolver'; +import { render } from '@react-email/components'; +import EmailTemplate from '../emails/welcome'; + +export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'No Subject', + body: await render( + + ), +})); +" +`; + +exports[`generateReactEmailStepFile > should match snapshot with different import paths > nested-import 1`] = ` +"import { step } from '@novu/framework/step-resolver'; +import { render } from '@react-email/components'; +import EmailTemplate from '../../src/emails/welcome'; + +export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'No Subject', + body: await render( + + ), +})); +" +`; + +exports[`generateReactEmailStepFile > should match snapshot with different import paths > relative-import 1`] = ` +"import { step } from '@novu/framework/step-resolver'; +import { render } from '@react-email/components'; +import EmailTemplate from './emails/welcome'; + +export default step.email('welcome-email', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'No Subject', + body: await render( + + ), +})); +" +`; + +exports[`generateSmsStepFile > should match snapshot 1`] = ` +"import { step } from '@novu/framework/step-resolver'; + +export default step.sms('send-sms', async (controls, { payload, subscriber, context, steps }) => ({ + body: \`Hello \${subscriber.firstName ?? 'there'}, your message here.\`, +})); +" +`; diff --git a/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap b/packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap similarity index 73% rename from packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap rename to packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap index 453fdaa77a0..05b5e162489 100644 --- a/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap +++ b/packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap @@ -2,7 +2,21 @@ exports[`generateWorkerWrapper > should handle empty steps array > empty-steps 1`] = ` "import { validateData } from '@novu/framework/validators'; - +import { channelStepSchemas } from '@novu/framework/step-resolver'; + + +// Pre-compile all JSON Schema validators during the startup phase. +// Cloudflare Workers allow new Function() (used by AJV) during startup but not during request handling. +// JsonSchemaValidator caches compiled validators by schema object reference, so these pre-compiled +// validators are reused on every request without triggering new Function() again. +await Promise.all([ + ...Object.values(channelStepSchemas).map(({ output }) => + validateData(output, {}) + ), + ...[].flatMap(handler => + handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] + ), +]); const stepHandlers = new Map([ @@ -90,8 +104,21 @@ export default { const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + const outputSchema = channelStepSchemas[step.type]?.output; + let validatedResult = result; + if (outputSchema) { + const outputResult = await validateData(outputSchema, result); + if (!outputResult.success) { + return jsonResponse( + { error: 'INVALID_OUTPUT', message: 'Step output failed schema validation', details: outputResult.errors }, + 400 + ); + } + validatedResult = outputResult.data ?? result; + } + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, subject: result.subject, body: result.body }, + { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, 200 ); } catch (error) { @@ -107,8 +134,22 @@ export default { exports[`generateWorkerWrapper > should handle single step > single-step 1`] = ` "import { validateData } from '@novu/framework/validators'; +import { channelStepSchemas } from '@novu/framework/step-resolver'; import stepHandler0 from "./novu/welcome-email.step"; +// Pre-compile all JSON Schema validators during the startup phase. +// Cloudflare Workers allow new Function() (used by AJV) during startup but not during request handling. +// JsonSchemaValidator caches compiled validators by schema object reference, so these pre-compiled +// validators are reused on every request without triggering new Function() again. +await Promise.all([ + ...Object.values(channelStepSchemas).map(({ output }) => + validateData(output, {}) + ), + ...[stepHandler0].flatMap(handler => + handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] + ), +]); + const stepHandlers = new Map([ ["onboarding" + '/' + stepHandler0.stepId, stepHandler0] ]); @@ -195,8 +236,21 @@ export default { const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + const outputSchema = channelStepSchemas[step.type]?.output; + let validatedResult = result; + if (outputSchema) { + const outputResult = await validateData(outputSchema, result); + if (!outputResult.success) { + return jsonResponse( + { error: 'INVALID_OUTPUT', message: 'Step output failed schema validation', details: outputResult.errors }, + 400 + ); + } + validatedResult = outputResult.data ?? result; + } + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, subject: result.subject, body: result.body }, + { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, 200 ); } catch (error) { @@ -212,9 +266,23 @@ export default { exports[`generateWorkerWrapper > should match snapshot 1`] = ` "import { validateData } from '@novu/framework/validators'; +import { channelStepSchemas } from '@novu/framework/step-resolver'; import stepHandler0 from "./novu/welcome-email.step"; import stepHandler1 from "./novu/verify-email.step"; +// Pre-compile all JSON Schema validators during the startup phase. +// Cloudflare Workers allow new Function() (used by AJV) during startup but not during request handling. +// JsonSchemaValidator caches compiled validators by schema object reference, so these pre-compiled +// validators are reused on every request without triggering new Function() again. +await Promise.all([ + ...Object.values(channelStepSchemas).map(({ output }) => + validateData(output, {}) + ), + ...[stepHandler0, stepHandler1].flatMap(handler => + handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] + ), +]); + const stepHandlers = new Map([ ["onboarding" + '/' + stepHandler0.stepId, stepHandler0], ["onboarding" + '/' + stepHandler1.stepId, stepHandler1] @@ -302,8 +370,21 @@ export default { const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + const outputSchema = channelStepSchemas[step.type]?.output; + let validatedResult = result; + if (outputSchema) { + const outputResult = await validateData(outputSchema, result); + if (!outputResult.success) { + return jsonResponse( + { error: 'INVALID_OUTPUT', message: 'Step output failed schema validation', details: outputResult.errors }, + 400 + ); + } + validatedResult = outputResult.data ?? result; + } + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, subject: result.subject, body: result.body }, + { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, 200 ); } catch (error) { diff --git a/packages/novu/src/commands/step/templates/index.ts b/packages/novu/src/commands/step/templates/index.ts new file mode 100644 index 00000000000..2bffd3f4013 --- /dev/null +++ b/packages/novu/src/commands/step/templates/index.ts @@ -0,0 +1,9 @@ +export { + generateChatStepFile, + generateEmailStepFile, + generateInAppStepFile, + generatePushStepFile, + generateReactEmailStepFile, + generateSmsStepFile, + generateStepFileForType, +} from './step-file'; diff --git a/packages/novu/src/commands/step/templates/step-file.spec.ts b/packages/novu/src/commands/step/templates/step-file.spec.ts new file mode 100644 index 00000000000..ba125a7350a --- /dev/null +++ b/packages/novu/src/commands/step/templates/step-file.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + generateChatStepFile, + generateEmailStepFile, + generateInAppStepFile, + generatePushStepFile, + generateReactEmailStepFile, + generateSmsStepFile, + generateStepFileForType, +} from './step-file'; + +describe('generateReactEmailStepFile', () => { + const stepId = 'welcome-email'; + + it('should match snapshot', () => { + expect(generateReactEmailStepFile(stepId, '../emails/welcome')).toMatchSnapshot(); + }); + + it('should match snapshot with different import paths', () => { + expect(generateReactEmailStepFile(stepId, './emails/welcome')).toMatchSnapshot('relative-import'); + expect(generateReactEmailStepFile(stepId, '../../src/emails/welcome')).toMatchSnapshot('nested-import'); + }); + + it('imports render from @react-email/components and calls it', () => { + const result = generateReactEmailStepFile(stepId, '../emails/welcome'); + expect(result).toContain("step.email('welcome-email'"); + expect(result).toContain("from '@react-email/components'"); + expect(result).toContain('await render('); + }); + + it('escapes single quotes in stepId and templatePath', () => { + const result = generateReactEmailStepFile("it's-a-step", "../emails/it's-template"); + expect(result).toContain("it\\'s-a-step"); + expect(result).toContain("it\\'s-template"); + }); +}); + +describe('generateEmailStepFile', () => { + it('should match snapshot', () => { + expect(generateEmailStepFile('plain-email')).toMatchSnapshot(); + }); + + it('does not use React Email', () => { + const result = generateEmailStepFile('plain-email'); + expect(result).toContain("step.email('plain-email'"); + expect(result).not.toContain('@react-email'); + expect(result).not.toContain('await render('); + }); +}); + +describe('generateSmsStepFile', () => { + it('should match snapshot', () => { + expect(generateSmsStepFile('send-sms')).toMatchSnapshot(); + }); +}); + +describe('generatePushStepFile', () => { + it('should match snapshot', () => { + expect(generatePushStepFile('send-push')).toMatchSnapshot(); + }); +}); + +describe('generateChatStepFile', () => { + it('should match snapshot', () => { + expect(generateChatStepFile('send-chat')).toMatchSnapshot(); + }); +}); + +describe('generateInAppStepFile', () => { + it('should match snapshot', () => { + expect(generateInAppStepFile('in-app-notify')).toMatchSnapshot(); + }); +}); + +describe('generateStepFileForType', () => { + it('throws for unknown type', () => { + expect(() => generateStepFileForType('my-step', 'custom')).toThrow(); + }); + + it('escapes single quotes in stepId', () => { + const result = generateStepFileForType("it's", 'sms'); + expect(result).toContain("it\\'s"); + }); +}); diff --git a/packages/novu/src/commands/step/templates/step-file.ts b/packages/novu/src/commands/step/templates/step-file.ts new file mode 100644 index 00000000000..c7c0fa6b510 --- /dev/null +++ b/packages/novu/src/commands/step/templates/step-file.ts @@ -0,0 +1,94 @@ +function escapeString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +export function generateReactEmailStepFile(stepId: string, templateImportPath: string): string { + return `import { step } from '@novu/framework/step-resolver'; +import { render } from '@react-email/components'; +import EmailTemplate from '${escapeString(templateImportPath)}'; + +export default step.email('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'No Subject', + body: await render( + + ), +})); +`; +} + +export function generateEmailStepFile(stepId: string): string { + return `import { step } from '@novu/framework/step-resolver'; + +export default step.email('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'No Subject', + body: \`

Hello \${subscriber.firstName ?? 'there'},

Your message here.

\`, +})); +`; +} + +export function generateSmsStepFile(stepId: string): string { + return `import { step } from '@novu/framework/step-resolver'; + +export default step.sms('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ + body: \`Hello \${subscriber.firstName ?? 'there'}, your message here.\`, +})); +`; +} + +export function generatePushStepFile(stepId: string): string { + return `import { step } from '@novu/framework/step-resolver'; + +export default step.push('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'New notification', + body: \`Hello \${subscriber.firstName ?? 'there'}, you have a new notification.\`, +})); +`; +} + +export function generateChatStepFile(stepId: string): string { + return `import { step } from '@novu/framework/step-resolver'; + +export default step.chat('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ + body: \`Hello \${subscriber.firstName ?? 'there'}, a message for you.\`, +})); +`; +} + +export function generateInAppStepFile(stepId: string): string { + return `import { step } from '@novu/framework/step-resolver'; + +export default step.inApp('${escapeString(stepId)}', async (controls, { payload, subscriber, context, steps }) => ({ + subject: 'New notification', + body: \`Hello \${subscriber.firstName ?? 'there'}, you have a new in-app notification.\`, +})); +`; +} + +const STEP_GENERATORS: Record string> = { + email: generateEmailStepFile, + sms: generateSmsStepFile, + push: generatePushStepFile, + chat: generateChatStepFile, + in_app: generateInAppStepFile, +}; + +export function generateStepFileForType(stepId: string, stepType: string): string { + const generator = STEP_GENERATORS[stepType]; + if (!generator) { + throw new Error(`No generator available for step type '${stepType}'.`); + } + + return generator(stepId); +} diff --git a/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts b/packages/novu/src/commands/step/templates/worker-wrapper.spec.ts similarity index 100% rename from packages/novu/src/commands/email/templates/worker-wrapper.spec.ts rename to packages/novu/src/commands/step/templates/worker-wrapper.spec.ts diff --git a/packages/novu/src/commands/email/templates/worker-wrapper.ts b/packages/novu/src/commands/step/templates/worker-wrapper.ts similarity index 76% rename from packages/novu/src/commands/email/templates/worker-wrapper.ts rename to packages/novu/src/commands/step/templates/worker-wrapper.ts index 89623a4ec2c..74db0cc4506 100644 --- a/packages/novu/src/commands/email/templates/worker-wrapper.ts +++ b/packages/novu/src/commands/step/templates/worker-wrapper.ts @@ -4,6 +4,7 @@ import type { DiscoveredStep } from '../types'; export function generateWorkerWrapper(steps: DiscoveredStep[], rootDir: string): string { return [ generateImports(steps, rootDir), + generateValidatorPrecompilation(steps), generateStepHandlersMap(steps), generateWorkerUtilities(), generateFetchHandler(), @@ -15,7 +16,25 @@ function generateImports(steps: DiscoveredStep[], rootDir: string): string { .map((s, i) => `import stepHandler${i} from ${JSON.stringify(getImportPath(s.filePath, rootDir))};`) .join('\n'); - return `import { validateData } from '@novu/framework/validators';\n${stepImports}`; + return `import { validateData } from '@novu/framework/validators'; +import { channelStepSchemas } from '@novu/framework/step-resolver';\n${stepImports}`; +} + +function generateValidatorPrecompilation(steps: DiscoveredStep[]): string { + const handlerRefs = steps.map((_, i) => `stepHandler${i}`).join(', '); + + return `// Pre-compile all JSON Schema validators during the startup phase. +// Cloudflare Workers allow new Function() (used by AJV) during startup but not during request handling. +// JsonSchemaValidator caches compiled validators by schema object reference, so these pre-compiled +// validators are reused on every request without triggering new Function() again. +await Promise.all([ + ...Object.values(channelStepSchemas).map(({ output }) => + validateData(output, {}) + ), + ...[${handlerRefs}].flatMap(handler => + handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] + ), +]);`; } function generateStepHandlersMap(steps: DiscoveredStep[]): string { @@ -88,8 +107,10 @@ function generateRequestHandler(): string { const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + ${generateOutputValidation()} + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, subject: result.subject, body: result.body }, + { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, 200 );`; } @@ -124,6 +145,21 @@ function generateBodyValidation(): string { }`; } +function generateOutputValidation(): string { + return `const outputSchema = channelStepSchemas[step.type]?.output; + let validatedResult = result; + if (outputSchema) { + const outputResult = await validateData(outputSchema, result); + if (!outputResult.success) { + return jsonResponse( + { error: 'INVALID_OUTPUT', message: 'Step output failed schema validation', details: outputResult.errors }, + 400 + ); + } + validatedResult = outputResult.data ?? result; + }`; +} + function generateSchemaValidation(): string { return `let validatedControls = controls; if (step.controlSchema) { diff --git a/packages/novu/src/commands/email/types.ts b/packages/novu/src/commands/step/types.ts similarity index 97% rename from packages/novu/src/commands/email/types.ts rename to packages/novu/src/commands/step/types.ts index 9babae33b39..7d039d054f9 100644 --- a/packages/novu/src/commands/email/types.ts +++ b/packages/novu/src/commands/step/types.ts @@ -27,6 +27,7 @@ export interface StepResolverReleaseBundle { export interface StepResolverManifestStep { workflowId: string; stepId: string; + stepType: string; controlSchema?: Record; } diff --git a/packages/novu/src/commands/email/utils/environment.ts b/packages/novu/src/commands/step/utils/environment.ts similarity index 100% rename from packages/novu/src/commands/email/utils/environment.ts rename to packages/novu/src/commands/step/utils/environment.ts diff --git a/packages/novu/src/commands/email/utils/file-paths.ts b/packages/novu/src/commands/step/utils/file-paths.ts similarity index 69% rename from packages/novu/src/commands/email/utils/file-paths.ts rename to packages/novu/src/commands/step/utils/file-paths.ts index fe7c0cea89f..9331f195fdc 100644 --- a/packages/novu/src/commands/email/utils/file-paths.ts +++ b/packages/novu/src/commands/step/utils/file-paths.ts @@ -1,5 +1,8 @@ +import * as fs from 'fs'; import * as path from 'path'; +const STEP_FILE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js']; + export class StepFilePathResolver { constructor( private readonly rootDir: string, @@ -14,6 +17,19 @@ export class StepFilePathResolver { return path.join(this.getWorkflowDir(workflowId), `${stepId}.step.tsx`); } + findExistingStepFilePath(workflowId: string, stepId: string): string | undefined { + const workflowDir = this.getWorkflowDir(workflowId); + + for (const ext of STEP_FILE_EXTENSIONS) { + const candidate = path.join(workflowDir, `${stepId}.step${ext}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return undefined; + } + getRelativeStepPath(workflowId: string, stepId: string): string { return path.relative(this.outDirPath, this.getStepFilePath(workflowId, stepId)); } diff --git a/packages/novu/src/commands/email/utils/index.ts b/packages/novu/src/commands/step/utils/index.ts similarity index 100% rename from packages/novu/src/commands/email/utils/index.ts rename to packages/novu/src/commands/step/utils/index.ts diff --git a/packages/novu/src/commands/email/utils/package-manager.ts b/packages/novu/src/commands/step/utils/package-manager.ts similarity index 100% rename from packages/novu/src/commands/email/utils/package-manager.ts rename to packages/novu/src/commands/step/utils/package-manager.ts diff --git a/packages/novu/src/commands/email/utils/spinner.ts b/packages/novu/src/commands/step/utils/spinner.ts similarity index 100% rename from packages/novu/src/commands/email/utils/spinner.ts rename to packages/novu/src/commands/step/utils/spinner.ts diff --git a/packages/novu/src/commands/email/utils/table.ts b/packages/novu/src/commands/step/utils/table.ts similarity index 100% rename from packages/novu/src/commands/email/utils/table.ts rename to packages/novu/src/commands/step/utils/table.ts diff --git a/packages/novu/src/index.ts b/packages/novu/src/index.ts index 0fb81cf41ba..eb508e1c3e8 100644 --- a/packages/novu/src/index.ts +++ b/packages/novu/src/index.ts @@ -3,8 +3,8 @@ import { Command } from 'commander'; import { v4 as uuidv4 } from 'uuid'; import { DevCommandOptions, devCommand } from './commands'; -import { emailPublish } from './commands/email'; import { IInitCommandOptions, init } from './commands/init'; +import { stepPublish } from './commands/step'; import { sync } from './commands/sync'; import { pullTranslations, pushTranslations } from './commands/translations'; import { NOVU_API_URL, NOVU_SECRET_KEY } from './constants'; @@ -137,18 +137,21 @@ translationsCommand await pushTranslations(options); }); -const emailCommand = program.command('email').description('Manage Novu email step resolvers'); +const stepCommand = program.command('step').description('Manage Novu step resolvers'); -emailCommand +stepCommand .command('publish') - .description('Bundle and deploy React Email step handlers to Novu') + .description('Bundle and deploy step handlers to Novu') .option('-s, --secret-key ', 'Novu API secret key', NOVU_SECRET_KEY || '') .option('-a, --api-url ', 'Novu API URL') .option('-c, --config ', 'Path to config file') .option('--out ', 'Directory containing step handlers') .option('--workflow ', 'Deploy only specific workflows') .option('--step ', 'Deploy only specific steps (requires --workflow)') - .option('--template ', 'Path to React Email template; scaffolds the step handler file if it does not exist') + .option( + '--template ', + 'Path to React Email template; scaffolds a React Email email handler if it does not exist' + ) .option('--bundle-out-dir [path]', 'Write bundled workflow artifacts to a directory for debugging') .option('--dry-run', 'Bundle without deploying') .action(async (options) => { @@ -157,9 +160,9 @@ emailCommand anonymousId, }, data: {}, - event: 'Email Publish Command', + event: 'Step Publish Command', }); - await emailPublish(options); + await stepPublish(options); }); program.parse(process.argv); diff --git a/packages/shared/src/dto/workflows/step.dto.ts b/packages/shared/src/dto/workflows/step.dto.ts index b8d9a9668e6..b349c50cce8 100644 --- a/packages/shared/src/dto/workflows/step.dto.ts +++ b/packages/shared/src/dto/workflows/step.dto.ts @@ -62,7 +62,6 @@ export enum UiSchemaGroupEnum { export enum UiComponentEnum { EMAIL_EDITOR_SELECT = 'EMAIL_EDITOR_SELECT', LAYOUT_SELECT = 'LAYOUT_SELECT', - EMAIL_RENDERER_SELECT = 'EMAIL_RENDERER_SELECT', /** @deprecated use EMAIL_BODY instead */ BLOCK_EDITOR = 'BLOCK_EDITOR', EMAIL_BODY = 'EMAIL_BODY', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ced8bac961..0d506e9acd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ importers: version: 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) '@nx/plugin': specifier: 21.3.11 - version: 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(esbuild-register@3.5.0(esbuild@0.27.3))(eslint@9.19.0(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))(typescript@5.6.2) + version: 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(esbuild-register@3.5.0(esbuild@0.27.3))(eslint@9.39.4(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))(typescript@5.6.2) '@nx/workspace': specifier: 21.3.11 version: 21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)) @@ -2739,7 +2739,7 @@ importers: devDependencies: '@eslint/js': specifier: ^9.19.0 - version: 9.19.0 + version: 9.39.4 '@tanstack/react-query': specifier: ^5.61.4 version: 5.65.0(react@18.3.1) @@ -2748,7 +2748,7 @@ importers: version: 18.3.18 eslint: specifier: ^9.19.0 - version: 9.19.0(jiti@2.6.1) + version: 9.39.4(jiti@2.6.1) globals: specifier: ^15.14.0 version: 15.14.0 @@ -2757,7 +2757,7 @@ importers: version: 5.8.3 typescript-eslint: specifier: ^8.26.0 - version: 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) + version: 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) libs/maily-core: dependencies: @@ -7394,8 +7394,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.7.0': - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -7404,28 +7404,32 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.19.1': - resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.10.0': - resolution: {integrity: sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.2.0': - resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.19.0': - resolution: {integrity: sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.5': - resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.2.5': - resolution: {integrity: sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@faker-js/faker@6.3.1': @@ -7647,8 +7651,8 @@ packages: resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} engines: {node: '>=18.18'} - '@humanwhocodes/retry@0.4.1': - resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} '@iconify/types@2.0.0': @@ -15148,8 +15152,8 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -18185,24 +18189,20 @@ packages: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} - eslint-scope@8.2.0: - resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.0: - resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@4.2.1: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.19.0: - resolution: {integrity: sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==} + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -18217,8 +18217,8 @@ packages: esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - espree@10.3.0: - resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esprima@4.0.1: @@ -22107,6 +22107,9 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.0.1: resolution: {integrity: sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==} engines: {node: '>=10'} @@ -30875,7 +30878,7 @@ snapshots: '@babel/traverse': 7.28.0 '@babel/types': 7.28.2 convert-source-map: 2.0.0 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -33445,46 +33448,50 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.19.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.19.0(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/config-array@0.19.1': + '@eslint/config-array@0.21.2': dependencies: - '@eslint/object-schema': 2.1.5 + '@eslint/object-schema': 2.1.7 debug: 4.4.3(supports-color@8.1.1) - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color - '@eslint/core@0.10.0': + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.2.0': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3(supports-color@8.1.1) - espree: 10.3.0 + espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 + js-yaml: 4.1.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.19.0': {} + '@eslint/js@9.39.4': {} - '@eslint/object-schema@2.1.5': {} + '@eslint/object-schema@2.1.7': {} - '@eslint/plugin-kit@0.2.5': + '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 0.10.0 + '@eslint/core': 0.17.0 levn: 0.4.1 '@faker-js/faker@6.3.1': {} @@ -33769,7 +33776,7 @@ snapshots: '@humanwhocodes/retry@0.3.0': {} - '@humanwhocodes/retry@0.4.1': {} + '@humanwhocodes/retry@0.4.3': {} '@iconify/types@2.0.0': {} @@ -34885,7 +34892,7 @@ snapshots: '@rushstack/terminal': 0.14.0(@types/node@22.15.13) '@rushstack/ts-command-line': 4.22.6(@types/node@22.15.13) lodash: 4.17.23 - minimatch: 3.1.2 + minimatch: 3.1.5 resolve: 1.22.8 semver: 7.5.4 source-map: 0.6.1 @@ -35441,11 +35448,11 @@ snapshots: tslib: 2.8.1 yargs-parser: 21.1.1 - '@nx/eslint@21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@zkochan/js-yaml@0.0.7)(eslint@9.19.0(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))': + '@nx/eslint@21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))': dependencies: '@nx/devkit': 21.3.11(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) '@nx/js': 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) - eslint: 9.19.0(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) semver: 7.7.3 tslib: 2.8.1 typescript: 5.8.3 @@ -35636,10 +35643,10 @@ snapshots: '@nx/nx-win32-x64-msvc@21.3.11': optional: true - '@nx/plugin@21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(esbuild-register@3.5.0(esbuild@0.27.3))(eslint@9.19.0(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))(typescript@5.6.2)': + '@nx/plugin@21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(esbuild-register@3.5.0(esbuild@0.27.3))(eslint@9.39.4(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))(typescript@5.6.2)': dependencies: '@nx/devkit': 21.3.11(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) - '@nx/eslint': 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@zkochan/js-yaml@0.0.7)(eslint@9.19.0(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) + '@nx/eslint': 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@zkochan/js-yaml@0.0.7)(eslint@9.39.4(jiti@2.6.1))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) '@nx/jest': 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(babel-plugin-macros@3.1.0)(esbuild-register@3.5.0(esbuild@0.27.3))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15)))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))(typescript@5.6.2) '@nx/js': 21.3.11(@babel/traverse@7.28.0)(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))(nx@21.3.11(@swc-node/register@1.10.10(@swc/core@1.7.26(@swc/helpers@0.5.15))(@swc/types@0.1.12)(typescript@5.6.2))(@swc/core@1.7.26(@swc/helpers@0.5.15))) tslib: 2.8.1 @@ -42412,7 +42419,7 @@ snapshots: '@ts-morph/common@0.11.1': dependencies: fast-glob: 3.3.1 - minimatch: 3.1.2 + minimatch: 3.1.5 mkdirp: 1.0.4 path-browserify: 1.0.1 @@ -43188,15 +43195,15 @@ snapshots: '@types/zen-observable@0.8.3': {} - '@typescript-eslint/eslint-plugin@8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.39.1 - '@typescript-eslint/type-utils': 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.39.1 - eslint: 9.19.0(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -43205,14 +43212,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/parser@8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.39.1 '@typescript-eslint/types': 8.39.1 '@typescript-eslint/typescript-estree': 8.39.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.39.1 debug: 4.4.3(supports-color@8.1.1) - eslint: 9.19.0(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -43235,13 +43242,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.39.1 '@typescript-eslint/typescript-estree': 8.39.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 9.19.0(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -43282,13 +43289,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/utils@8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.19.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.39.1 '@typescript-eslint/types': 8.39.1 '@typescript-eslint/typescript-estree': 8.39.1(typescript@5.8.3) - eslint: 9.19.0(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -44286,16 +44293,16 @@ snapshots: ajv: 8.13.0 optional: true - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: - ajv: 6.12.6 + ajv: 6.14.0 ajv-keywords@5.1.0(ajv@8.17.1): dependencies: ajv: 8.17.1 fast-deep-equal: 3.1.3 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -46192,14 +46199,14 @@ snapshots: cosmiconfig@8.1.3: dependencies: import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 cosmiconfig@8.2.0: dependencies: import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 @@ -47807,39 +47814,37 @@ snapshots: esrecurse: 4.3.0 estraverse: 4.3.0 - eslint-scope@8.2.0: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.0: {} - eslint-visitor-keys@4.2.1: {} - eslint@9.19.0(jiti@2.6.1): + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.19.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.19.1 - '@eslint/core': 0.10.0 - '@eslint/eslintrc': 3.2.0 - '@eslint/js': 9.19.0 - '@eslint/plugin-kit': 0.2.5 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.1 + '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.6 - '@types/json-schema': 7.0.15 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 - eslint-scope: 8.2.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -47851,7 +47856,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.3 optionalDependencies: @@ -47863,11 +47868,11 @@ snapshots: esm-env@1.2.2: {} - espree@10.3.0: + espree@10.4.0: dependencies: acorn: 8.15.0 acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.0 + eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -49077,7 +49082,7 @@ snapshots: dependencies: inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 @@ -49086,7 +49091,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 @@ -49104,7 +49109,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 @@ -49891,11 +49896,11 @@ snapshots: ignore-walk@3.0.4: dependencies: - minimatch: 3.1.2 + minimatch: 3.1.5 ignore-walk@4.0.1: dependencies: - minimatch: 3.1.2 + minimatch: 3.1.5 ignore@3.3.10: {} @@ -50487,7 +50492,7 @@ snapshots: async: 3.2.6 chalk: 4.1.2 filelist: 1.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 java-properties@1.0.2: {} @@ -53307,6 +53312,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.11 + minimatch@5.0.1: dependencies: brace-expansion: 2.0.1 @@ -55082,7 +55091,7 @@ snapshots: pm2-axon-rpc@0.7.1: dependencies: - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -55090,7 +55099,7 @@ snapshots: dependencies: amp: 0.3.1 amp-message: 0.1.2 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 transitivePeerDependencies: - supports-color @@ -55107,7 +55116,7 @@ snapshots: pm2-sysmonit@1.2.8: dependencies: async: 3.2.6 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) pidusage: 2.0.21 systeminformation: 5.31.3 tx2: 1.0.5 @@ -56435,7 +56444,7 @@ snapshots: read-yaml-file@2.1.0: dependencies: - js-yaml: 4.1.0 + js-yaml: 4.1.1 strip-bom: 4.0.0 read@1.0.7: @@ -57071,8 +57080,8 @@ snapshots: schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) schema-utils@4.0.0: dependencies: @@ -58521,7 +58530,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.5 text-decoder@1.1.1: dependencies: @@ -59207,13 +59216,13 @@ snapshots: shiki: 0.14.1 typescript: 5.6.2 - typescript-eslint@8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3): + typescript-eslint@8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/parser': 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.39.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.39.1(eslint@9.19.0(jiti@2.6.1))(typescript@5.8.3) - eslint: 9.19.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.39.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -59398,7 +59407,7 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.14.0 + acorn: 8.15.0 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 @@ -60398,7 +60407,7 @@ snapshots: write-yaml-file@4.2.0: dependencies: - js-yaml: 4.1.0 + js-yaml: 4.1.1 write-file-atomic: 3.0.3 ws@7.5.10: {} From 19cbbdac5df3733b02e736345f3656ef334a60bb Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Fri, 13 Mar 2026 17:27:18 +0100 Subject: [PATCH 2/2] feat(novu,framework): align step resolver handlers with framework steps fixes NV-7235 (#10286) --- .../execute-step-resolver-request.usecase.ts | 47 +++- .../src/resources/step-resolver/step.ts | 107 +++++++- packages/framework/src/step-resolver.ts | 2 + packages/novu/src/commands/step/publish.ts | 15 +- .../__snapshots__/worker-wrapper.spec.ts.snap | 231 ++++++++++++++++-- .../step/templates/worker-wrapper.spec.ts | 68 ++++++ .../commands/step/templates/worker-wrapper.ts | 107 ++++++-- 7 files changed, 525 insertions(+), 52 deletions(-) diff --git a/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts b/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts index dcd9432bd68..7d2c46940a2 100644 --- a/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts +++ b/libs/application-generic/src/usecases/execute-step-resolver/execute-step-resolver-request.usecase.ts @@ -5,6 +5,7 @@ import got, { HTTPError } from 'got'; import { InstrumentUsecase } from '../../instrumentation'; import { PinoLogger } from '../../logging'; import { RETRYABLE_ERROR_CODES } from '../../services/http-client'; +import { sanitizeHtmlInObject } from '../../services/sanitize/sanitizer.service'; import { BridgeError, ExecuteBridgeRequestCommand, @@ -63,7 +64,18 @@ class StepResolverRequestError extends HttpException { } } -type StepResolverResponse = Record; +type StepResolverResponse = { + outputs: Record; + providers?: Record; + options: { skip: boolean }; + metadata: { + status: string; + error: boolean; + duration: number; + stepType?: string; + disableOutputSanitization?: boolean; + }; +}; @Injectable() export class ExecuteStepResolverRequest { @@ -131,17 +143,44 @@ export class ExecuteStepResolverRequest { const duration = Math.round(performance.now() - startTime); - return this.transformToExecuteOutput(response, duration); + const executeOutput = this.transformToExecuteOutput(response, duration); + + return this.sanitizeOutputsIfNeeded( + executeOutput, + response.metadata.stepType, + response.metadata.disableOutputSanitization + ); } catch (error) { await this.handleResponseError(error, url, command.stepResolverHash, command.processError); } } + private sanitizeOutputsIfNeeded( + result: ExecuteOutput, + stepType?: string, + disableOutputSanitization?: boolean + ): ExecuteOutput { + if (disableOutputSanitization) { + return result; + } + + const sanitizableTypes = ['email', 'in_app']; + if (stepType && sanitizableTypes.includes(stepType)) { + return { + ...result, + outputs: sanitizeHtmlInObject(result.outputs as Record), + }; + } + + return result; + } + private transformToExecuteOutput(response: StepResolverResponse, duration: number): ExecuteOutput { return { - outputs: { ...response }, + outputs: response.outputs, + providers: (response.providers ?? {}) as ExecuteOutput['providers'], options: { - skip: false, + skip: response.options?.skip === true, }, metadata: { status: 'success', diff --git a/packages/framework/src/resources/step-resolver/step.ts b/packages/framework/src/resources/step-resolver/step.ts index 2cc49419289..d63199213e2 100644 --- a/packages/framework/src/resources/step-resolver/step.ts +++ b/packages/framework/src/resources/step-resolver/step.ts @@ -1,4 +1,7 @@ +import { providerSchemas } from '../../schemas/providers'; import type { FromSchema, Schema } from '../../types'; +import type { ContextResolved } from '../../types/context.types'; +import type { WithPassthrough } from '../../types/provider.types'; import type { ChatOutputUnvalidated, EmailOutputUnvalidated, @@ -6,19 +9,52 @@ import type { PushOutputUnvalidated, SmsOutputUnvalidated, } from '../../types/step.types'; +import type { Subscriber } from '../../types/subscriber.types'; +import type { Awaitable } from '../../types/util.types'; type StepResolverContext = Record> = { payload: TPayload; - subscriber: Record; - context: Record; + subscriber: Subscriber; + context: ContextResolved; steps: Record; }; type ResolveControls = T extends Schema ? FromSchema : Record; -type StepResolverOptions = { +type StepResolverProviders< + T_StepType extends keyof typeof providerSchemas, + T_Controls, + T_Output, + T_Payload extends Record = Record, +> = { + [K in keyof (typeof providerSchemas)[T_StepType]]?: ( + step: { controls: T_Controls; outputs: T_Output }, + ctx: StepResolverContext + ) => Awaitable>>; +}; + +type BaseStepResolverOptions = { controlSchema?: TControlSchema; payloadSchema?: TPayloadSchema; + skip?: ( + controls: ResolveControls, + ctx: StepResolverContext> + ) => Awaitable; +}; + +type ChannelStepResolverOptions< + T_StepType extends keyof typeof providerSchemas, + TControlSchema extends Schema | undefined, + TPayloadSchema extends Schema | undefined, + T_Output extends Record, +> = BaseStepResolverOptions & { + providers?: StepResolverProviders< + T_StepType, + ResolveControls, + T_Output, + ResolveControls + >; + disableOutputSanitization?: boolean; }; export type EmailStepResolver< @@ -33,6 +69,14 @@ export type EmailStepResolver< ) => Promise; controlSchema?: TControlSchema; payloadSchema?: TPayloadSchema; + skip?: BaseStepResolverOptions['skip']; + providers?: StepResolverProviders< + 'email', + ResolveControls, + EmailOutputUnvalidated, + ResolveControls + >; + disableOutputSanitization?: boolean; }; export type SmsStepResolver< @@ -47,6 +91,14 @@ export type SmsStepResolver< ) => Promise; controlSchema?: TControlSchema; payloadSchema?: TPayloadSchema; + skip?: BaseStepResolverOptions['skip']; + providers?: StepResolverProviders< + 'sms', + ResolveControls, + SmsOutputUnvalidated, + ResolveControls + >; + disableOutputSanitization?: boolean; }; export type ChatStepResolver< @@ -61,6 +113,14 @@ export type ChatStepResolver< ) => Promise; controlSchema?: TControlSchema; payloadSchema?: TPayloadSchema; + skip?: BaseStepResolverOptions['skip']; + providers?: StepResolverProviders< + 'chat', + ResolveControls, + ChatOutputUnvalidated, + ResolveControls + >; + disableOutputSanitization?: boolean; }; export type PushStepResolver< @@ -75,6 +135,14 @@ export type PushStepResolver< ) => Promise; controlSchema?: TControlSchema; payloadSchema?: TPayloadSchema; + skip?: BaseStepResolverOptions['skip']; + providers?: StepResolverProviders< + 'push', + ResolveControls, + PushOutputUnvalidated, + ResolveControls + >; + disableOutputSanitization?: boolean; }; export type InAppStepResolver< @@ -89,6 +157,14 @@ export type InAppStepResolver< ) => Promise; controlSchema?: TControlSchema; payloadSchema?: TPayloadSchema; + skip?: BaseStepResolverOptions['skip']; + providers?: StepResolverProviders< + 'in_app', + ResolveControls, + InAppOutputUnvalidated, + ResolveControls + >; + disableOutputSanitization?: boolean; }; export type AnyStepResolver = @@ -107,7 +183,7 @@ function email< controls: ResolveControls, ctx: StepResolverContext> ) => Promise, - options?: StepResolverOptions + options?: ChannelStepResolverOptions<'email', TControlSchema, TPayloadSchema, EmailOutputUnvalidated> ): EmailStepResolver { return { type: 'email', @@ -115,6 +191,9 @@ function email< resolve: resolve as EmailStepResolver['resolve'], controlSchema: options?.controlSchema, payloadSchema: options?.payloadSchema, + skip: options?.skip, + providers: options?.providers as EmailStepResolver['providers'], + disableOutputSanitization: options?.disableOutputSanitization, }; } @@ -127,7 +206,7 @@ function sms< controls: ResolveControls, ctx: StepResolverContext> ) => Promise, - options?: StepResolverOptions + options?: ChannelStepResolverOptions<'sms', TControlSchema, TPayloadSchema, SmsOutputUnvalidated> ): SmsStepResolver { return { type: 'sms', @@ -135,6 +214,9 @@ function sms< resolve: resolve as SmsStepResolver['resolve'], controlSchema: options?.controlSchema, payloadSchema: options?.payloadSchema, + skip: options?.skip, + providers: options?.providers as SmsStepResolver['providers'], + disableOutputSanitization: options?.disableOutputSanitization, }; } @@ -147,7 +229,7 @@ function chat< controls: ResolveControls, ctx: StepResolverContext> ) => Promise, - options?: StepResolverOptions + options?: ChannelStepResolverOptions<'chat', TControlSchema, TPayloadSchema, ChatOutputUnvalidated> ): ChatStepResolver { return { type: 'chat', @@ -155,6 +237,9 @@ function chat< resolve: resolve as ChatStepResolver['resolve'], controlSchema: options?.controlSchema, payloadSchema: options?.payloadSchema, + skip: options?.skip, + providers: options?.providers as ChatStepResolver['providers'], + disableOutputSanitization: options?.disableOutputSanitization, }; } @@ -167,7 +252,7 @@ function push< controls: ResolveControls, ctx: StepResolverContext> ) => Promise, - options?: StepResolverOptions + options?: ChannelStepResolverOptions<'push', TControlSchema, TPayloadSchema, PushOutputUnvalidated> ): PushStepResolver { return { type: 'push', @@ -175,6 +260,9 @@ function push< resolve: resolve as PushStepResolver['resolve'], controlSchema: options?.controlSchema, payloadSchema: options?.payloadSchema, + skip: options?.skip, + providers: options?.providers as PushStepResolver['providers'], + disableOutputSanitization: options?.disableOutputSanitization, }; } @@ -187,7 +275,7 @@ function inApp< controls: ResolveControls, ctx: StepResolverContext> ) => Promise, - options?: StepResolverOptions + options?: ChannelStepResolverOptions<'in_app', TControlSchema, TPayloadSchema, InAppOutputUnvalidated> ): InAppStepResolver { return { type: 'in_app', @@ -195,6 +283,9 @@ function inApp< resolve: resolve as InAppStepResolver['resolve'], controlSchema: options?.controlSchema, payloadSchema: options?.payloadSchema, + skip: options?.skip, + providers: options?.providers as InAppStepResolver['providers'], + disableOutputSanitization: options?.disableOutputSanitization, }; } diff --git a/packages/framework/src/step-resolver.ts b/packages/framework/src/step-resolver.ts index 85232c0cd6d..36813bfb767 100644 --- a/packages/framework/src/step-resolver.ts +++ b/packages/framework/src/step-resolver.ts @@ -7,4 +7,6 @@ export type { SmsStepResolver, } from './resources/step-resolver/step'; export { step } from './resources/step-resolver/step'; +export { providerSchemas } from './schemas/providers'; export { channelStepSchemas } from './schemas/steps/channels'; +export type { WithPassthrough } from './types/provider.types'; diff --git a/packages/novu/src/commands/step/publish.ts b/packages/novu/src/commands/step/publish.ts index e884fe45cff..a2c73eee946 100644 --- a/packages/novu/src/commands/step/publish.ts +++ b/packages/novu/src/commands/step/publish.ts @@ -136,8 +136,8 @@ export async function stepPublish(options: PublishOptions): Promise { return; } - if (process.stdout.isTTY) { - const confirmed = await confirmDeploy(selectedSteps.length, isFirstTimeScaffold); + if (process.stdout.isTTY && isFirstTimeScaffold) { + const confirmed = await confirmDeploy(selectedSteps.length); if (!confirmed) { console.log(''); console.log(yellow('ℹ Publish cancelled.')); @@ -310,14 +310,11 @@ async function promptForEmailTemplate(rootDir: string): Promise { +async function confirmDeploy(stepCount: number): Promise { const stepText = stepCount === 1 ? '1 step' : `${stepCount} steps`; console.log(''); - - if (isFirstTimeScaffold) { - console.log(yellow(`⚠ Publishing will override any existing editor content for ${stepText}.`)); - console.log(''); - } + console.log(yellow(`⚠ Publishing will override any existing editor content for ${stepText}.`)); + console.log(''); const response = await prompts({ type: 'confirm', @@ -724,7 +721,7 @@ function printSuccessSummary(deployment: DeploymentResult, steps: DiscoveredStep ); console.log(''); console.log( - ` ${green(`${steps.length} ${stepText}`)} live in ${workflowCount} ${workflowText}${dim(` · Version ${deployment.stepResolverHash.slice(0, 8)} · ${elapsed}`)}` + ` ${green(`${steps.length} ${stepText}`)} live in ${workflowCount} ${workflowText}${dim(` · Version ${deployment.stepResolverHash} · ${elapsed}`)}` ); console.log(''); } diff --git a/packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap b/packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap index 05b5e162489..ca3196abf88 100644 --- a/packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap +++ b/packages/novu/src/commands/step/templates/__snapshots__/worker-wrapper.spec.ts.snap @@ -2,7 +2,7 @@ exports[`generateWorkerWrapper > should handle empty steps array > empty-steps 1`] = ` "import { validateData } from '@novu/framework/validators'; -import { channelStepSchemas } from '@novu/framework/step-resolver'; +import { channelStepSchemas, providerSchemas } from '@novu/framework/step-resolver'; // Pre-compile all JSON Schema validators during the startup phase. @@ -13,9 +13,17 @@ await Promise.all([ ...Object.values(channelStepSchemas).map(({ output }) => validateData(output, {}) ), - ...[].flatMap(handler => - handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] - ), + ...[].flatMap(handler => { + const schemas = []; + if (handler.controlSchema) schemas.push(validateData(handler.controlSchema, {})); + if (handler.providers && providerSchemas[handler.type]) { + for (const key of Object.keys(handler.providers)) { + const providerSchema = providerSchemas[handler.type]?.[key]?.output; + if (providerSchema) schemas.push(validateData(providerSchema, {})); + } + } + return schemas; + }), ]); const stepHandlers = new Map([ @@ -62,6 +70,8 @@ export default { ); } + const startTime = Date.now(); + let body = {}; const rawBody = await request.text(); if (rawBody) { @@ -82,6 +92,7 @@ export default { const stateArray = Array.isArray(body.state) ? body.state : []; const stepOutputs = stateArray.reduce((acc, s) => { if (s && typeof s.stepId === 'string') acc[s.stepId] = s.outputs ?? {}; return acc; }, {}); const controls = body.controls ?? {}; + const isPreview = body.action === 'preview'; if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs) || !isObject(controls)) { return jsonResponse( @@ -102,6 +113,27 @@ export default { validatedControls = controlsResult.data; } + if (!isPreview && step.skip) { + const shouldSkip = await step.skip(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + if (shouldSkip) { + return jsonResponse( + { + outputs: {}, + providers: {}, + options: { skip: true }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, + 200 + ); + } + } + const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); const outputSchema = channelStepSchemas[step.type]?.output; @@ -117,8 +149,43 @@ export default { validatedResult = outputResult.data ?? result; } + const providers = {}; + if (step.providers) { + const ctx = { payload, subscriber, context, steps: stepOutputs }; + for (const [providerKey, providerResolve] of Object.entries(step.providers)) { + const providerResult = await providerResolve({ controls: validatedControls, outputs: validatedResult }, ctx); + const providerOutputSchema = providerSchemas[step.type]?.[providerKey]?.output; + if (providerOutputSchema) { + const providerValidation = await validateData(providerOutputSchema, providerResult); + if (!providerValidation.success) { + return jsonResponse( + { error: 'INVALID_PROVIDER_OUTPUT', provider: providerKey, message: 'Provider output failed schema validation', details: providerValidation.errors }, + 400 + ); + } + const validated = providerValidation.data ?? providerResult; + providers[providerKey] = providerResult._passthrough !== undefined + ? { ...validated, _passthrough: providerResult._passthrough } + : validated; + } else { + providers[providerKey] = providerResult; + } + } + } + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, + { + outputs: validatedResult, + providers, + options: { skip: false }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, 200 ); } catch (error) { @@ -134,7 +201,7 @@ export default { exports[`generateWorkerWrapper > should handle single step > single-step 1`] = ` "import { validateData } from '@novu/framework/validators'; -import { channelStepSchemas } from '@novu/framework/step-resolver'; +import { channelStepSchemas, providerSchemas } from '@novu/framework/step-resolver'; import stepHandler0 from "./novu/welcome-email.step"; // Pre-compile all JSON Schema validators during the startup phase. @@ -145,9 +212,17 @@ await Promise.all([ ...Object.values(channelStepSchemas).map(({ output }) => validateData(output, {}) ), - ...[stepHandler0].flatMap(handler => - handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] - ), + ...[stepHandler0].flatMap(handler => { + const schemas = []; + if (handler.controlSchema) schemas.push(validateData(handler.controlSchema, {})); + if (handler.providers && providerSchemas[handler.type]) { + for (const key of Object.keys(handler.providers)) { + const providerSchema = providerSchemas[handler.type]?.[key]?.output; + if (providerSchema) schemas.push(validateData(providerSchema, {})); + } + } + return schemas; + }), ]); const stepHandlers = new Map([ @@ -194,6 +269,8 @@ export default { ); } + const startTime = Date.now(); + let body = {}; const rawBody = await request.text(); if (rawBody) { @@ -214,6 +291,7 @@ export default { const stateArray = Array.isArray(body.state) ? body.state : []; const stepOutputs = stateArray.reduce((acc, s) => { if (s && typeof s.stepId === 'string') acc[s.stepId] = s.outputs ?? {}; return acc; }, {}); const controls = body.controls ?? {}; + const isPreview = body.action === 'preview'; if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs) || !isObject(controls)) { return jsonResponse( @@ -234,6 +312,27 @@ export default { validatedControls = controlsResult.data; } + if (!isPreview && step.skip) { + const shouldSkip = await step.skip(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + if (shouldSkip) { + return jsonResponse( + { + outputs: {}, + providers: {}, + options: { skip: true }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, + 200 + ); + } + } + const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); const outputSchema = channelStepSchemas[step.type]?.output; @@ -249,8 +348,43 @@ export default { validatedResult = outputResult.data ?? result; } + const providers = {}; + if (step.providers) { + const ctx = { payload, subscriber, context, steps: stepOutputs }; + for (const [providerKey, providerResolve] of Object.entries(step.providers)) { + const providerResult = await providerResolve({ controls: validatedControls, outputs: validatedResult }, ctx); + const providerOutputSchema = providerSchemas[step.type]?.[providerKey]?.output; + if (providerOutputSchema) { + const providerValidation = await validateData(providerOutputSchema, providerResult); + if (!providerValidation.success) { + return jsonResponse( + { error: 'INVALID_PROVIDER_OUTPUT', provider: providerKey, message: 'Provider output failed schema validation', details: providerValidation.errors }, + 400 + ); + } + const validated = providerValidation.data ?? providerResult; + providers[providerKey] = providerResult._passthrough !== undefined + ? { ...validated, _passthrough: providerResult._passthrough } + : validated; + } else { + providers[providerKey] = providerResult; + } + } + } + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, + { + outputs: validatedResult, + providers, + options: { skip: false }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, 200 ); } catch (error) { @@ -266,7 +400,7 @@ export default { exports[`generateWorkerWrapper > should match snapshot 1`] = ` "import { validateData } from '@novu/framework/validators'; -import { channelStepSchemas } from '@novu/framework/step-resolver'; +import { channelStepSchemas, providerSchemas } from '@novu/framework/step-resolver'; import stepHandler0 from "./novu/welcome-email.step"; import stepHandler1 from "./novu/verify-email.step"; @@ -278,9 +412,17 @@ await Promise.all([ ...Object.values(channelStepSchemas).map(({ output }) => validateData(output, {}) ), - ...[stepHandler0, stepHandler1].flatMap(handler => - handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] - ), + ...[stepHandler0, stepHandler1].flatMap(handler => { + const schemas = []; + if (handler.controlSchema) schemas.push(validateData(handler.controlSchema, {})); + if (handler.providers && providerSchemas[handler.type]) { + for (const key of Object.keys(handler.providers)) { + const providerSchema = providerSchemas[handler.type]?.[key]?.output; + if (providerSchema) schemas.push(validateData(providerSchema, {})); + } + } + return schemas; + }), ]); const stepHandlers = new Map([ @@ -328,6 +470,8 @@ export default { ); } + const startTime = Date.now(); + let body = {}; const rawBody = await request.text(); if (rawBody) { @@ -348,6 +492,7 @@ export default { const stateArray = Array.isArray(body.state) ? body.state : []; const stepOutputs = stateArray.reduce((acc, s) => { if (s && typeof s.stepId === 'string') acc[s.stepId] = s.outputs ?? {}; return acc; }, {}); const controls = body.controls ?? {}; + const isPreview = body.action === 'preview'; if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs) || !isObject(controls)) { return jsonResponse( @@ -368,6 +513,27 @@ export default { validatedControls = controlsResult.data; } + if (!isPreview && step.skip) { + const shouldSkip = await step.skip(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + if (shouldSkip) { + return jsonResponse( + { + outputs: {}, + providers: {}, + options: { skip: true }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, + 200 + ); + } + } + const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); const outputSchema = channelStepSchemas[step.type]?.output; @@ -383,8 +549,43 @@ export default { validatedResult = outputResult.data ?? result; } + const providers = {}; + if (step.providers) { + const ctx = { payload, subscriber, context, steps: stepOutputs }; + for (const [providerKey, providerResolve] of Object.entries(step.providers)) { + const providerResult = await providerResolve({ controls: validatedControls, outputs: validatedResult }, ctx); + const providerOutputSchema = providerSchemas[step.type]?.[providerKey]?.output; + if (providerOutputSchema) { + const providerValidation = await validateData(providerOutputSchema, providerResult); + if (!providerValidation.success) { + return jsonResponse( + { error: 'INVALID_PROVIDER_OUTPUT', provider: providerKey, message: 'Provider output failed schema validation', details: providerValidation.errors }, + 400 + ); + } + const validated = providerValidation.data ?? providerResult; + providers[providerKey] = providerResult._passthrough !== undefined + ? { ...validated, _passthrough: providerResult._passthrough } + : validated; + } else { + providers[providerKey] = providerResult; + } + } + } + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, + { + outputs: validatedResult, + providers, + options: { skip: false }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, 200 ); } catch (error) { diff --git a/packages/novu/src/commands/step/templates/worker-wrapper.spec.ts b/packages/novu/src/commands/step/templates/worker-wrapper.spec.ts index 56975a4bd6f..895881f66a7 100644 --- a/packages/novu/src/commands/step/templates/worker-wrapper.spec.ts +++ b/packages/novu/src/commands/step/templates/worker-wrapper.spec.ts @@ -35,6 +35,12 @@ describe('generateWorkerWrapper', () => { expect(result).toMatchSnapshot('single-step'); }); + it('should import providerSchemas from @novu/framework/step-resolver', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain("import { channelStepSchemas, providerSchemas } from '@novu/framework/step-resolver'"); + }); + it('should use inline workflowId strings and stepHandler.stepId for map keys', () => { const result = generateWorkerWrapper(mockSteps, '/root'); @@ -65,4 +71,66 @@ describe('generateWorkerWrapper', () => { expect(result).toContain("error: 'Invalid JSON body'"); expect(result).toContain("error: 'STEP_HANDLER_ERROR'"); }); + + it('should evaluate step.skip before calling step.resolve but not in preview mode', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain("body.action === 'preview'"); + expect(result).toContain('if (!isPreview && step.skip)'); + expect(result).toContain('const shouldSkip = await step.skip(validatedControls,'); + expect(result).toContain('if (shouldSkip)'); + }); + + it('should return skip response with ExecuteOutput shape when skipped', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('options: { skip: true }'); + }); + + it('should execute provider overrides and collect results', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('if (step.providers)'); + expect(result).toContain('for (const [providerKey, providerResolve] of Object.entries(step.providers))'); + expect(result).toContain('await providerResolve('); + expect(result).toContain("error: 'INVALID_PROVIDER_OUTPUT'"); + }); + + it('should preserve _passthrough metadata from provider result after schema validation', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('providerResult._passthrough !== undefined'); + expect(result).toContain('_passthrough: providerResult._passthrough'); + }); + + it('should return ExecuteOutput-shaped response with outputs, providers, options, and metadata', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('outputs: validatedResult'); + expect(result).toContain('providers,'); + expect(result).toContain('options: { skip: false }'); + expect(result).toContain("status: 'success'"); + expect(result).toContain('error: false'); + expect(result).toContain('duration: Date.now() - startTime'); + expect(result).toContain('stepType: step.type'); + expect(result).toContain('disableOutputSanitization: step.disableOutputSanitization === true'); + }); + + it('should not return flat legacy response format', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).not.toContain('stepId: step.stepId, workflowId: workflowId, ...validatedResult'); + }); + + it('should pre-compile provider validators during startup', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('handler.providers && providerSchemas[handler.type]'); + }); + + it('should track startTime for duration calculation', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('const startTime = Date.now()'); + }); }); diff --git a/packages/novu/src/commands/step/templates/worker-wrapper.ts b/packages/novu/src/commands/step/templates/worker-wrapper.ts index 74db0cc4506..7e12bd3322a 100644 --- a/packages/novu/src/commands/step/templates/worker-wrapper.ts +++ b/packages/novu/src/commands/step/templates/worker-wrapper.ts @@ -17,7 +17,7 @@ function generateImports(steps: DiscoveredStep[], rootDir: string): string { .join('\n'); return `import { validateData } from '@novu/framework/validators'; -import { channelStepSchemas } from '@novu/framework/step-resolver';\n${stepImports}`; +import { channelStepSchemas, providerSchemas } from '@novu/framework/step-resolver';\n${stepImports}`; } function generateValidatorPrecompilation(steps: DiscoveredStep[]): string { @@ -31,9 +31,17 @@ await Promise.all([ ...Object.values(channelStepSchemas).map(({ output }) => validateData(output, {}) ), - ...[${handlerRefs}].flatMap(handler => - handler.controlSchema ? [validateData(handler.controlSchema, {})] : [] - ), + ...[${handlerRefs}].flatMap(handler => { + const schemas = []; + if (handler.controlSchema) schemas.push(validateData(handler.controlSchema, {})); + if (handler.providers && providerSchemas[handler.type]) { + for (const key of Object.keys(handler.providers)) { + const providerSchema = providerSchemas[handler.type]?.[key]?.output; + if (providerSchema) schemas.push(validateData(providerSchema, {})); + } + } + return schemas; + }), ]);`; } @@ -105,18 +113,35 @@ function generateRequestHandler(): string { ${generateSchemaValidation()} + ${generateSkipCheck()} + const result = await step.resolve(validatedControls, { payload, subscriber, context, steps: stepOutputs }); ${generateOutputValidation()} + ${generateProviderExecution()} + return jsonResponse( - { stepId: step.stepId, workflowId: workflowId, ...validatedResult }, + { + outputs: validatedResult, + providers, + options: { skip: false }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, 200 );`; } function generateBodyValidation(): string { - return `let body = {}; + return `const startTime = Date.now(); + + let body = {}; const rawBody = await request.text(); if (rawBody) { try { @@ -136,6 +161,7 @@ function generateBodyValidation(): string { const stateArray = Array.isArray(body.state) ? body.state : []; const stepOutputs = stateArray.reduce((acc, s) => { if (s && typeof s.stepId === 'string') acc[s.stepId] = s.outputs ?? {}; return acc; }, {}); const controls = body.controls ?? {}; + const isPreview = body.action === 'preview'; if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs) || !isObject(controls)) { return jsonResponse( @@ -145,6 +171,43 @@ function generateBodyValidation(): string { }`; } +function generateSchemaValidation(): string { + return `let validatedControls = controls; + if (step.controlSchema) { + const controlsResult = await validateData(step.controlSchema, controls); + if (!controlsResult.success) { + return jsonResponse( + { error: 'INVALID_CONTROLS', message: 'Controls failed schema validation', details: controlsResult.errors }, + 400 + ); + } + validatedControls = controlsResult.data; + }`; +} + +function generateSkipCheck(): string { + return `if (!isPreview && step.skip) { + const shouldSkip = await step.skip(validatedControls, { payload, subscriber, context, steps: stepOutputs }); + if (shouldSkip) { + return jsonResponse( + { + outputs: {}, + providers: {}, + options: { skip: true }, + metadata: { + status: 'success', + error: false, + duration: Date.now() - startTime, + stepType: step.type, + disableOutputSanitization: step.disableOutputSanitization === true, + }, + }, + 200 + ); + } + }`; +} + function generateOutputValidation(): string { return `const outputSchema = channelStepSchemas[step.type]?.output; let validatedResult = result; @@ -160,17 +223,29 @@ function generateOutputValidation(): string { }`; } -function generateSchemaValidation(): string { - return `let validatedControls = controls; - if (step.controlSchema) { - const controlsResult = await validateData(step.controlSchema, controls); - if (!controlsResult.success) { - return jsonResponse( - { error: 'INVALID_CONTROLS', message: 'Controls failed schema validation', details: controlsResult.errors }, - 400 - ); +function generateProviderExecution(): string { + return `const providers = {}; + if (step.providers) { + const ctx = { payload, subscriber, context, steps: stepOutputs }; + for (const [providerKey, providerResolve] of Object.entries(step.providers)) { + const providerResult = await providerResolve({ controls: validatedControls, outputs: validatedResult }, ctx); + const providerOutputSchema = providerSchemas[step.type]?.[providerKey]?.output; + if (providerOutputSchema) { + const providerValidation = await validateData(providerOutputSchema, providerResult); + if (!providerValidation.success) { + return jsonResponse( + { error: 'INVALID_PROVIDER_OUTPUT', provider: providerKey, message: 'Provider output failed schema validation', details: providerValidation.errors }, + 400 + ); + } + const validated = providerValidation.data ?? providerResult; + providers[providerKey] = providerResult._passthrough !== undefined + ? { ...validated, _passthrough: providerResult._passthrough } + : validated; + } else { + providers[providerKey] = providerResult; + } } - validatedControls = controlsResult.data; }`; }