Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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',
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { StepTypeEnum } from '@novu/shared';
import { IsEnum, IsNotEmpty } from 'class-validator';

export class DisconnectStepResolverRequestDto {
@IsEnum(StepTypeEnum)
@IsNotEmpty()
stepType: StepTypeEnum;
}
1 change: 1 addition & 0 deletions apps/api/src/app/step-resolvers/dtos/index.ts
Original file line number Diff line number Diff line change
@@ -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';
38 changes: 35 additions & 3 deletions apps/api/src/app/step-resolvers/step-resolvers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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<void> {
await this.disconnectStepResolverUsecase.execute(
DisconnectStepResolverCommand.create({
stepInternalId,
stepType: body.stepType,
user,
})
);
}
}

function parseManifestOrThrow(rawManifest: string): DeployStepResolverManifestDto {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +22,10 @@ export class DeployStepResolverManifestStepCommand {
@IsNotEmpty()
stepId: string;

@IsEnum(StepTypeEnum)
@IsNotEmpty()
stepType: StepTypeEnum;

@IsOptional()
@IsObject()
controlSchema?: Record<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import {
FeatureFlagsService,
GetWorkflowByIdsCommand,
GetWorkflowByIdsUseCase,
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';
Expand All @@ -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>([
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<string, unknown>;
existingStepResolverHash: string | undefined;
existingControlValues: ControlValuesEntity | null;
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -180,11 +206,10 @@ export class DeployStepResolverUsecase {
session: ClientSession | null
): Promise<void> {
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(
Expand Down Expand Up @@ -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 }
);
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 }
);
Expand Down
14 changes: 10 additions & 4 deletions apps/api/src/app/workflows-v2/usecases/preview/preview.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
GetWorkflowByIdsUseCase,
Instrument,
InstrumentUsecase,
isStepResolverEmailStep,
isStepResolverActive,
PinoLogger,
PreviewCommand,
PreviewErrorHandler,
Expand All @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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) }
: {};
Expand Down
2 changes: 0 additions & 2 deletions apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions apps/dashboard/src/api/step-resolvers.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
await delV2<void>(`/step-resolvers/${stepInternalId}/disconnect`, {
environment,
body: { stepType },
});
};
Loading
Loading