diff --git a/.cspell.json b/.cspell.json index 673be22b4f9..ce984af5ea5 100644 --- a/.cspell.json +++ b/.cspell.json @@ -789,6 +789,7 @@ "packages/shared/src/types/timezones/timezones.types.ts", "*.riv", "websockets", + "apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.usecase.ts", ".env", ".env.development", ".env.local", diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts index ea8c2d09509..1b1dd6cfc47 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts @@ -4,6 +4,6 @@ import { BaseCommand } from '@novu/application-generic'; import { FullPayloadForRender } from './render-command'; export class ExpandEmailEditorSchemaCommand extends BaseCommand { - body: string; + emailEditorJson: string; fullPayloadForRender: FullPayloadForRender; } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts index e12603fab99..5c304c69a2f 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts @@ -16,7 +16,7 @@ export class ExpandEmailEditorSchemaUsecase { } private hydrate(command: ExpandEmailEditorSchemaCommand) { const { hydratedEmailSchema } = this.hydrateEmailSchemaUseCase.execute({ - emailEditor: command.body, + emailEditor: command.emailEditorJson, fullPayloadForRender: command.fullPayloadForRender, }); diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts index 386caa79277..aef68e588ed 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts @@ -3,31 +3,43 @@ import { Injectable } from '@nestjs/common'; import { PreviewPayload, TipTapNode } from '@novu/shared'; import { z } from 'zod'; import { HydrateEmailSchemaCommand } from './hydrate-email-schema.command'; +import { PlaceholderAggregation } from '../../../workflows-v2/usecases'; @Injectable() export class HydrateEmailSchemaUseCase { execute(command: HydrateEmailSchemaCommand): { hydratedEmailSchema: TipTapNode; - nestedPayload: Record; + placeholderAggregation: PlaceholderAggregation; } { - const defaultPayload: Record = {}; + const placeholderAggregation: PlaceholderAggregation = { + nestedForPlaceholders: {}, + regularPlaceholdersToDefaultValue: {}, + }; const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)); if (emailEditorSchema.content) { - this.transformContentInPlace(emailEditorSchema.content, defaultPayload, command.fullPayloadForRender); + this.transformContentInPlace(emailEditorSchema.content, command.fullPayloadForRender, placeholderAggregation); } - return { hydratedEmailSchema: emailEditorSchema, nestedPayload: this.flattenToNested(defaultPayload) }; + return { + hydratedEmailSchema: emailEditorSchema, + placeholderAggregation, + }; } private variableLogic( masterPayload: PreviewPayload, - node: TipTapNode & { attrs: { id: string } }, - defaultPayload: Record, + node: TipTapNode & { + attrs: { id: string }; + }, content: TipTapNode[], - index: number + index: number, + placeholderAggregation: PlaceholderAggregation ) { - const resolvedValueRegularPlaceholder = this.getResolvedValueRegularPlaceholder(masterPayload, node); - defaultPayload[node.attrs.id] = resolvedValueRegularPlaceholder; + const resolvedValueRegularPlaceholder = this.getResolvedValueRegularPlaceholder( + masterPayload, + node, + placeholderAggregation + ); content[index] = { type: 'text', text: resolvedValueRegularPlaceholder, @@ -35,19 +47,21 @@ export class HydrateEmailSchemaUseCase { } private forNodeLogic( - node: TipTapNode & { attrs: { each: string } }, + node: TipTapNode & { + attrs: { each: string }; + }, masterPayload: PreviewPayload, - defaultPayload: Record, content: TipTapNode[], - index: number + index: number, + placeholderAggregation: PlaceholderAggregation ) { const itemPointerToDefaultRecord = this.collectAllItemPlaceholders(node); const resolvedValueForPlaceholder = this.getResolvedValueForPlaceholder( masterPayload, node, - itemPointerToDefaultRecord + itemPointerToDefaultRecord, + placeholderAggregation ); - defaultPayload[node.attrs.each] = resolvedValueForPlaceholder; content[index] = { type: 'for', attrs: { each: resolvedValueForPlaceholder }, @@ -57,31 +71,31 @@ export class HydrateEmailSchemaUseCase { private showLogic( masterPayload: PreviewPayload, - node: TipTapNode & { attrs: { show: string } }, - defaultPayload: Record + node: TipTapNode & { + attrs: { show: string }; + }, + placeholderAggregation: PlaceholderAggregation ) { - const resolvedValueShowPlaceholder = this.getResolvedValueShowPlaceholder(masterPayload, node); - defaultPayload[node.attrs.show] = resolvedValueShowPlaceholder; - node.attrs.show = resolvedValueShowPlaceholder; + node.attrs.show = this.getResolvedValueShowPlaceholder(masterPayload, node, placeholderAggregation); } private transformContentInPlace( content: TipTapNode[], - defaultPayload: Record, - masterPayload: PreviewPayload + masterPayload: PreviewPayload, + placeholderAggregation: PlaceholderAggregation ) { content.forEach((node, index) => { if (this.isVariableNode(node)) { - this.variableLogic(masterPayload, node, defaultPayload, content, index); + this.variableLogic(masterPayload, node, content, index, placeholderAggregation); } if (this.isForNode(node)) { - this.forNodeLogic(node, masterPayload, defaultPayload, content, index); + this.forNodeLogic(node, masterPayload, content, index, placeholderAggregation); } if (this.isShowNode(node)) { - this.showLogic(masterPayload, node, defaultPayload); + this.showLogic(masterPayload, node, placeholderAggregation); } if (node.content) { - this.transformContentInPlace(node.content, defaultPayload, masterPayload); + this.transformContentInPlace(node.content, masterPayload, placeholderAggregation); } }); } @@ -98,53 +112,65 @@ export class HydrateEmailSchemaUseCase { return !!(node.type === 'variable' && node.attrs && 'id' in node.attrs && typeof node.attrs.id === 'string'); } - private getResolvedValueRegularPlaceholder(masterPayload: PreviewPayload, node) { + private getResolvedValueRegularPlaceholder( + masterPayload: PreviewPayload, + node, + placeholderAggregation: PlaceholderAggregation + ) { const resolvedValue = this.getValueByPath(masterPayload, node.attrs.id); const { fallback } = node.attrs; - return resolvedValue || fallback || `{{${node.attrs.id}}}`; + const finalValue = resolvedValue || fallback || `{{${node.attrs.id}}}`; + placeholderAggregation.regularPlaceholdersToDefaultValue[`{{${node.attrs.id}}}`] = finalValue; + + return finalValue; } - private getResolvedValueShowPlaceholder(masterPayload: PreviewPayload, node) { + private getResolvedValueShowPlaceholder( + masterPayload: PreviewPayload, + node, + placeholderAggregation: PlaceholderAggregation + ) { const resolvedValue = this.getValueByPath(masterPayload, node.attrs.show); const { fallback } = node.attrs; - return resolvedValue || fallback || `true`; - } + const finalValue = resolvedValue || fallback || `true`; + placeholderAggregation.regularPlaceholdersToDefaultValue[`{{${node.attrs.show}}}`] = finalValue; - private flattenToNested(flatJson: Record): Record { - const nestedJson: Record = {}; - // eslint-disable-next-line guard-for-in - for (const key in flatJson) { - const keys = key.split('.'); - keys.reduce((acc, part, index) => { - if (index === keys.length - 1) { - acc[part] = flatJson[key]; - } else if (!acc[part]) { - acc[part] = {}; - } - - return acc[part]; - }, nestedJson); - } - - return nestedJson; + return finalValue; } private getResolvedValueForPlaceholder( masterPayload: PreviewPayload, - node: TipTapNode & { attrs: { each: string } }, - itemPointerToDefaultRecord: Record + node: TipTapNode & { + attrs: { each: string }; + }, + itemPointerToDefaultRecord: Record, + placeholderAggregation: PlaceholderAggregation ) { - const resolvedValue = this.getValueByPath(masterPayload, node.attrs.each); + let resolvedValueIfFound = this.getValueByPath(masterPayload, node.attrs.each); - if (!resolvedValue) { - return [this.buildElement(itemPointerToDefaultRecord, '1'), this.buildElement(itemPointerToDefaultRecord, '2')]; + if (!resolvedValueIfFound) { + resolvedValueIfFound = [ + this.buildElement(itemPointerToDefaultRecord, '1'), + this.buildElement(itemPointerToDefaultRecord, '2'), + ]; } + placeholderAggregation.nestedForPlaceholders[`{{${node.attrs.each}}}`] = + this.buildNestedVariableRecord(itemPointerToDefaultRecord); - return resolvedValue; + return resolvedValueIfFound; } + private buildNestedVariableRecord(itemPointerToDefaultRecord: Record) { + const transformedObj: Record = {}; + + Object.entries(itemPointerToDefaultRecord).forEach(([key, value]) => { + transformedObj[value] = value; + }); + + return transformedObj; + } private collectAllItemPlaceholders(nodeExt: TipTapNode) { const payloadValues = {}; const traverse = (node: TipTapNode) => { @@ -153,7 +179,7 @@ export class HydrateEmailSchemaUseCase { } if (this.isPayloadValue(node)) { const { id } = node.attrs; - payloadValues[node.attrs.id] = node.attrs.fallback || `{{item.${id}}}`; + payloadValues[`${node.attrs.id}`] = node.attrs.fallback || `{{item.${id}}}`; } if (node.content && Array.isArray(node.content)) { node.content.forEach(traverse); @@ -164,18 +190,16 @@ export class HydrateEmailSchemaUseCase { return payloadValues; } - private getValueByPath(obj: Record, path: string): any { - const keys = path.split('.'); + private getValueByPath(masterPayload: Record, placeholderRef: string): any { + const keys = placeholderRef.split('.'); return keys.reduce((currentObj, key) => { if (currentObj && typeof currentObj === 'object' && key in currentObj) { - const nextObj = currentObj[key]; - - return nextObj; + return currentObj[key]; } return undefined; - }, obj); + }, masterPayload); } private buildElement(itemPointerToDefaultRecord: Record, suffix: string) { diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index 9867279ab81..2cc59e7a46c 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -13,7 +13,6 @@ export class RenderEmailOutputUsecase { async execute(renderCommand: RenderEmailOutputCommand): Promise { const { emailEditor, subject } = EmailStepControlSchema.parse(renderCommand.controlValues); - console.log('payload', JSON.stringify(renderCommand.fullPayloadForRender)); const expandedSchema = this.transformForAndShowLogic(emailEditor, renderCommand.fullPayloadForRender); const htmlRendered = await render(expandedSchema); @@ -21,7 +20,7 @@ export class RenderEmailOutputUsecase { } private transformForAndShowLogic(body: string, fullPayloadForRender: FullPayloadForRender) { - return this.expendEmailEditorSchemaUseCase.execute({ body, fullPayloadForRender }); + return this.expendEmailEditorSchemaUseCase.execute({ emailEditorJson: body, fullPayloadForRender }); } } diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/step-id-missing.exception.ts b/apps/api/src/app/workflows-v2/exceptions/step-id-missing.exception.ts similarity index 100% rename from apps/api/src/app/workflows-v2/usecases/generate-preview/step-id-missing.exception.ts rename to apps/api/src/app/workflows-v2/exceptions/step-id-missing.exception.ts diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index 568a4c6b629..5805a5a9dd6 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -192,7 +192,59 @@ describe('Generate Preview', () => { }); }); }); + describe('payload sanitation', () => { + it('Should produce a correct payload when pipe is used etc {{payload.variable | upper}}', async () => { + const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.SMS); + const requestDto = { + controlValues: { + body: 'This is a legal placeholder with a pipe [{{payload.variableName | upper}}the pipe should show in the preview]', + }, + }; + const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, 'email'); + expect(previewResponseDto.result!.preview).to.exist; + if (previewResponseDto.result!.type !== 'sms') { + throw new Error('Expected sms'); + } + expect(previewResponseDto.result!.preview.body).to.contain('{{payload.variableName | upper}}'); + expect(previewResponseDto.previewPayloadExample).to.exist; + expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal( + '{{payload.variableName | upper}}' + ); + }); + }); + describe('Error Handling', () => { + it('Should not fail on illegal placeholder {{}} ', async () => { + const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.SMS); + const requestDto = { + controlValues: { body: 'some text that illegal placeholder[{{}}this text should be alone in brackets]' }, + }; + const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, 'sms'); + expect(previewResponseDto.result!.preview).to.exist; + if (previewResponseDto.result!.type === 'sms') { + expect(previewResponseDto.result!.preview.body).to.contain('[this text should be alone in brackets]'); + } + const issue = previewResponseDto.issues.body; + expect(issue).to.exist; + expect(issue[0].variableName).to.equal('{{}}'); + expect(issue[0].issueType).to.equal('ILLEGAL_VARIABLE_IN_CONTROL_VALUE'); + }); + it('Should return a clear error on illegal placeholder {{name}} ', async () => { + const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.SMS); + const requestDto = { + controlValues: { body: 'some text that illegal placeholder[{{name}}this text should be alone in brackets]' }, + }; + const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, 'sms'); + expect(previewResponseDto.result!.preview).to.exist; + if (previewResponseDto.result!.type === 'sms') { + expect(previewResponseDto.result!.preview.body).to.contain('[this text should be alone in brackets]'); + } + const issue = previewResponseDto.issues.body; + expect(issue).to.exist; + expect(issue[0].variableName).to.equal('{{name}}'); + expect(issue[0].issueType).to.equal('ILLEGAL_VARIABLE_IN_CONTROL_VALUE'); + }); + }); describe('Missing Required ControlValues', () => { const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; @@ -427,14 +479,14 @@ function assertEmail(dto: GeneratePreviewResponseDto) { if (dto.result!.type === ChannelTypeEnum.EMAIL) { const preview = dto.result!.preview.body; expect(preview).to.exist; - expect(preview).to.contain('{{item.header}}1'); - expect(preview).to.contain('{{item.header}}2'); - expect(preview).to.contain('{{item.name}}1'); - expect(preview).to.contain('{{item.name}}2'); - expect(preview).to.contain('{{item.id}}1'); - expect(preview).to.contain('{{item.id}}2'); - expect(preview).to.contain('{{item.origin.country}}1'); - expect(preview).to.contain('{{item.origin.country}}2'); + expect(preview).to.contain('{{item.header}}-1'); + expect(preview).to.contain('{{item.header}}-2'); + expect(preview).to.contain('{{item.name}}-1'); + expect(preview).to.contain('{{item.name}}-2'); + expect(preview).to.contain('{{item.id}}-1'); + expect(preview).to.contain('{{item.id}}-2'); + expect(preview).to.contain('{{item.origin.country}}-1'); + expect(preview).to.contain('{{item.origin.country}}-2'); expect(preview).to.contain('{{payload.body}}'); expect(preview).to.contain('should be the fallback value'); } diff --git a/apps/api/src/app/workflows-v2/shared/build-string-schema.ts b/apps/api/src/app/workflows-v2/shared/build-string-schema.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts deleted file mode 100644 index a71ebaf8d04..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { Injectable } from '@nestjs/common'; -import { ContentIssue, PreviewPayload, StepContentIssueEnum } from '@novu/shared'; -import { BaseCommand } from '@novu/application-generic'; -import _ = require('lodash'); -import { CreateMockPayloadForSingleControlValueUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase'; - -class BuildDefaultPayloadCommand extends BaseCommand { - controlValues?: Record; - payloadValues?: PreviewPayload; -} - -@Injectable() -export class BuildDefaultPayloadUseCase { - constructor(private payloadForSingleControlValueUseCase: CreateMockPayloadForSingleControlValueUseCase) {} - - execute(command: BuildDefaultPayloadCommand): { - previewPayload: PreviewPayload; - issues: Record; - } { - let aggregatedDefaultValues = {}; - const aggregatedDefaultValuesForControl: Record> = {}; - if (this.hasNoValues(command)) { - return { - previewPayload: command.payloadValues || {}, - issues: {}, - }; - } - - const flattenedValues = flattenJson(command.controlValues); - for (const controlValueKey in flattenedValues) { - if (flattenedValues.hasOwnProperty(controlValueKey)) { - const defaultPayloadForASingleControlValue = this.payloadForSingleControlValueUseCase.execute({ - controlValues: flattenedValues, - controlValueKey, - }); - if (defaultPayloadForASingleControlValue) { - aggregatedDefaultValuesForControl[controlValueKey] = defaultPayloadForASingleControlValue; - } - aggregatedDefaultValues = _.merge(defaultPayloadForASingleControlValue, aggregatedDefaultValues); - } - } - - return { - previewPayload: _.merge(aggregatedDefaultValues, command.payloadValues), - issues: this.buildVariableMissingIssueRecord( - aggregatedDefaultValuesForControl, - aggregatedDefaultValues, - command.payloadValues - ), - }; - } - - private hasNoValues(command: BuildDefaultPayloadCommand) { - return ( - !command.controlValues || - (Object.keys(command.controlValues).length === 0 && command.controlValues.constructor === Object) - ); - } - - private buildVariableMissingIssueRecord( - valueKeyToDefaultsMap: Record>, - aggregatedDefaultValues: Record, - payloadValues: PreviewPayload | undefined - ) { - const defaultVariableToValueKeyMap = flattenJsonWithArrayValues(valueKeyToDefaultsMap); - const missingRequiredPayloadIssues = this.findMissingKeys(aggregatedDefaultValues, payloadValues); - - return this.buildPayloadIssues(missingRequiredPayloadIssues, defaultVariableToValueKeyMap); - } - - private findMissingKeys(requiredRecord: Record, actualRecord?: PreviewPayload) { - const requiredKeys = this.collectKeys(requiredRecord); - const actualKeys = actualRecord ? this.collectKeys(actualRecord) : []; - - return _.difference(requiredKeys, actualKeys); - } - - private collectKeys(obj, prefix = '') { - return _.reduce( - obj, - (result, value, key) => { - const newKey = prefix ? `${prefix}.${key}` : key; - if (_.isObject(value) && !_.isArray(value)) { - result.push(...this.collectKeys(value, newKey)); - } else { - result.push(newKey); - } - - return result; - }, - [] - ); - } - - private buildPayloadIssues( - missingVariables: string[], - variableToControlValueKeys: Record - ): Record { - const record: Record = {}; - missingVariables.forEach((missingVariable) => { - variableToControlValueKeys[missingVariable].forEach((controlValueKey) => { - record[controlValueKey] = [ - { - issueType: StepContentIssueEnum.MISSING_VARIABLE_IN_PAYLOAD, - message: `Variable payload.${missingVariable} is missing in payload`, - variableName: `payload.${missingVariable}`, - }, - ]; - }); - }); - - return record; - } -} -function flattenJson(obj, parentKey = '', result = {}) { - // eslint-disable-next-line guard-for-in - for (const key in obj) { - const newKey = parentKey ? `${parentKey}.${key}` : key; - - if (typeof obj[key] === 'object' && obj[key] !== null && !_.isArray(obj[key])) { - flattenJson(obj[key], newKey, result); - } else if (_.isArray(obj[key])) { - obj[key].forEach((item, index) => { - const arrayKey = `${newKey}[${index}]`; - if (typeof item === 'object' && item !== null) { - flattenJson(item, arrayKey, result); - } else { - result[arrayKey] = item; - } - }); - } else { - result[newKey] = obj[key]; - } - } - - return result; -} -function flattenJsonWithArrayValues(valueKeyToDefaultsMap: Record>) { - const flattened = {}; - Object.keys(valueKeyToDefaultsMap).forEach((controlValue) => { - const defaultPayloads = valueKeyToDefaultsMap[controlValue]; - const defaultPlaceholders = getDotNotationKeys(defaultPayloads); - defaultPlaceholders.forEach((defaultPlaceholder) => { - if (!flattened[defaultPlaceholder]) { - flattened[defaultPlaceholder] = []; - } - flattened[defaultPlaceholder].push(controlValue); - }); - }); - - return flattened; -} - -type NestedRecord = Record; - -function getDotNotationKeys(input: NestedRecord, parentKey: string = '', keys: string[] = []): string[] { - for (const key in input) { - if (input.hasOwnProperty(key)) { - const newKey = parentKey ? `${parentKey}.${key}` : key; - - if (typeof input[key] === 'object' && input[key] !== null && !_.isArray(input[key])) { - getDotNotationKeys(input[key] as NestedRecord, newKey, keys); - } else { - keys.push(newKey); - } - } - } - - return keys; -} diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts deleted file mode 100644 index d2e73ea7104..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './build-default-payload-use-case.service'; diff --git a/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.command.ts b/apps/api/src/app/workflows-v2/usecases/build-step-data/build-step-data.command.ts similarity index 81% rename from apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.command.ts rename to apps/api/src/app/workflows-v2/usecases/build-step-data/build-step-data.command.ts index 20ab4ede7aa..4df980d5e1e 100644 --- a/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-step-data/build-step-data.command.ts @@ -2,7 +2,7 @@ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { IsNotEmpty, IsString } from 'class-validator'; import { IdentifierOrInternalId } from '@novu/shared'; -export class GetStepDataCommand extends EnvironmentWithUserObjectCommand { +export class BuildStepDataCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsNotEmpty() identifierOrInternalId: IdentifierOrInternalId; diff --git a/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-step-data/build-step-data.usecase.ts similarity index 62% rename from apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts rename to apps/api/src/app/workflows-v2/usecases/build-step-data/build-step-data.usecase.ts index a6701c03b34..cd0274c0dde 100644 --- a/apps/api/src/app/workflows-v2/usecases/get-step-schema/get-step-data.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-step-data/build-step-data.usecase.ts @@ -1,32 +1,27 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { ControlValuesLevelEnum, StepDataDto, WorkflowOriginEnum } from '@novu/shared'; import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; import { GetWorkflowByIdsUseCase } from '@novu/application-generic'; - -import { ControlValuesLevelEnum, StepDataDto } from '@novu/shared'; -import { GetStepDataCommand } from './get-step-data.command'; +import { BuildStepDataCommand } from './build-step-data.command'; import { InvalidStepException } from '../../exceptions/invalid-step.exception'; -import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder'; -import { BuildAvailableVariableSchemaUsecase } from './build-available-variable-schema-usecase.service'; -import { convertJsonToSchemaWithDefaults } from '../../util/jsonToSchema'; +import { BuildAvailableVariableSchemaUsecase } from '../build-variable-schema'; @Injectable() -export class GetStepDataUsecase { +export class BuildStepDataUsecase { constructor( private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase, private controlValuesRepository: ControlValuesRepository, private buildAvailableVariableSchemaUsecase: BuildAvailableVariableSchemaUsecase // Dependency injection for new use case ) {} - async execute(command: GetStepDataCommand): Promise { + async execute(command: BuildStepDataCommand): Promise { const workflow = await this.fetchWorkflow(command); - const { currentStep, previousSteps } = await this.loadStepsFromDb(command, workflow); - if (!currentStep.name || !currentStep._templateId || !currentStep.stepId) { + const { currentStep } = await this.loadStepsFromDb(command, workflow); + if (!currentStep.name || !currentStep._templateId || !currentStep.stepId || !currentStep.template?.type) { throw new InvalidStepException(currentStep); } const controlValues = await this.getValues(command, currentStep, workflow._id); - const payloadSchema = this.buildPayloadSchema(controlValues); return { controls: { @@ -35,24 +30,20 @@ export class GetStepDataUsecase { values: controlValues, }, variables: this.buildAvailableVariableSchemaUsecase.execute({ - previousSteps, - payloadSchema, + stepDatabaseId: currentStep._templateId, + workflow, }), // Use the new use case to build variables schema name: currentStep.name, _id: currentStep._templateId, stepId: currentStep.stepId, + type: currentStep.template?.type, + origin: workflow.origin || WorkflowOriginEnum.EXTERNAL, + workflowId: workflow.triggers[0].identifier, + workflowDatabaseId: workflow._id, }; } - private buildPayloadSchema(controlValues: Record) { - const payloadVariables = this.buildDefaultPayloadUseCase.execute({ - controlValues, - }).previewPayload.payload; - - return convertJsonToSchemaWithDefaults(payloadVariables); - } - - private async fetchWorkflow(command: GetStepDataCommand) { + private async fetchWorkflow(command: BuildStepDataCommand) { const workflow = await this.getWorkflowByIdsUseCase.execute({ identifierOrInternalId: command.identifierOrInternalId, environmentId: command.user.environmentId, @@ -70,7 +61,7 @@ export class GetStepDataUsecase { return workflow; } - private async getValues(command: GetStepDataCommand, currentStep: NotificationStepEntity, _workflowId: string) { + private async getValues(command: BuildStepDataCommand, currentStep: NotificationStepEntity, _workflowId: string) { const controlValuesEntity = await this.controlValuesRepository.findOne({ _environmentId: command.user.environmentId, _organizationId: command.user.organizationId, @@ -82,7 +73,7 @@ export class GetStepDataUsecase { return controlValuesEntity?.controls || {}; } - private async loadStepsFromDb(command: GetStepDataCommand, workflow: NotificationTemplateEntity) { + private async loadStepsFromDb(command: BuildStepDataCommand, workflow: NotificationTemplateEntity) { const currentStep = workflow.steps.find( (stepItem) => stepItem._id === command.stepId || stepItem.stepId === command.stepId ); @@ -95,11 +86,6 @@ export class GetStepDataUsecase { }); } - const previousSteps = workflow.steps.slice( - 0, - workflow.steps.findIndex((stepItem) => stepItem._id === command.stepId) - ); - - return { currentStep, previousSteps }; + return { currentStep }; } } diff --git a/apps/api/src/app/workflows-v2/usecases/build-step-data/index.ts b/apps/api/src/app/workflows-v2/usecases/build-step-data/index.ts new file mode 100644 index 00000000000..815fa04f24c --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/build-step-data/index.ts @@ -0,0 +1,2 @@ +export * from './build-step-data.command'; +export * from './build-step-data.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.command.ts b/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.command.ts similarity index 100% rename from apps/api/src/app/workflows-v2/usecases/test-data/test-data.command.ts rename to apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.command.ts diff --git a/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts new file mode 100644 index 00000000000..ab8ab77faed --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; +import { JSONSchemaDto, StepTypeEnum, UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared'; + +import { GetWorkflowByIdsCommand, GetWorkflowByIdsUseCase } from '@novu/application-generic'; +import { WorkflowTestDataCommand } from './build-workflow-test-data.command'; + +@Injectable() +export class BuildWorkflowTestDataUseCase { + constructor(private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase) {} + + async execute(command: WorkflowTestDataCommand): Promise { + const _workflowEntity: NotificationTemplateEntity = await this.fetchWorkflow(command); + const toSchema = buildToFieldSchema({ user: command.user, steps: _workflowEntity.steps }); + const payloadSchema = JSON.parse(_workflowEntity.payloadSchema); + + return { + to: toSchema, + payload: payloadSchema, + }; + } + + private async fetchWorkflow(command: WorkflowTestDataCommand): Promise { + return await this.getWorkflowByIdsUseCase.execute( + GetWorkflowByIdsCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + userId: command.user._id, + identifierOrInternalId: command.identifierOrInternalId, + }) + ); + } +} + +const buildToFieldSchema = ({ user, steps }: { user: UserSessionData; steps: NotificationStepEntity[] }) => { + const isEmailExist = isContainsStepType(steps, StepTypeEnum.EMAIL); + const isSmsExist = isContainsStepType(steps, StepTypeEnum.SMS); + + return { + type: 'object', + properties: { + subscriberId: { type: 'string', default: user._id }, + ...(isEmailExist ? { email: { type: 'string', default: user.email ?? '', format: 'email' } } : {}), + ...(isSmsExist ? { phone: { type: 'string', default: '' } } : {}), + }, + required: ['subscriberId', ...(isEmailExist ? ['email'] : []), ...(isSmsExist ? ['phone'] : [])], + additionalProperties: false, + } as const satisfies JSONSchemaDto; +}; + +function isContainsStepType(steps: NotificationStepEntity[], type: StepTypeEnum) { + return steps.some((step) => step.template?.type === type); +} diff --git a/apps/api/src/app/workflows-v2/usecases/build-test-data/index.ts b/apps/api/src/app/workflows-v2/usecases/build-test-data/index.ts new file mode 100644 index 00000000000..c64ade2adca --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/build-test-data/index.ts @@ -0,0 +1,2 @@ +export * from './build-workflow-test-data.command'; +export * from './build-workflow-test-data.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts new file mode 100644 index 00000000000..d1b35a10c04 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.command.ts @@ -0,0 +1,7 @@ +import { BaseCommand } from '@novu/application-generic'; +import { NotificationTemplateEntity } from '@novu/dal'; + +export class BuildAvailableVariableSchemaCommand extends BaseCommand { + workflow: NotificationTemplateEntity; + stepDatabaseId: string; +} diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts new file mode 100644 index 00000000000..e33ef03970e --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; +import { JSONSchemaDto } from '@novu/shared'; +import { computeResultSchema } from '../../shared'; +import { BuildAvailableVariableSchemaCommand } from './build-available-variable-schema.command'; + +@Injectable() +export class BuildAvailableVariableSchemaUsecase { + execute(command: BuildAvailableVariableSchemaCommand): JSONSchemaDto { + const { workflow } = command; + const previousSteps = workflow.steps.slice( + 0, + workflow.steps.findIndex((stepItem) => stepItem._id === command.stepDatabaseId) + ); + + return { + type: 'object', + properties: { + subscriber: { + type: 'object', + description: 'Schema representing the subscriber entity', + properties: { + firstName: { type: 'string', description: "Subscriber's first name" }, + lastName: { type: 'string', description: "Subscriber's last name" }, + email: { type: 'string', description: "Subscriber's email address" }, + phone: { type: 'string', description: "Subscriber's phone number (optional)" }, + avatar: { type: 'string', description: "URL to the subscriber's avatar image (optional)" }, + locale: { type: 'string', description: 'Locale for the subscriber (optional)' }, + subscriberId: { type: 'string', description: 'Unique identifier for the subscriber' }, + isOnline: { type: 'boolean', description: 'Indicates if the subscriber is online (optional)' }, + lastOnlineAt: { + type: 'string', + format: 'date-time', + description: 'The last time the subscriber was online (optional)', + }, + }, + required: ['firstName', 'lastName', 'email', 'subscriberId'], + additionalProperties: false, + }, + steps: buildPreviousStepsSchema(previousSteps, workflow.payloadSchema), + payload: safePayloadSchema(workflow) || { type: 'object', description: 'Payload for the current step' }, + }, + additionalProperties: false, + } as const satisfies JSONSchemaDto; + } +} +function safePayloadSchema(workflow: NotificationTemplateEntity): JSONSchemaDto | undefined { + try { + return JSON.parse(workflow.payloadSchema); + } catch (e) { + return undefined; + } +} + +function buildPreviousStepsProperties( + previousSteps: NotificationStepEntity[] | undefined, + payloadSchema?: JSONSchemaDto +) { + return (previousSteps || []).reduce( + (acc, step) => { + if (step.stepId && step.template?.type) { + acc[step.stepId] = computeResultSchema(step.template.type, payloadSchema); + } + + return acc; + }, + {} as Record + ); +} + +function buildPreviousStepsSchema( + previousSteps: NotificationStepEntity[] | undefined, + payloadSchema?: JSONSchemaDto +): JSONSchemaDto { + return { + type: 'object', + properties: buildPreviousStepsProperties(previousSteps, payloadSchema), + required: [], + additionalProperties: false, + description: 'Previous Steps Results', + } as const satisfies JSONSchemaDto; +} diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/index.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/index.ts new file mode 100644 index 00000000000..8e7f7668147 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/index.ts @@ -0,0 +1,2 @@ +export * from './build-available-variable-schema.command'; +export * from './build-available-variable-schema.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.command.ts similarity index 50% rename from apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts rename to apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.command.ts index df89e0532d4..5be4b2b4eb1 100644 --- a/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults-command.ts +++ b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.command.ts @@ -1,6 +1,6 @@ import { BaseCommand } from '@novu/application-generic'; import { JSONSchemaDto } from '@novu/shared'; -export class ExtractDefaultsCommand extends BaseCommand { - jsonSchemaDto: JSONSchemaDto; +export class ExtractDefaultValuesFromSchemaCommand extends BaseCommand { + jsonSchemaDto?: JSONSchemaDto; } diff --git a/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts similarity index 79% rename from apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts rename to apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts index 62db341c0c3..c5d70833cdb 100644 --- a/apps/api/src/app/workflows-v2/usecases/get-default-values-from-schema/extract-defaults.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts @@ -1,14 +1,19 @@ import { JSONSchemaDto } from '@novu/shared'; -import { ExtractDefaultsCommand } from './extract-defaults-command'; +import { Injectable } from '@nestjs/common'; +import { ExtractDefaultValuesFromSchemaCommand } from './extract-default-values-from-schema.command'; -export class ExtractDefaultsUsecase { +@Injectable() +export class ExtractDefaultValuesFromSchemaUsecase { /** * Executes the use case to extract default values from the JSON Schema. * @param command - The command containing the JSON Schema DTO. * @returns A nested JSON structure with field paths and their default values. */ - execute(command: ExtractDefaultsCommand): Record { + execute(command: ExtractDefaultValuesFromSchemaCommand): Record { const { jsonSchemaDto } = command; + if (!jsonSchemaDto) { + return {}; + } return this.extractDefaults(jsonSchemaDto); } diff --git a/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/index.ts b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/index.ts new file mode 100644 index 00000000000..56c7e3aa7e7 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/index.ts @@ -0,0 +1,2 @@ +export * from './extract-default-values-from-schema.command'; +export * from './extract-default-values-from-schema.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview-command.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.command.ts similarity index 100% rename from apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview-command.ts rename to apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.command.ts diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts index 9dbb438e548..b29953d59cc 100644 --- a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts @@ -1,91 +1,76 @@ import { Injectable } from '@nestjs/common'; import { ChannelTypeEnum, - ContentIssue, - ControlSchemas, + GeneratePreviewRequestDto, GeneratePreviewResponseDto, JobStatusEnum, PreviewPayload, - StepContentIssueEnum, - StepTypeEnum, - WorkflowOriginEnum, - WorkflowTypeEnum, + StepDataDto, } from '@novu/shared'; -import { merge } from 'lodash/fp'; -import _ = require('lodash'); -import { GetWorkflowByIdsUseCase } from '@novu/application-generic'; -import { NotificationTemplateEntity } from '@novu/dal'; -import { GeneratePreviewCommand } from './generate-preview-command'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; -import { StepMissingControlsException, StepNotFoundException } from '../../exceptions/step-not-found-exception'; -import { OriginMissingException, StepIdMissingException } from './step-id-missing.exception'; -import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder'; import { FrameworkPreviousStepsOutputState } from '../../../bridge/usecases/preview-step/preview-step.command'; -import { ValidateControlValuesAndConstructPassableStructureUsecase } from '../validate-control-values/build-default-control-values-usecase.service'; +import { StepMissingControlsException } from '../../exceptions/step-not-found-exception'; +import { PrepareAndValidateContentUsecase, ValidatedContentResponse } from '../validate-content'; +import { BuildStepDataUsecase } from '../build-step-data'; +import { GeneratePreviewCommand } from './generate-preview.command'; @Injectable() export class GeneratePreviewUsecase { constructor( private legacyPreviewStepUseCase: PreviewStep, - private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private constructPayloadUseCase: BuildDefaultPayloadUseCase, - private controlValuesUsecase: ValidateControlValuesAndConstructPassableStructureUsecase + private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase, + private buildStepDataUsecase: BuildStepDataUsecase ) {} async execute(command: GeneratePreviewCommand): Promise { - const payloadInfo = this.buildPayloadWithDefaults(command); - const workflowInfo = await this.getWorkflowUserIdentifierFromWorkflowObject(command); - const controlValuesResult = this.addMissingValuesToControlValues(command, workflowInfo.stepControlSchema); + const dto = command.generatePreviewRequestDto; + const stepData = await this.getStepData(command); + + const validatedContent: ValidatedContentResponse = await this.getValidatedContent(dto, stepData); const executeOutput = await this.executePreviewUsecase( - workflowInfo.workflowId, - workflowInfo.stepId, - workflowInfo.origin, - payloadInfo.previewPayload, - controlValuesResult.augmentedControlValues, - command + command, + stepData, + validatedContent.finalPayload, + validatedContent.finalControlValues ); - return buildResponse( - controlValuesResult.issuesMissingValues, - payloadInfo.issues, - executeOutput, - workflowInfo.stepType, - payloadInfo.previewPayload - ); + return { + issues: validatedContent.issues, // Use the issues from validatedContent + result: { + preview: executeOutput.outputs as any, + type: stepData.type as unknown as ChannelTypeEnum, + }, + previewPayloadExample: validatedContent.finalPayload, + }; } - private buildPayloadWithDefaults(command: GeneratePreviewCommand) { - const dto = command.generatePreviewRequestDto; - const { previewPayload, issues } = this.constructPayloadUseCase.execute({ - controlValues: dto.controlValues, - payloadValues: dto.previewPayload, - }); + private async getValidatedContent(dto: GeneratePreviewRequestDto, stepData: StepDataDto) { + if (!stepData.controls?.dataSchema) { + throw new StepMissingControlsException(stepData.stepId, stepData); + } - return { previewPayload, issues }; + return await this.prepareAndValidateContentUsecase.execute({ + controlValues: dto.controlValues || {}, + controlDataSchema: stepData.controls.dataSchema, + variableSchema: stepData.variables, + previewPayloadFromDto: dto.previewPayload, + }); } - private addMissingValuesToControlValues(command: GeneratePreviewCommand, stepControlSchema: ControlSchemas) { - return this.controlValuesUsecase.execute({ - controlSchema: stepControlSchema, - controlValues: command.generatePreviewRequestDto.controlValues || {}, + private async getStepData(command: GeneratePreviewCommand) { + return await this.buildStepDataUsecase.execute({ + identifierOrInternalId: command.workflowId, + stepId: command.stepDatabaseId, + user: command.user, }); } private async executePreviewUsecase( - workflowId: string, - stepId: string | undefined, - origin: WorkflowOriginEnum | undefined, + command: GeneratePreviewCommand, + stepData: StepDataDto, hydratedPayload: PreviewPayload, - updatedControlValues: Record, - command: GeneratePreviewCommand + updatedControlValues: Record ) { - if (!stepId) { - throw new StepIdMissingException(workflowId); - } - if (!origin) { - throw new OriginMissingException(stepId); - } - const state = buildState(hydratedPayload.steps); return await this.legacyPreviewStepUseCase.execute( @@ -95,105 +80,16 @@ export class GeneratePreviewUsecase { controls: updatedControlValues || {}, environmentId: command.user.environmentId, organizationId: command.user.organizationId, - stepId, + stepId: stepData.stepId, userId: command.user._id, - workflowId, - workflowOrigin: origin, + workflowId: stepData.workflowId, + workflowOrigin: stepData.origin, state, }) ); } - - private async getWorkflowUserIdentifierFromWorkflowObject(command: GeneratePreviewCommand) { - const persistedWorkflow = await this.getWorkflowByIdsUseCase.execute({ - identifierOrInternalId: command.workflowId, - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - }); - const { steps } = persistedWorkflow; - const step = steps.find((stepDto) => stepDto._id === command.stepDatabaseId); - if (!step) { - throw new StepNotFoundException(command.stepDatabaseId); - } - if (!step.template || !step.template.controls) { - throw new StepMissingControlsException(command.stepDatabaseId, step); - } - const origin = this.buildOrigin(persistedWorkflow); - - return { - workflowId: persistedWorkflow.triggers[0].identifier, - stepId: step.stepId, - stepType: step.template.type, - stepControlSchema: step.template.controls, - origin, - }; - } - - /** - * Builds the origin of the workflow based on the workflow type. - * If the origin is not set, it will be built based on the workflow type. - * We need to do so for backward compatibility reasons. - */ - private buildOrigin(persistedWorkflow: NotificationTemplateEntity): WorkflowOriginEnum { - if (persistedWorkflow.origin) { - return persistedWorkflow.origin; - } - - if (persistedWorkflow.type === WorkflowTypeEnum.ECHO || persistedWorkflow.type === WorkflowTypeEnum.BRIDGE) { - return WorkflowOriginEnum.EXTERNAL; - } - - if (persistedWorkflow.type === WorkflowTypeEnum.REGULAR) { - return WorkflowOriginEnum.NOVU_CLOUD_V1; - } - - return WorkflowOriginEnum.NOVU_CLOUD; - } } -function buildResponse( - missingValuesIssue: Record, - missingPayloadVariablesIssue: Record, - executionOutput, - stepType: StepTypeEnum, - augmentedPayload: PreviewPayload -): GeneratePreviewResponseDto { - return { - issues: merge(missingValuesIssue, missingPayloadVariablesIssue), - result: { - preview: executionOutput.outputs as any, - type: stepType as unknown as ChannelTypeEnum, - }, - previewPayloadExample: augmentedPayload, - }; -} - -function findMissingKeys(requiredRecord: Record, actualRecord: Record): string[] { - const requiredKeys = collectKeys(requiredRecord); - const actualKeys = collectKeys(actualRecord); - - return _.difference(requiredKeys, actualKeys); -} - -function collectKeys(obj: Record, prefix = ''): string[] { - // Initialize result as an empty array of strings - return _.reduce( - obj, - (result: string[], value, key) => { - const newKey = prefix ? `${prefix}.${key}` : key; - if (_.isObject(value) && !_.isArray(value)) { - // Call collectKeys recursively and concatenate the results - result.push(...collectKeys(value, newKey)); - } else { - result.push(newKey); - } - - return result; - }, - [] // Pass an empty array as the initial value - ); -} function buildState(steps: Record | undefined): FrameworkPreviousStepsOutputState[] { const outputArray: FrameworkPreviousStepsOutputState[] = []; for (const [stepId, value] of Object.entries(steps || {})) { diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/index.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/index.ts new file mode 100644 index 00000000000..62ba75b1671 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/index.ts @@ -0,0 +1,2 @@ +export * from './generate-preview.command'; +export * from './generate-preview.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/get-step-schema/build-available-variable-schema-usecase.service.ts b/apps/api/src/app/workflows-v2/usecases/get-step-schema/build-available-variable-schema-usecase.service.ts deleted file mode 100644 index 6b10c950042..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/get-step-schema/build-available-variable-schema-usecase.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { NotificationStepEntity } from '@novu/dal'; -import { JSONSchemaDto } from '@novu/shared'; -import { computeResultSchema } from '../../shared'; - -@Injectable() -class BuildAvailableVariableSchemaCommand { - previousSteps: NotificationStepEntity[] | undefined; - payloadSchema: JSONSchemaDto; -} - -export class BuildAvailableVariableSchemaUsecase { - execute(command: BuildAvailableVariableSchemaCommand): JSONSchemaDto { - const { previousSteps, payloadSchema } = command; - - return { - type: 'object', - properties: { - subscriber: buildSubscriberSchema(), - steps: buildPreviousStepsSchema(previousSteps, payloadSchema), - payload: payloadSchema, - }, - additionalProperties: false, - } as const satisfies JSONSchemaDto; - } -} - -function buildPreviousStepsProperties( - previousSteps: NotificationStepEntity[] | undefined, - payloadSchema?: JSONSchemaDto -) { - return (previousSteps || []).reduce( - (acc, step) => { - if (step.stepId && step.template?.type) { - acc[step.stepId] = computeResultSchema(step.template.type, payloadSchema); - } - - return acc; - }, - {} as Record - ); -} - -function buildPreviousStepsSchema( - previousSteps: NotificationStepEntity[] | undefined, - payloadSchema?: JSONSchemaDto -): JSONSchemaDto { - return { - type: 'object', - properties: buildPreviousStepsProperties(previousSteps, payloadSchema), - required: [], - additionalProperties: false, - description: 'Previous Steps Results', - } as const satisfies JSONSchemaDto; -} -const buildSubscriberSchema = () => - ({ - type: 'object', - description: 'Schema representing the subscriber entity', - properties: { - firstName: { type: 'string', description: "Subscriber's first name" }, - lastName: { type: 'string', description: "Subscriber's last name" }, - email: { type: 'string', description: "Subscriber's email address" }, - phone: { type: 'string', description: "Subscriber's phone number (optional)" }, - avatar: { type: 'string', description: "URL to the subscriber's avatar image (optional)" }, - locale: { type: 'string', description: 'Locale for the subscriber (optional)' }, - subscriberId: { type: 'string', description: 'Unique identifier for the subscriber' }, - isOnline: { type: 'boolean', description: 'Indicates if the subscriber is online (optional)' }, - lastOnlineAt: { - type: 'string', - format: 'date-time', - description: 'The last time the subscriber was online (optional)', - }, - }, - additionalProperties: false, - }) as const satisfies JSONSchemaDto; diff --git a/apps/api/src/app/workflows-v2/usecases/get-workflow/index.ts b/apps/api/src/app/workflows-v2/usecases/get-workflow/index.ts new file mode 100644 index 00000000000..2f4ae789920 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/get-workflow/index.ts @@ -0,0 +1,2 @@ +export * from './get-workflow.command'; +export * from './get-workflow.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/index.ts b/apps/api/src/app/workflows-v2/usecases/index.ts new file mode 100644 index 00000000000..0729d353617 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/index.ts @@ -0,0 +1,11 @@ +export * from './build-step-data'; +export * from './build-test-data'; +export * from './build-variable-schema'; +export * from './extract-default-values-from-schema'; +export * from './generate-preview'; +export * from './get-workflow'; +export * from './list-workflows'; +export * from './sync-to-environment'; +export * from './upsert-workflow'; +export * from './validate-content'; +export * from './process-workflow-issues'; diff --git a/apps/api/src/app/workflows-v2/usecases/list-workflows/index.ts b/apps/api/src/app/workflows-v2/usecases/list-workflows/index.ts new file mode 100644 index 00000000000..8e28e79efd8 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/list-workflows/index.ts @@ -0,0 +1,2 @@ +export * from './list-workflow.usecase'; +export * from './list-workflows.command'; diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts deleted file mode 100644 index 8a86684f971..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/add-keys-to-payload-based-on-hydration-strategy-command.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { BaseCommand } from '@novu/application-generic'; - -export class AddKeysToPayloadBasedOnHydrationStrategyCommand extends BaseCommand { - controlValues: Record; - controlValueKey: string; -} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts deleted file mode 100644 index 966bc3e6722..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BaseCommand } from '@novu/application-generic'; - -export class BuildPayloadNestedStructureCommand extends BaseCommand { - placeholdersDotNotation: string[]; -} - -export class BuildPayloadNestedStructureUsecase { - public execute(command: BuildPayloadNestedStructureCommand): Record { - const defaultPayload: Record = {}; - - const setNestedValue = (obj: Record, path: string, value: any) => { - const keys = path.split('.'); - let current = obj; - - keys.forEach((key, index) => { - if (!current.hasOwnProperty(key)) { - current[key] = index === keys.length - 1 ? value : {}; - } - current = current[key]; - }); - }; - - for (const placeholderWithDotNotation of command.placeholdersDotNotation) { - setNestedValue(defaultPayload, placeholderWithDotNotation, `{{${placeholderWithDotNotation}}}`); - } - - return defaultPayload; - } -} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts deleted file mode 100644 index 40787c160e6..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { InternalServerErrorException } from '@nestjs/common'; - -export class PayloadDefaultsEngineFailureException extends InternalServerErrorException { - constructor(notATextControlValue: object) { - super({ message: `Payload Default construct, Control value is not a primitive: `, notATextControlValue }); - } -} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts deleted file mode 100644 index e3b8c15fd34..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AddKeysToPayloadBasedOnHydrationStrategyCommand } from './add-keys-to-payload-based-on-hydration-strategy-command'; -import { HydrateEmailSchemaUseCase } from '../../../environments-v1/usecases/output-renderers'; -import { - BuildPayloadNestedStructureCommand, - BuildPayloadNestedStructureUsecase, -} from './buildPayloadNestedStructureUsecase'; -import { PayloadDefaultsEngineFailureException } from './payload-defaults-engine-failure.exception'; - -const unsupportedPrefixes: string[] = ['actor']; -@Injectable() -export class CreateMockPayloadForSingleControlValueUseCase { - constructor( - private readonly transformPlaceholderMapUseCase: BuildPayloadNestedStructureUsecase, - private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase - ) {} - public execute(command: AddKeysToPayloadBasedOnHydrationStrategyCommand): Record { - const { controlValues, controlValueKey } = command; - - if (!controlValues) { - return {}; - } - - const controlValue = controlValues[controlValueKey]; - const payloadFromEmailSchema = this.safeAttemptToParseEmailSchema(controlValue); - if (payloadFromEmailSchema) { - return payloadFromEmailSchema; - } - - return this.buildPayloadForRegularText(controlValue); - } - - private safeAttemptToParseEmailSchema(controlValue: string) { - try { - const { nestedPayload } = this.hydrateEmailSchemaUseCase.execute({ - emailEditor: controlValue, - fullPayloadForRender: { - payload: {}, - subscriber: {}, - steps: {}, - }, - }); - - return nestedPayload; - } catch (e) { - return undefined; - } - } - - private buildPayloadForRegularText(controlValue: unknown) { - const placeholders = extractPlaceholders(controlValue).filter( - (placeholder) => !unsupportedPrefixes.some((prefix) => placeholder.startsWith(prefix)) - ); - - return this.transformPlaceholderMapUseCase.execute( - BuildPayloadNestedStructureCommand.create({ placeholdersDotNotation: placeholders }) - ); - } -} - -export function extractPlaceholders(potentialText: unknown): string[] { - if (!potentialText || typeof potentialText === 'number') { - return []; - } - if (typeof potentialText === 'object') { - throw new PayloadDefaultsEngineFailureException(potentialText); - } - - if (typeof potentialText !== 'string') { - return []; - } - - const regex = /\{\{\{(.*?)\}\}\}|\{\{(.*?)\}\}|\{#(.*?)#\}/g; // todo: add support for nested placeholders - const matches: string[] = []; - let match: RegExpExecArray | null; - - // eslint-disable-next-line no-cond-assign - while ((match = regex.exec(potentialText)) !== null) { - const placeholder = match[1] || match[2] || match[3]; - - if (placeholder) { - matches.push(placeholder.trim()); - } - } - - return matches; -} -function convertToRecord(keys: string[]): Record { - return keys.reduce( - (acc, key) => { - acc[key] = ''; // You can set the value to any default value you want - - return acc; - }, - {} as Record - ); -} diff --git a/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/index.ts b/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/index.ts new file mode 100644 index 00000000000..7ba50e22f2c --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/index.ts @@ -0,0 +1,2 @@ +export * from './process-workflow-issues.usecase'; +export * from './process-workflow-issues.command'; diff --git a/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.command.ts b/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.command.ts index 86839190124..21a848c1f3b 100644 --- a/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.command.ts @@ -1,8 +1,10 @@ import { EnvironmentWithUserObjectCommand, GetPreferencesResponseDto } from '@novu/application-generic'; import { ControlValuesEntity, NotificationTemplateEntity } from '@novu/dal'; +import { ValidatedContentResponse } from '../validate-content'; export class ProcessWorkflowIssuesCommand extends EnvironmentWithUserObjectCommand { workflow: NotificationTemplateEntity; preferences?: GetPreferencesResponseDto; stepIdToControlValuesMap: { [p: string]: ControlValuesEntity }; + validatedContentsArray: Record; } diff --git a/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.usecase.ts b/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.usecase.ts index 9cd4857750b..f288a4862d0 100644 --- a/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/process-workflow-issues/process-workflow-issues.usecase.ts @@ -1,6 +1,8 @@ import { ContentIssue, RuntimeIssue, + StepCreateAndUpdateKeys, + StepIssue, StepIssueEnum, StepIssues, StepIssuesDto, @@ -8,46 +10,35 @@ import { WorkflowResponseDto, WorkflowStatusEnum, } from '@novu/shared'; -import { - ControlValuesEntity, - NotificationStepEntity, - NotificationTemplateEntity, - NotificationTemplateRepository, -} from '@novu/dal'; +import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { Injectable } from '@nestjs/common'; import { ProcessWorkflowIssuesCommand } from './process-workflow-issues.command'; -import { ValidateControlValuesAndConstructPassableStructureUsecase } from '../validate-control-values/build-default-control-values-usecase.service'; -import { WorkflowNotFoundException } from '../../exceptions/workflow-not-found-exception'; +import { ValidatedContentResponse } from '../validate-content'; @Injectable() export class ProcessWorkflowIssuesUsecase { - constructor( - private notificationTemplateRepository: NotificationTemplateRepository, - private buildDefaultControlValuesUsecase: ValidateControlValuesAndConstructPassableStructureUsecase - ) {} + constructor(private notificationTemplateRepository: NotificationTemplateRepository) {} async execute(command: ProcessWorkflowIssuesCommand): Promise { const workflowIssues = await this.validateWorkflow(command); - const stepIssues = this.validateSteps(command.workflow.steps, command.stepIdToControlValuesMap); + const stepIssues = this.validateSteps(command.workflow.steps, command.validatedContentsArray); const workflowWithIssues = this.updateIssuesOnWorkflow(command.workflow, workflowIssues, stepIssues); - await this.persistWorkflow(command, workflowWithIssues); - return await this.getWorkflow(command); + return this.updateStatusOnWorkflow(workflowWithIssues); + } + + private updateStatusOnWorkflow(workflowWithIssues: NotificationTemplateEntity) { + // eslint-disable-next-line no-param-reassign + workflowWithIssues.status = this.computeStatus(workflowWithIssues); + + return workflowWithIssues; } - private async persistWorkflow(command: ProcessWorkflowIssuesCommand, workflowWithIssues: NotificationTemplateEntity) { + private computeStatus(workflowWithIssues) { const isWorkflowCompleteAndValid = this.isWorkflowCompleteAndValid(workflowWithIssues); const status = this.calculateStatus(isWorkflowCompleteAndValid, workflowWithIssues); - await this.notificationTemplateRepository.update( - { - _id: command.workflow._id, - _environmentId: command.user.environmentId, - }, - { - ...workflowWithIssues, - status, - } - ); + + return status; } private calculateStatus(isGoodWorkflow: boolean, workflowWithIssues: NotificationTemplateEntity) { @@ -80,48 +71,21 @@ export class ProcessWorkflowIssuesUsecase { private hasBodyIssues(issue: StepIssues) { return issue.body && Object.keys(issue.body).length > 0; } - - private async getWorkflow(command: ProcessWorkflowIssuesCommand) { - const entity = await this.notificationTemplateRepository.findById(command.workflow._id, command.user.environmentId); - if (entity == null) { - throw new WorkflowNotFoundException(command.workflow._id); - } - - return entity; - } - private validateSteps( steps: NotificationStepEntity[], - stepIdToControlValuesMap: { [p: string]: ControlValuesEntity } + validatedContentsArray: Record ): Record { const stepIdToIssues: Record = {}; for (const step of steps) { - // @ts-ignore - const stepIssues: Required = { body: {}, controls: {} }; - this.addControlIssues(step, stepIdToControlValuesMap, stepIssues); - this.addStepBodyIssues(step, stepIssues); - stepIdToIssues[step._templateId] = stepIssues; + stepIdToIssues[step._templateId] = { + body: this.addStepBodyIssues(step), + controls: validatedContentsArray[step._templateId]?.issues || {}, + }; } return stepIdToIssues; } - private addControlIssues( - step: NotificationStepEntity, - stepIdToControlValuesMap: { - [p: string]: ControlValuesEntity; - }, - stepIssues: StepIssuesDto - ) { - if (step.template?.controls) { - const { issuesMissingValues } = this.buildDefaultControlValuesUsecase.execute({ - controlSchema: step.template?.controls, - controlValues: stepIdToControlValuesMap[step._templateId].controls, - }); - // eslint-disable-next-line no-param-reassign - stepIssues.controls = issuesMissingValues; - } - } private async validateWorkflow( command: ProcessWorkflowIssuesCommand ): Promise> { @@ -144,47 +108,6 @@ export class ProcessWorkflowIssuesUsecase { issues.name = [{ issueType: WorkflowIssueTypeEnum.MISSING_VALUE, message: 'Name is missing' }]; } } - - private addTagsIssues( - command: ProcessWorkflowIssuesCommand, - issues: Record - ) { - const tags = command.workflow.tags?.map((tag) => tag.trim()); - - if (!tags.length) { - return; - } - - const tagsIssues: RuntimeIssue[] = []; - - const duplicatedTags = tags.filter((tag, index) => tags.indexOf(tag) !== index); - const hasDuplications = duplicatedTags.length > 0; - if (hasDuplications) { - tagsIssues.push({ - issueType: WorkflowIssueTypeEnum.DUPLICATED_VALUE, - message: `Duplicated tags: ${duplicatedTags.join(', ')}`, - }); - } - - const hasEmptyTags = tags?.some((tag) => !tag || tag === ''); - if (hasEmptyTags) { - tagsIssues.push({ issueType: WorkflowIssueTypeEnum.MISSING_VALUE, message: 'Empty tag value' }); - } - - const exceedsMaxLength = tags?.some((tag) => tag.length > 8); - if (exceedsMaxLength) { - tagsIssues.push({ - issueType: WorkflowIssueTypeEnum.LIMIT_REACHED, - message: 'Exceeded the 8 tag limit', - }); - } - - if (tagsIssues.length > 0) { - // eslint-disable-next-line no-param-reassign - issues.tags = tagsIssues; - } - } - private addDescriptionTooLongIfApplicable( command: ProcessWorkflowIssuesCommand, issues: Record @@ -218,14 +141,17 @@ export class ProcessWorkflowIssuesUsecase { } } - private addStepBodyIssues(step: NotificationStepEntity, stepIssues: Required) { + private addStepBodyIssues(step: NotificationStepEntity) { + // @ts-ignore + const issues: Record = {}; if (!step.name || step.name.trim() === '') { - // eslint-disable-next-line no-param-reassign - stepIssues.body.name = { + issues.name = { issueType: StepIssueEnum.MISSING_REQUIRED_VALUE, message: 'Step name is missing', }; } + + return issues; } private updateIssuesOnWorkflow( @@ -244,4 +170,43 @@ export class ProcessWorkflowIssuesUsecase { return { ...workflow, issues }; } + private addTagsIssues( + command: ProcessWorkflowIssuesCommand, + issues: Record + ) { + const tags = command.workflow.tags?.map((tag) => tag.trim()); + + if (!tags.length) { + return; + } + + const tagsIssues: RuntimeIssue[] = []; + + const duplicatedTags = tags.filter((tag, index) => tags.indexOf(tag) !== index); + const hasDuplications = duplicatedTags.length > 0; + if (hasDuplications) { + tagsIssues.push({ + issueType: WorkflowIssueTypeEnum.DUPLICATED_VALUE, + message: `Duplicated tags: ${duplicatedTags.join(', ')}`, + }); + } + + const hasEmptyTags = tags?.some((tag) => !tag || tag === ''); + if (hasEmptyTags) { + tagsIssues.push({ issueType: WorkflowIssueTypeEnum.MISSING_VALUE, message: 'Empty tag value' }); + } + + const exceedsMaxLength = tags?.some((tag) => tag.length > 8); + if (exceedsMaxLength) { + tagsIssues.push({ + issueType: WorkflowIssueTypeEnum.LIMIT_REACHED, + message: 'Exceeded the 8 tag limit', + }); + } + + if (tagsIssues.length > 0) { + // eslint-disable-next-line no-param-reassign + issues.tags = tagsIssues; + } + } } diff --git a/apps/api/src/app/workflows-v2/usecases/sync-to-environment/index.ts b/apps/api/src/app/workflows-v2/usecases/sync-to-environment/index.ts new file mode 100644 index 00000000000..084368cd267 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/sync-to-environment/index.ts @@ -0,0 +1,2 @@ +export * from './sync-to-environment.command'; +export * from './sync-to-environment.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts b/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts index ab766dec611..84fb215fc22 100644 --- a/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts @@ -13,11 +13,9 @@ import { } from '@novu/shared'; import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; import { SyncToEnvironmentCommand } from './sync-to-environment.command'; -import { UpsertWorkflowUseCase } from '../upsert-workflow/upsert-workflow.usecase'; -import { UpsertWorkflowCommand } from '../upsert-workflow/upsert-workflow.command'; -import { GetWorkflowUseCase } from '../get-workflow/get-workflow.usecase'; -import { GetStepDataUsecase } from '../get-step-schema/get-step-data.usecase'; -import { GetWorkflowCommand } from '../get-workflow/get-workflow.command'; +import { GetWorkflowCommand, GetWorkflowUseCase } from '../get-workflow'; +import { UpsertWorkflowCommand, UpsertWorkflowUseCase } from '../upsert-workflow'; +import { BuildStepDataUsecase } from '../build-step-data'; /** * This usecase is used to sync a workflow from one environment to another. @@ -34,7 +32,7 @@ export class SyncToEnvironmentUseCase { private getWorkflowUseCase: GetWorkflowUseCase, private preferencesRepository: PreferencesRepository, private upsertWorkflowUseCase: UpsertWorkflowUseCase, - private getStepData: GetStepDataUsecase + private buildStepDataUsecase: BuildStepDataUsecase ) {} async execute(command: SyncToEnvironmentCommand): Promise { @@ -137,7 +135,7 @@ export class SyncToEnvironmentUseCase { const augmentedSteps: (StepUpdateDto | StepCreateDto)[] = []; for (const step of steps) { const idAsOptionalObject = this.prodDbIdAsOptionalObject(existingWorkflowSteps, step); - const stepDataDto = await this.getStepData.execute({ + const stepDataDto = await this.buildStepDataUsecase.execute({ identifierOrInternalId: command.identifierOrInternalId, stepId: step.stepId, user: command.user, @@ -199,14 +197,12 @@ export class SyncToEnvironmentUseCase { } private async getWorkflowPreferences(workflowId: string, environmentId: string): Promise { - const workflowPreferences = await this.preferencesRepository.find({ + return await this.preferencesRepository.find({ _templateId: workflowId, _environmentId: environmentId, type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW], }, }); - - return workflowPreferences; } } diff --git a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts deleted file mode 100644 index 2a187641df4..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; -import { - ControlValuesLevelEnum, - JSONSchemaDto, - StepTypeEnum, - UserSessionData, - WorkflowTestDataResponseDto, -} from '@novu/shared'; - -import { GetWorkflowByIdsCommand, GetWorkflowByIdsUseCase } from '@novu/application-generic'; -import { WorkflowTestDataCommand } from './test-data.command'; -import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder'; -import { convertJsonToSchemaWithDefaults } from '../../util/jsonToSchema'; - -@Injectable() -export class WorkflowTestDataUseCase { - constructor( - private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private controlValuesRepository: ControlValuesRepository, - private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase - ) {} - - async execute(command: WorkflowTestDataCommand): Promise { - const _workflowEntity: NotificationTemplateEntity = await this.fetchWorkflow(command); - const toSchema = buildToFieldSchema({ user: command.user, steps: _workflowEntity.steps }); - const payloadSchema = await this.buildAggregateWorkflowPayloadSchema(command, _workflowEntity); - - return { - to: toSchema, - payload: payloadSchema, - }; - } - - private async fetchWorkflow(command: WorkflowTestDataCommand): Promise { - return await this.getWorkflowByIdsUseCase.execute( - GetWorkflowByIdsCommand.create({ - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - identifierOrInternalId: command.identifierOrInternalId, - }) - ); - } - - private async buildAggregateWorkflowPayloadSchema( - command: WorkflowTestDataCommand, - _workflowEntity: NotificationTemplateEntity - ): Promise { - let payloadExampleForWorkflow: Record = {}; - for (const step of _workflowEntity.steps) { - const controlValuesForStep = await this.getValues(command.user, step._templateId, _workflowEntity._id); - const payloadExampleForStep = this.buildDefaultPayloadUseCase.execute({ - controlValues: controlValuesForStep, - }).previewPayload.payload; - payloadExampleForWorkflow = { ...payloadExampleForWorkflow, ...payloadExampleForStep }; - } - - return convertJsonToSchemaWithDefaults(payloadExampleForWorkflow); - } - - private async getValues(user: UserSessionData, _stepId: string, _workflowId: string) { - const controlValuesEntity = await this.controlValuesRepository.findOne({ - _environmentId: user.environmentId, - _organizationId: user.organizationId, - _workflowId, - _stepId, - level: ControlValuesLevelEnum.STEP_CONTROLS, - }); - - return controlValuesEntity?.controls || {}; - } -} - -const buildToFieldSchema = ({ user, steps }: { user: UserSessionData; steps: NotificationStepEntity[] }) => { - const isEmailExist = isContainsStepType(steps, StepTypeEnum.EMAIL); - const isSmsExist = isContainsStepType(steps, StepTypeEnum.SMS); - - return { - type: 'object', - properties: { - subscriberId: { type: 'string', default: user._id }, - ...(isEmailExist ? { email: { type: 'string', default: user.email ?? '', format: 'email' } } : {}), - ...(isSmsExist ? { phone: { type: 'string', default: '' } } : {}), - }, - required: ['subscriberId', ...(isEmailExist ? ['email'] : []), ...(isSmsExist ? ['phone'] : [])], - additionalProperties: false, - } as const satisfies JSONSchemaDto; -}; - -function isContainsStepType(steps: NotificationStepEntity[], type: StepTypeEnum) { - return steps.some((step) => step.template?.type === type); -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts new file mode 100644 index 00000000000..05fdb87e144 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts @@ -0,0 +1,2 @@ +export * from './upsert-workflow.command'; +export * from './upsert-workflow.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts index b8e94da10c3..dc6fb99318a 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -1,11 +1,26 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; - import { ControlValuesEntity, NotificationGroupRepository, NotificationStepEntity, NotificationTemplateEntity, + NotificationTemplateRepository, } from '@novu/dal'; +import { + CreateWorkflowDto, + DEFAULT_WORKFLOW_PREFERENCES, + IdentifierOrInternalId, + slugify, + StepCreateDto, + StepDto, + StepUpdateDto, + UpdateWorkflowDto, + UserSessionData, + WorkflowCreationSourceEnum, + WorkflowOriginEnum, + WorkflowPreferences, + WorkflowResponseDto, + WorkflowTypeEnum, +} from '@novu/shared'; import { CreateWorkflow as CreateWorkflowGeneric, CreateWorkflowCommand, @@ -24,28 +39,18 @@ import { UpsertUserWorkflowPreferencesCommand, UpsertWorkflowPreferencesCommand, } from '@novu/application-generic'; -import { - CreateWorkflowDto, - DEFAULT_WORKFLOW_PREFERENCES, - IdentifierOrInternalId, - slugify, - StepCreateDto, - StepDto, - StepUpdateDto, - UpdateWorkflowDto, - UserSessionData, - WorkflowCreationSourceEnum, - WorkflowOriginEnum, - WorkflowPreferences, - WorkflowResponseDto, - WorkflowTypeEnum, -} from '@novu/shared'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import _ = require('lodash'); import { UpsertWorkflowCommand } from './upsert-workflow.command'; -import { StepUpsertMechanismFailedMissingIdException } from '../../exceptions/step-upsert-mechanism-failed-missing-id.exception'; +import { PrepareAndValidateContentUsecase, ValidatedContentResponse } from '../validate-content'; +import { BuildAvailableVariableSchemaUsecase } from '../build-variable-schema'; import { toResponseWorkflowDto } from '../../mappers/notification-template-mapper'; +import { convertJsonToSchemaWithDefaults } from '../../util/jsonToSchema'; +import { StepUpsertMechanismFailedMissingIdException } from '../../exceptions/step-upsert-mechanism-failed-missing-id.exception'; import { stepTypeToDefaultDashboardControlSchema } from '../../shared'; -import { ProcessWorkflowIssuesUsecase } from '../process-workflow-issues/process-workflow-issues.usecase'; -import { ProcessWorkflowIssuesCommand } from '../process-workflow-issues/process-workflow-issues.command'; +import { StepMissingControlsException } from '../../exceptions/step-not-found-exception'; +import { ProcessWorkflowIssuesUsecase } from '../process-workflow-issues'; +import { WorkflowNotFoundException } from '../../exceptions/workflow-not-found-exception'; function buildUpsertControlValuesCommand( command: UpsertWorkflowCommand, @@ -72,24 +77,61 @@ export class UpsertWorkflowUseCase { private upsertControlValuesUseCase: UpsertControlValuesUseCase, private processWorkflowIssuesUsecase: ProcessWorkflowIssuesUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private getPreferencesUseCase: GetPreferences + private getPreferencesUseCase: GetPreferences, + private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase, + private buildAvailableVariableSchemaUsecase: BuildAvailableVariableSchemaUsecase, + private notificationTemplateRepository: NotificationTemplateRepository ) {} async execute(command: UpsertWorkflowCommand): Promise { const workflowForUpdate = await this.queryWorkflow(command); - const workflow = await this.createOrUpdateWorkflow(workflowForUpdate, command); const stepIdToControlValuesMap = await this.upsertControlValues(workflow, command); const preferences = await this.upsertPreference(command, workflow); - const workflowIssues = await this.processWorkflowIssuesUsecase.execute( - ProcessWorkflowIssuesCommand.create({ - user: command.user, - workflow, - preferences, - stepIdToControlValuesMap, - }) + const validatedContentsArray = await this.validateStepContent(workflow, stepIdToControlValuesMap); + await this.overloadPayloadSchemaOnWorkflow(workflow, validatedContentsArray); + const validatedWorkflowWithIssues = await this.processWorkflowIssuesUsecase.execute({ + user: command.user, + workflow, + preferences, + stepIdToControlValuesMap, + validatedContentsArray, + }); + await this.persistWorkflow(validatedWorkflowWithIssues, command); + const persistedWorkflow = await this.getWorkflow(validatedWorkflowWithIssues._id, command.user.environmentId); + + return toResponseWorkflowDto(persistedWorkflow, preferences); + } + private async getWorkflow(workflowId: string, environmentId: string) { + const entity = await this.notificationTemplateRepository.findById(workflowId, environmentId); + if (entity == null) { + throw new WorkflowNotFoundException(workflowId); + } + + return entity; + } + + private async persistWorkflow(workflowWithIssues: NotificationTemplateEntity, command: UpsertWorkflowCommand) { + await this.notificationTemplateRepository.update( + { + _id: workflowWithIssues._id, + _environmentId: command.user.environmentId, + }, + { + ...workflowWithIssues, + } ); + } - return toResponseWorkflowDto(workflowIssues, preferences); + async overloadPayloadSchemaOnWorkflow( + workflow: NotificationTemplateEntity, + stepIdToControlValuesMap: { [p: string]: ValidatedContentResponse } + ) { + let finalPayload = {}; + for (const value of Object.values(stepIdToControlValuesMap)) { + finalPayload = _.merge(finalPayload, value.finalPayload.payload); + } + // eslint-disable-next-line no-param-reassign + workflow.payloadSchema = JSON.stringify(convertJsonToSchemaWithDefaults(finalPayload)); } private async queryWorkflow(command: UpsertWorkflowCommand): Promise { @@ -373,6 +415,32 @@ export class UpsertWorkflowUseCase { ) )?._id; } + + private async validateStepContent( + workflow: NotificationTemplateEntity, + stepIdToControlValuesMap: Record + ) { + const validatedStepContent: Record = {}; + + for (const step of workflow.steps) { + const controls = step.template?.controls; + if (!controls) { + throw new StepMissingControlsException(step._templateId, step); + } + const controlValues = stepIdToControlValuesMap[step._templateId]; + const jsonSchemaDto = this.buildAvailableVariableSchemaUsecase.execute({ + workflow, + stepDatabaseId: step._templateId, + }); + validatedStepContent[step._templateId] = await this.prepareAndValidateContentUsecase.execute({ + controlDataSchema: controls.schema, + controlValues: controlValues?.controls || {}, + variableSchema: jsonSchemaDto, + }); + } + + return validatedStepContent; + } } function isWorkflowUpdateDto( diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.command.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.command.ts new file mode 100644 index 00000000000..c1da3361b20 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.command.ts @@ -0,0 +1,6 @@ +import { BaseCommand } from '@novu/application-generic'; +import { ValidatedPlaceholderAggregation } from '../validate-placeholders'; + +export class BuildDefaultPayloadCommand extends BaseCommand { + placeholderAggregators: ValidatedPlaceholderAggregation[]; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.response.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.response.ts new file mode 100644 index 00000000000..fee8ae462ba --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.response.ts @@ -0,0 +1,5 @@ +import { PreviewPayload } from '@novu/shared'; + +export class BuildDefaultPayloadResponse { + previewPayload: PreviewPayload; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.usecase.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.usecase.ts new file mode 100644 index 00000000000..8d56f431abf --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/build-default-payload.usecase.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { merge } from 'lodash'; +import { BuildDefaultPayloadCommand } from './build-default-payload.command'; +import { BuildDefaultPayloadResponse } from './build-default-payload.response'; +import { ValidatedPlaceholderAggregation } from '../validate-placeholders'; + +@Injectable() +export class BuildDefaultPayloadUsecase { + execute(command: BuildDefaultPayloadCommand): BuildDefaultPayloadResponse { + let localPayload: Record = {}; + for (const placeholderAggregator of command.placeholderAggregators) { + const regularPayload = this.buildRegularPayload(placeholderAggregator); + const nestedPayload = this.buildNestedPayload(placeholderAggregator); + localPayload = merge(merge(localPayload, regularPayload), nestedPayload); + } + + return { previewPayload: localPayload }; + } + + private buildRegularPayload(placeholderAggregator: ValidatedPlaceholderAggregation) { + return removeBracketsAndFlattenToNested(placeholderAggregator.validRegularPlaceholdersToDefaultValue); + } + + private buildNestedPayload(placeholderAggregator: ValidatedPlaceholderAggregation) { + const innerPlaceholdersResolved = this.removeNesting(placeholderAggregator.validNestedForPlaceholders); + + return removeBracketsAndFlattenToNested(innerPlaceholdersResolved); + } + + private removeNesting(nestedForPlaceholders: Record>): Record { + const result: Record = {}; + + Object.keys(nestedForPlaceholders).forEach((key) => { + const nestedItemsResolved = removeBracketsAndFlattenToNested(nestedForPlaceholders[key]); + const nestedItemsResolvedWithoutPrefix = nestedItemsResolved.item as Record; + const item1 = this.addSuffixToValues(nestedItemsResolvedWithoutPrefix, '1'); + const item2 = this.addSuffixToValues(nestedItemsResolvedWithoutPrefix, '2'); + result[key] = [item1, item2]; + }); + + return result; + } + private addSuffixToValues(optionalParams: Record, suffix: string): Record { + const result: Record = {}; + + Object.keys(optionalParams).forEach((key) => { + const value = optionalParams[key]; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + result[key] = this.addSuffixToValues(value as Record, suffix); + } else { + result[key] = `${value}-${suffix}`; + } + }); + + return result; + } +} +function removeBracketsAndFlattenToNested(input: Record): Record { + const result: Record = {}; + + Object.keys(input).forEach((key) => { + const cleanedKey = key.replace(/^\{\{|\}\}$/g, ''); + + const keys = cleanedKey.split('.'); + keys.reduce((acc, part, index) => { + if (index === keys.length - 1) { + acc[part] = input[key]; + } else { + acc[part] = acc[part] || {}; + } + + return acc[part]; + }, result); + }); + + return result; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/index.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/index.ts new file mode 100644 index 00000000000..8e995657861 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/build-payload-from-placeholder/index.ts @@ -0,0 +1,3 @@ +export * from './build-default-payload.response'; +export * from './build-default-payload.command'; +export * from './build-default-payload.usecase'; diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/collect-placeholder-with-defaults.command.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/collect-placeholder-with-defaults.command.ts new file mode 100644 index 00000000000..70c4cd6b144 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/collect-placeholder-with-defaults.command.ts @@ -0,0 +1,5 @@ +import { BaseCommand } from '@novu/application-generic'; + +export class CollectPlaceholderWithDefaultsCommand extends BaseCommand { + controlValues?: Record; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/collect-placeholder-with-defaults.usecase.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/collect-placeholder-with-defaults.usecase.ts new file mode 100644 index 00000000000..a1360fcc842 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/collect-placeholder-with-defaults.usecase.ts @@ -0,0 +1,119 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { PlaceholderAggregation } from './placeholder.aggregation'; +import { HydrateEmailSchemaUseCase } from '../../../../environments-v1/usecases/output-renderers'; +import { CollectPlaceholderWithDefaultsCommand } from './collect-placeholder-with-defaults.command'; +import { flattenJson } from '../../../util/jsonUtils'; + +@Injectable() +export class CollectPlaceholderWithDefaultsUsecase { + constructor(private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase) {} + execute(command: CollectPlaceholderWithDefaultsCommand): Record { + if (!command.controlValues) { + return {}; + } + const placeholders: Record = {}; + const flattenedControlValues = flattenJson(command.controlValues); + + for (const controlValueKey of Object.keys(flattenedControlValues)) { + const flattenedControlValue = flattenedControlValues[controlValueKey]; + placeholders[controlValueKey] = this.extractPlaceholdersLogic(flattenedControlValue); + } + + return placeholders; + } + private extractPlaceholdersLogic(controlValue: unknown): PlaceholderAggregation { + let placeholders: PlaceholderAggregation; + const parseEmailSchemaResult = this.safeAttemptToParseEmailSchema(controlValue); + if (parseEmailSchemaResult) { + placeholders = parseEmailSchemaResult; + } else { + placeholders = extractPlaceholders(controlValue); + } + + return placeholders; + } + private safeAttemptToParseEmailSchema(controlValue: unknown) { + if (typeof controlValue !== 'string') { + return undefined; + } + try { + const { placeholderAggregation } = this.hydrateEmailSchemaUseCase.execute({ + emailEditor: controlValue, + fullPayloadForRender: { + payload: {}, + subscriber: {}, + steps: {}, + }, + }); + + return placeholderAggregation; + } catch (e) { + return undefined; + } + } +} + +class PayloadDefaultsEngineFailureException extends InternalServerErrorException { + constructor(notText: object) { + super({ message: `placeholder engine expected string but got object`, ctx: notText }); + } +} + +function extractPlaceholders(potentialText: unknown): PlaceholderAggregation { + const placeholders = { + nestedForPlaceholders: {}, + regularPlaceholdersToDefaultValue: {}, + }; + if (!potentialText || typeof potentialText === 'number') { + return placeholders; + } + if (typeof potentialText === 'object') { + throw new PayloadDefaultsEngineFailureException(potentialText); + } + + if (typeof potentialText !== 'string') { + return placeholders; + } + + extractLiquidJSPlaceholders(potentialText).forEach((placeholderResult) => { + placeholders.regularPlaceholdersToDefaultValue[placeholderResult.placeholder] = placeholderResult.defaultValue; + }); + + return placeholders; +} +function extractLiquidJSPlaceholders(text: string) { + const regex = /\{\{([^}]*?)\}\}/g; + const matches: { + placeholder: string; + defaultValue?: string; + }[] = []; + let match: RegExpExecArray | null; + + // eslint-disable-next-line no-cond-assign + while ((match = regex.exec(text)) !== null) { + const fullMatch = match[0]; + const innerContent = match[1].trim(); + const defaultMatch = innerContent.match(/default:\s*["']?([^"']+)["']?/); + const defaultValue = defaultMatch ? defaultMatch[1] : fullMatch; + + const sanitizedContent = innerContent + .replace(/(\s*\|\s*default:\s*["']?[^"']+["']?)/, '') + .replace(/\s*\|\s*[^ ]+/g, ''); + + const trimmedContent = sanitizedContent.trim(); + + if (trimmedContent === '') { + matches.push({ + placeholder: fullMatch, + defaultValue, + }); + } else { + matches.push({ + placeholder: `{{${trimmedContent}}}`, + defaultValue, + }); + } + } + + return matches; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/index.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/index.ts new file mode 100644 index 00000000000..49b16d86792 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/index.ts @@ -0,0 +1,3 @@ +export * from './collect-placeholder-with-defaults.usecase'; +export * from './placeholder.aggregation'; +export * from './collect-placeholder-with-defaults.command'; diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/placeholder.aggregation.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/placeholder.aggregation.ts new file mode 100644 index 00000000000..9f3dfe12203 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/collect-placeholders/placeholder.aggregation.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface PlaceholderAggregation { + nestedForPlaceholders: Record>; + regularPlaceholdersToDefaultValue: Record; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/index.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/index.ts new file mode 100644 index 00000000000..7395db1c9ee --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/index.ts @@ -0,0 +1,4 @@ +export * from './build-payload-from-placeholder'; +export * from './collect-placeholders'; +export * from './prepare-and-validate-content'; +export * from './validate-placeholders'; diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/index.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/index.ts new file mode 100644 index 00000000000..d85d6cca739 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/index.ts @@ -0,0 +1,3 @@ +export * from './prepare-and-validate-content.command'; +export * from './prepare-and-validate-content.usecase'; +export * from './validated-content.response'; diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.command.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.command.ts new file mode 100644 index 00000000000..a5aad86fad9 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.command.ts @@ -0,0 +1,9 @@ +import { JSONSchemaDto, PreviewPayload } from '@novu/shared'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface PrepareAndValidateContentCommand { + controlValues: Record; + controlDataSchema: JSONSchemaDto; + variableSchema: JSONSchemaDto; + previewPayloadFromDto?: PreviewPayload; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts new file mode 100644 index 00000000000..85ab235cb3f --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts @@ -0,0 +1,208 @@ +import { Injectable } from '@nestjs/common'; +import { ContentIssue, JSONSchemaDto, PreviewPayload, StepContentIssueEnum } from '@novu/shared'; +import { merge } from 'lodash'; +import { PrepareAndValidateContentCommand } from './prepare-and-validate-content.command'; +import { mergeObjects } from '../../../util/jsonUtils'; +import { findMissingKeys } from '../../../util/utils'; +import { BuildDefaultPayloadUsecase } from '../build-payload-from-placeholder'; +import { ValidatedPlaceholderAggregation, ValidatePlaceholderUsecase } from '../validate-placeholders'; +import { CollectPlaceholderWithDefaultsUsecase, PlaceholderAggregation } from '../collect-placeholders'; +import { ExtractDefaultValuesFromSchemaUsecase } from '../../extract-default-values-from-schema'; +import { ValidatedContentResponse } from './validated-content.response'; + +@Injectable() +export class PrepareAndValidateContentUsecase { + constructor( + private constructPayloadUseCase: BuildDefaultPayloadUsecase, + private validatePlaceholdersUseCase: ValidatePlaceholderUsecase, + private collectPlaceholderWithDefaultsUsecase: CollectPlaceholderWithDefaultsUsecase, + private extractDefaultsFromSchemaUseCase: ExtractDefaultValuesFromSchemaUsecase + ) {} + + async execute(command: PrepareAndValidateContentCommand): Promise { + const controlValueToPlaceholders = this.collectPlaceholders(command.controlValues); + const controlValueToValidPlaceholders = this.validatePlaceholders( + controlValueToPlaceholders, + command.variableSchema + ); + const finalPayload = this.buildAndMergePayload(controlValueToValidPlaceholders, command.previewPayloadFromDto); + const { defaultControlValues, finalControlValues } = this.mergeAndSanitizeControlValues( + command.controlDataSchema, + command.controlValues, + controlValueToValidPlaceholders + ); + + const issues = this.buildIssues( + finalPayload, + command.previewPayloadFromDto || finalPayload, // if no payload provided no point creating issues. + defaultControlValues, + command.controlValues, + controlValueToValidPlaceholders + ); + + return { + finalPayload, + finalControlValues, + issues, + }; + } + + private collectPlaceholders(controlValues: Record) { + return this.collectPlaceholderWithDefaultsUsecase.execute({ + controlValues, + }); + } + + private validatePlaceholders( + controlValueToPlaceholders: Record, + variableSchema: JSONSchemaDto // Now using JsonStepSchemaDto + ) { + return this.validatePlaceholdersUseCase.execute({ + controlValueToPlaceholders, + variableSchema, + }); + } + + private buildAndMergePayload( + controlValueToValidPlaceholders: Record, + previewPayloadFromDto?: PreviewPayload + ) { + const { previewPayload } = this.constructPayloadUseCase.execute({ + placeholderAggregators: Object.values(controlValueToValidPlaceholders), + }); + + return previewPayloadFromDto ? merge(previewPayload, previewPayloadFromDto) : previewPayload; + } + + private mergeAndSanitizeControlValues( + jsonSchema: JSONSchemaDto, // Now using JsonSchemaDto + controlValues: Record, + controlValueToValidPlaceholders: Record + ) { + const defaultControlValues = this.extractDefaultsFromSchemaUseCase.execute({ + jsonSchemaDto: jsonSchema, + }); + const mergedControlValues = merge(defaultControlValues, controlValues); + Object.keys(mergedControlValues).forEach((controlValueKey) => { + const controlValue = mergedControlValues[controlValueKey]; + + if (typeof controlValue !== 'string') { + return; + } + + const placeholders = controlValueToValidPlaceholders[controlValueKey]; + if (!placeholders) { + return; + } + + let cleanedControlValue = controlValue; // Initialize cleanedControlValue with the original controlValue + + for (const problematicPlaceholder of Object.keys(placeholders.problematicPlaceholders)) { + cleanedControlValue = this.removePlaceholdersFromText(problematicPlaceholder, cleanedControlValue); + } + + mergedControlValues[controlValueKey] = cleanedControlValue; // Update mergedControlValues with cleanedControlValue + }); + + return { defaultControlValues, finalControlValues: mergedControlValues }; + } + + private removePlaceholdersFromText(text: string, targetText: string) { + const regex = /\{\{\s*([^}]*?)\s*\}\}/g; + let match: RegExpExecArray | null; + + // eslint-disable-next-line no-cond-assign + while ((match = regex.exec(text)) !== null) { + const placeholderContent = match[1].trim(); + const placeholderRegex = new RegExp(`\\s*\\{\\{\\s*${placeholderContent}\\s*\\}\\}\\s*`, 'g'); + // eslint-disable-next-line no-param-reassign + targetText = targetText.replace(placeholderRegex, ''); + } + + return targetText.trim(); + } + + private buildIssues( + payload: PreviewPayload, + providedPayload: PreviewPayload, + defaultControlValues: Record, + userProvidedValues: Record, + valueToPlaceholders: Record + ): Record { + let finalIssues: Record = {}; + finalIssues = mergeObjects(finalIssues, this.computeIllegalVariablesIssues(valueToPlaceholders)); + finalIssues = mergeObjects(finalIssues, this.getMissingInPayload(providedPayload, valueToPlaceholders, payload)); + finalIssues = mergeObjects(finalIssues, this.computeMissingControlValue(defaultControlValues, userProvidedValues)); + + return finalIssues; + } + + private computeIllegalVariablesIssues( + controlValueToValidPlaceholders: Record + ) { + const result: Record = {}; + + for (const [controlValue, placeholderAggregation] of Object.entries(controlValueToValidPlaceholders)) { + const illegalVariables = placeholderAggregation.problematicPlaceholders; + for (const [placeholder, errorMsg] of Object.entries(illegalVariables)) { + if (!result[controlValue]) { + result[controlValue] = []; + } + result[controlValue].push({ + issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE, + variableName: placeholder, + message: errorMsg, + }); + } + } + + return result; + } + + private getMissingInPayload( + userProvidedPayload: PreviewPayload, + controlValueToValidPlaceholders: Record, + defaultPayload: PreviewPayload + ) { + const missingPayloadKeys = findMissingKeys(defaultPayload, userProvidedPayload); + const result: Record = {}; + + for (const item of missingPayloadKeys) { + const controlValueKeys = Object.keys(controlValueToValidPlaceholders); + for (const controlValueKey of controlValueKeys) { + const placeholder = controlValueToValidPlaceholders[controlValueKey].validRegularPlaceholdersToDefaultValue; + if (placeholder[`{{${item}}}`]) { + if (!result[controlValueKey]) { + result[controlValueKey] = []; + } + result[controlValueKey].push({ + issueType: StepContentIssueEnum.MISSING_VARIABLE_IN_PAYLOAD, + variableName: item, + message: `[${item}] Missing Reference in payload`, + }); + } + } + } + + return result; + } + + private computeMissingControlValue( + defaultControlValues: Record, + userProvidedValues: Record + ) { + const missingControlKeys = findMissingKeys(defaultControlValues, userProvidedValues); + const result: Record = {}; + + for (const item of missingControlKeys) { + result[item] = [ + { + issueType: StepContentIssueEnum.MISSING_VALUE, + message: `[${item}] No Value was submitted to a required control.`, + }, + ]; + } + + return result; + } +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/validated-content.response.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/validated-content.response.ts new file mode 100644 index 00000000000..59d23902d43 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/validated-content.response.ts @@ -0,0 +1,9 @@ +// Define the ValidatedContent interface +import { ContentIssue, PreviewPayload } from '@novu/shared'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ValidatedContentResponse { + finalPayload: PreviewPayload; + finalControlValues: Record; + issues: Record; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/index.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/index.ts new file mode 100644 index 00000000000..1018dfd3039 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/index.ts @@ -0,0 +1,3 @@ +export * from './validate-placeholder.usecase'; +export * from './validate-placeholder.command'; +export * from './validated-placeholder-aggregation'; diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.command.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.command.ts new file mode 100644 index 00000000000..3702c5eb0ac --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.command.ts @@ -0,0 +1,8 @@ +import { JSONSchemaDto } from '@novu/shared'; +import { PlaceholderAggregation } from '../collect-placeholders/placeholder.aggregation'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ValidatePlaceholderCommand { + controlValueToPlaceholders: Record; + variableSchema: JSONSchemaDto; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.usecase.ts new file mode 100644 index 00000000000..5c89afaea83 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.usecase.ts @@ -0,0 +1,310 @@ +import { JSONSchemaDefinition, JSONSchemaDto, JSONSchemaTypeName } from '@novu/shared'; +import { Injectable } from '@nestjs/common'; +import { ValidatePlaceholderCommand } from './validate-placeholder.command'; +import { ValidatedPlaceholderAggregation } from './validated-placeholder-aggregation'; +import { PlaceholderAggregation } from '../collect-placeholders'; + +@Injectable() +export class ValidatePlaceholderUsecase { + execute(command: ValidatePlaceholderCommand): Record { + const validatedPlaceholders: Record = {}; + const variablesFromSchema = extractPropertiesFromJsonSchema(command.variableSchema); + for (const controlValueKey of Object.keys(command.controlValueToPlaceholders)) { + const controlValue = command.controlValueToPlaceholders[controlValueKey]; + validatedPlaceholders[controlValueKey] = this.validatePlaceholders(controlValue, variablesFromSchema); + } + + return validatedPlaceholders; + } + + validatePlaceholders( + placeholderAggregation: PlaceholderAggregation, + variablesFromSchema: ExtractedPropertiesResult + ): ValidatedPlaceholderAggregation { + const { problematicPlaceholders, validPlaceholders } = this.validateRegularPlaceholders( + placeholderAggregation.regularPlaceholdersToDefaultValue, + variablesFromSchema + ); + const { problematicNestedPlaceholders, validNestedPlaceholders } = this.validateNestedPlaceholders( + placeholderAggregation.nestedForPlaceholders, + variablesFromSchema + ); + + return { + problematicPlaceholders: { ...problematicPlaceholders, ...problematicNestedPlaceholders }, + validNestedForPlaceholders: validNestedPlaceholders, + validRegularPlaceholdersToDefaultValue: validPlaceholders, + }; + } + + private validateRegularPlaceholders( + regularPlaceholdersToDefaultValue: Record, + variablesFromSchema: ExtractedPropertiesResult + ) { + const problematicPlaceholders: Record = {}; + const validPlaceholders: Record = {}; + for (const placeholder of Object.keys(regularPlaceholdersToDefaultValue)) { + const { errorMsg } = this.validateSingleRegularPlaceholder(variablesFromSchema, placeholder); + if (errorMsg === undefined) { + validPlaceholders[placeholder] = regularPlaceholdersToDefaultValue[placeholder]; + } else { + problematicPlaceholders[placeholder] = errorMsg; + } + } + + return { problematicPlaceholders, validPlaceholders }; + } + private validateNestedPlaceholders( + iterativePlaceholderToNestedPlaceholders: Record>, + variablesFromSchema: ExtractedPropertiesResult + ) { + const problematicNestedPlaceholders: Record = {}; + const validNestedPlaceholders: Record> = {}; + + for (const [arrayPlaceholder, nestedPlaceholderArray] of Object.entries(iterativePlaceholderToNestedPlaceholders)) { + const isPredefinedArray = Object.keys(variablesFromSchema.arrayProperties).includes(arrayPlaceholder); + + if (!isPredefinedArray && !this.searchPlaceholderObjectPrefix(variablesFromSchema, arrayPlaceholder)) { + problematicNestedPlaceholders[arrayPlaceholder] = + `Error: Placeholder "${arrayPlaceholder}" is not defined in the schema.`; + continue; + } + + validNestedPlaceholders[arrayPlaceholder] = {}; + + for (const nestedPlaceholder of Object.keys(nestedPlaceholderArray)) { + const { errorMsg } = this.validateNested(variablesFromSchema, arrayPlaceholder, nestedPlaceholder); + + if (errorMsg === undefined) { + validNestedPlaceholders[arrayPlaceholder][nestedPlaceholder] = + iterativePlaceholderToNestedPlaceholders[arrayPlaceholder][nestedPlaceholder]; + } else { + problematicNestedPlaceholders[nestedPlaceholder] = errorMsg; + } + } + } + + return { problematicNestedPlaceholders, validNestedPlaceholders }; + } + + private validateSingleRegularPlaceholder( + variablesFromSchema: ExtractedPropertiesResult, + placeholder: string + ): { errorMsg?: string } { + const errorMsg = validateLiquidPlaceholder(placeholder); + if (errorMsg) { + return { errorMsg }; + } + const isPredefinedVariable = variablesFromSchema.primitiveProperties.includes(this.cleanString(placeholder)); + if (isPredefinedVariable) { + return {}; + } + const prefixFromVariableSchema = this.searchPlaceholderObjectPrefix(variablesFromSchema, placeholder); + if (prefixFromVariableSchema) { + return {}; + } + + return { errorMsg: `Error: Placeholder "${placeholder}" is not defined in the schema.` }; + } + + private searchPlaceholderObjectPrefix(variablesFromSchema: ExtractedPropertiesResult, placeholder: string) { + return variablesFromSchema.objectProperties.some((prefix) => { + return this.cleanString(placeholder).startsWith(prefix); + }); + } + private cleanString(input: string): string { + return input.replace(/^{{\s*(.*?)\s*}}$/, '$1'); + } + private validateNested( + variablesFromSchema: ExtractedPropertiesResult, + arrayPlaceholder: string, + nestedPlaceholder: string + ) { + const cleanNestedPlaceholder = this.cleanString(nestedPlaceholder); + if (!cleanNestedPlaceholder.startsWith('item.')) { + return { errorMsg: `Error: Nested placeholder "${nestedPlaceholder}" must start with "item.".` }; + } + const placeholderWithoutTheItemPrefix = cleanNestedPlaceholder.slice(5); + const res = variablesFromSchema.arrayProperties[arrayPlaceholder]; + if (!res) { + return {}; + } + if (res.primitiveProperties.includes(placeholderWithoutTheItemPrefix)) { + return {}; + } + const prefixFromVariableSchema = res.objectProperties.some((prefix) => + placeholderWithoutTheItemPrefix.startsWith(prefix) + ); + if (prefixFromVariableSchema) { + return {}; + } + + return { errorMsg: `Error: Placeholder "${nestedPlaceholder}" is not defined in the schema.` }; + } +} +const VALID_VARIABLE_REGEX: RegExp = /^[a-zA-Z_][\w.]*$/; +const NESTED_PLACEHOLDER_REGEX: RegExp = /\{\{.*\{\{.*\}\}.*\}\}/; +const FILTER_REGEX: RegExp = /\|/; +const RESERVED_WORDS: string[] = [ + 'for', + 'if', + 'unless', + 'case', + 'when', + 'endif', + 'endfor', + 'endcase', + 'capture', + 'assign', + 'include', + 'layout', + 'block', + 'comment', + 'raw', +]; + +function validateLiquidPlaceholder(placeholder: string): string | undefined { + if (!placeholder.startsWith('{{') || !placeholder.endsWith('}}')) { + return ERROR_MESSAGES.invalidFormat; + } + + const content: string = placeholder.slice(2, -2).trim(); + + if (content === '') { + return ERROR_MESSAGES.empty; + } + + if (NESTED_PLACEHOLDER_REGEX.test(content)) { + return ERROR_MESSAGES.nestedPlaceholders; + } + + if (!VALID_VARIABLE_REGEX.test(content)) { + return ERROR_MESSAGES.invalidCharacters; + } + + if (content.startsWith('.') || content.endsWith('.')) { + return ERROR_MESSAGES.invalidDotUsage; + } + + if (content.includes('..')) { + return ERROR_MESSAGES.consecutiveDots; + } + + if (FILTER_REGEX.test(content)) { + const parts: string[] = content.split('|').map((part) => part.trim()); + for (const part of parts) { + if (!VALID_VARIABLE_REGEX.test(part) && part !== '') { + return ERROR_MESSAGES.invalidFilter(part); + } + } + if (content.includes('||')) { + return ERROR_MESSAGES.incorrectPipeUsage; + } + if (content.endsWith('|')) { + return ERROR_MESSAGES.incompleteFilter; + } + } + + if ((content.match(/\{\{/g) || []).length !== (content.match(/\}\}/g) || []).length) { + return ERROR_MESSAGES.unbalancedBraces; + } + + if (RESERVED_WORDS.includes(content)) { + return ERROR_MESSAGES.reservedWord(content); + } + + return undefined; +} +// eslint-disable-next-line @typescript-eslint/naming-convention +interface ExtractedPropertiesResult { + objectProperties: string[]; + primitiveProperties: string[]; + arrayProperties: Record; +} +function extractPropertiesFromJsonSchema(jsonSchema: JSONSchemaDto): ExtractedPropertiesResult { + const objectProperties: string[] = []; + const primitiveProperties: string[] = []; + const arrayProperties: Record = {}; + + const isPrimitiveType = (type?: JSONSchemaTypeName | JSONSchemaTypeName[]): boolean => { + return ['string', 'number', 'integer', 'boolean', 'null'].includes(type as string); + }; + + const traverseProperties = (properties?: { [key: string]: JSONSchemaDefinition }, parentKey = ''): void => { + if (!properties) return; + + for (const [key, value] of Object.entries(properties)) { + const currentKey = parentKey ? `${parentKey}.${key}` : key; + + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value.type) && value.type.includes('object')) { + objectProperties.push(currentKey); + } else if (value.type === 'object') { + objectProperties.push(currentKey); + } + + if (value.properties) { + traverseProperties(value.properties, currentKey); + } + } + + if (value && typeof value !== 'boolean' && isPrimitiveType(value.type)) { + primitiveProperties.push(currentKey); + } + + if (value && typeof value !== 'boolean' && value.type === 'array') { + arrayProperties[currentKey] = { + objectProperties: [], + primitiveProperties: [], + arrayProperties: {}, + }; + + if (value.items) { + if (Array.isArray(value.items)) { + // Handle when items is an array of schemas + value.items.forEach((item) => { + if (typeof item === 'object' && item !== null && item.type === 'object' && item.properties) { + traverseProperties(item.properties, key); + } + }); + } else if (typeof value.items === 'object') { + // Handle when items is a single schema + if ( + value.items.type === 'object' || + (Array.isArray(value.items.type) && value.items.type.includes('object')) + ) { + if (value.items.properties) { + traverseProperties(value.items.properties, key); + } + } + } + } + } + } + }; + + if (jsonSchema.properties) { + traverseProperties(jsonSchema.properties); + } + + return { + objectProperties, + primitiveProperties, + arrayProperties, + }; +} +const ERROR_MESSAGES = { + invalidFormat: 'Error: Placeholder must start with {{ and end with }}.', + empty: 'Error: Placeholder cannot be empty. Please provide a valid variable name.', + nestedPlaceholders: 'Error: Nested placeholders are not allowed. Please remove any nested {{ }}.', + invalidCharacters: + 'Error: Placeholder contains invalid characters. A valid placeholder can only include letters, numbers, underscores, and dots.', + invalidDotUsage: 'Error: Placeholder cannot start or end with a dot. Please adjust the variable name.', + consecutiveDots: 'Error: Placeholder cannot contain consecutive dots. Please correct the variable name.', + invalidFilter: (part: string) => + `Error: Invalid filter or variable name "${part}". Filters must be valid variable names.`, + incorrectPipeUsage: 'Error: Incorrect pipe usage. Please ensure filters are applied correctly.', + incompleteFilter: 'Error: Incomplete filter syntax. Please provide a valid filter name.', + unbalancedBraces: 'Error: Unbalanced curly braces found. Please ensure all curly braces are properly closed.', + reservedWord: (word: string) => `Error: "${word}" is a reserved word and cannot be used as a variable name.`, +}; diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validated-placeholder-aggregation.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validated-placeholder-aggregation.ts new file mode 100644 index 00000000000..3a3641c4afd --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validated-placeholder-aggregation.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ValidatedPlaceholderAggregation { + problematicPlaceholders: Record; // key is the placeholder, value is the error message + validNestedForPlaceholders: Record>; + validRegularPlaceholdersToDefaultValue: Record; +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts b/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts deleted file mode 100644 index 8c89f198ca9..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values-usecase.service.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ContentIssue, JSONSchemaDto, StepContentIssueEnum } from '@novu/shared'; -import _ = require('lodash'); -import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase'; -import { BuildDefaultControlValuesCommand } from './build-default-control-values.command'; -import { findMissingKeys } from '../../util/utils'; -import { capitalize } from '../../../shared/services/helper/helper.service'; - -@Injectable() -export class ValidateControlValuesAndConstructPassableStructureUsecase { - constructor(private extractDefaultsUseCase: ExtractDefaultsUsecase) {} - - execute(command: BuildDefaultControlValuesCommand): { - augmentedControlValues: Record; - issuesMissingValues: Record; - } { - const defaultValues = this.extractDefaultsUseCase.execute({ - jsonSchemaDto: command.controlSchema.schema as JSONSchemaDto, - }); - - return { - augmentedControlValues: _.merge(defaultValues, command.controlValues), - issuesMissingValues: this.buildMissingControlValuesIssuesList(defaultValues, command.controlValues), - }; - } - - private buildMissingControlValuesIssuesList(defaultValues: Record, controlValues: Record) { - const missingRequiredControlValues = findMissingKeys(defaultValues, controlValues); - - return this.buildContentIssues(missingRequiredControlValues); - } - - private buildContentIssues(keys: string[]): Record { - const record: Record = {}; - - keys.forEach((key) => { - record[key] = [ - { - issueType: StepContentIssueEnum.MISSING_VALUE, - message: `${capitalize(key)} is missing`, // Custom message for the issue - }, - ]; - }); - - return record; - } -} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts b/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts deleted file mode 100644 index 06da962c65f..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/validate-control-values/build-default-control-values.command.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ControlsSchema } from '@novu/shared'; - -export class BuildDefaultControlValuesCommand { - controlSchema: ControlsSchema; - controlValues: Record; -} diff --git a/apps/api/src/app/workflows-v2/util/jsonUtils.ts b/apps/api/src/app/workflows-v2/util/jsonUtils.ts new file mode 100644 index 00000000000..4cbf6cbbad2 --- /dev/null +++ b/apps/api/src/app/workflows-v2/util/jsonUtils.ts @@ -0,0 +1,47 @@ +import _ from 'lodash'; + +export function flattenJson(obj?: Object, parentKey = '', result = {}) { + if (!obj || typeof obj !== 'object' || _.isArray(obj)) { + return result; // Return the result as is if obj is not a valid object + } + + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const newKey = parentKey ? `${parentKey}.${key}` : key; + + if (typeof obj[key] === 'object' && obj[key] !== null && !_.isArray(obj[key])) { + flattenJson(obj[key], newKey, result); + } else if (_.isArray(obj[key])) { + obj[key].forEach((item, index) => { + const arrayKey = `${newKey}[${index}]`; + if (typeof item === 'object' && item !== null) { + flattenJson(item, arrayKey, result); + } else { + // eslint-disable-next-line no-param-reassign + result[arrayKey] = item; + } + }); + } else { + // eslint-disable-next-line no-param-reassign + result[newKey] = obj[key]; + } + } + } + + return result; +} +// Merging the JSON objects, arrays are concatenated +export function mergeObjects(json1: Record, json2?: Record) { + if (!json2) { + return json1; + } + if (!json1) { + return json2; + } + + return _.mergeWith(json1, json2, (objValue, srcValue) => { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); // Accumulate arrays + } + }); +} diff --git a/apps/api/src/app/workflows-v2/util/utils.ts b/apps/api/src/app/workflows-v2/util/utils.ts index 97536bafabe..07e97ed870c 100644 --- a/apps/api/src/app/workflows-v2/util/utils.ts +++ b/apps/api/src/app/workflows-v2/util/utils.ts @@ -1,6 +1,6 @@ import _ = require('lodash'); -export function findMissingKeys(requiredRecord: Record, actualRecord: Record) { +export function findMissingKeys(requiredRecord: object, actualRecord: object) { const requiredKeys = collectKeys(requiredRecord); const actualKeys = collectKeys(actualRecord); 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 956b4846dd5..0733a408846 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -45,6 +45,7 @@ let session: UserSession; const LONG_DESCRIPTION = `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`; + describe('Workflow Controller E2E API Testing', () => { let workflowsClient: ReturnType; @@ -387,7 +388,6 @@ describe('Workflow Controller E2E API Testing', () => { // Verify updated properties expect(prodWorkflowUpdated.name).to.equal('Updated Name'); expect(prodWorkflowUpdated.description).to.equal('Updated Description'); - console.log('prodWorkflowUpdated\n', JSON.stringify(prodWorkflowUpdated, null, 2)); // Verify unchanged properties ['status', 'type', 'origin'].forEach((prop) => { expect(prodWorkflowUpdated[prop]).to.deep.equal(prodWorkflowCreated[prop], `Property ${prop} should match`); @@ -498,12 +498,28 @@ describe('Workflow Controller E2E API Testing', () => { }); }); describe('Variables', () => { - it('should get step available variables', async () => { + it('should persist payload schema', async () => { + const steps = [ + { + ...buildInAppStep(), + controlValues: { subject: 'Welcome to our newsletter {{payload.legalVariable}},{{IllegalVariable}}' }, + }, + ]; + const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('', { steps }); + const res = await workflowsClient.createWorkflow(createWorkflowDto); + if (!res.isSuccessResult()) { + throw new Error(res.error!.responseText); + } + const workflowResponse = res.value; + await validatePayloadSchemaInStepDataVariables(workflowResponse); + await validatePayloadSchemaOnTestData(workflowResponse); + }); + it('should get step available variables', async () => { const steps = [ { ...buildEmailStep(), controlValues: { - body: 'Welcome to our newsletter {{bodyText}}{{bodyText2}}{{payload.prefixBodyText}}', + body: 'Welcome to our newsletter {{subscriber.nonExistentValue}}{{payload.prefixBodyText2}}{{payload.prefixBodyText}}', subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}', }, }, @@ -524,8 +540,7 @@ describe('Workflow Controller E2E API Testing', () => { const payloadVariables = properties.payload; expect(payloadVariables).to.be.ok; if (!payloadVariables) throw new Error('Payload schema is not valid'); - expect(JSON.stringify(payloadVariables)).to.contain('prefixSubjectText'); - expect(JSON.stringify(payloadVariables)).to.contain('prefixBodyText'); + expect(JSON.stringify(payloadVariables)).to.contain('payload.prefixBodyText2'); expect(JSON.stringify(payloadVariables)).to.contain('{{payload.prefixSubjectText}}'); }); it('should serve previous step variables with payload schema', async () => { @@ -611,7 +626,21 @@ describe('Workflow Controller E2E API Testing', () => { expect(toSchema.additionalProperties).to.be.false; }); }); + async function validatePayloadSchemaInStepDataVariables(workflowResponse: WorkflowResponseDto) { + const stepData = await getStepData(workflowResponse._id, workflowResponse.steps[0]._id); + if (!stepData) throw new Error('Step data is not valid'); + if (!stepData.variables.properties) throw new Error('Payload schema is not valid'); + const payloadVariables = stepData.variables.properties.payload; + if (!payloadVariables) throw new Error('Payload schema is not valid'); + expect(JSON.stringify(payloadVariables), JSON.stringify(payloadVariables)).to.contain('legalVariable'); + } + async function validatePayloadSchemaOnTestData(workflowResponse: WorkflowResponseDto) { + const testData = await getWorkflowTestData(workflowResponse._id); + expect(testData.payload).to.be.ok; + expect(testData.payload.properties).to.be.ok; + expect(testData.payload.properties?.legalVariable).to.be.ok; + } async function updateWorkflowRest(id: string, workflow: UpdateWorkflowDto): Promise { const novuRestResult = await workflowsClient.updateWorkflow(id, workflow); if (novuRestResult.isSuccessResult()) { diff --git a/apps/api/src/app/workflows-v2/workflow.controller.ts b/apps/api/src/app/workflows-v2/workflow.controller.ts index 7a5d747bd39..b0a7d204b54 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.ts @@ -22,11 +22,11 @@ import { IdentifierOrInternalId, ListWorkflowResponse, StepDataDto, + SyncWorkflowDto, UpdateWorkflowDto, UserSessionData, WorkflowResponseDto, WorkflowTestDataResponseDto, - SyncWorkflowDto, } from '@novu/shared'; import { DeleteWorkflowCommand, DeleteWorkflowUseCase, UserAuthGuard, UserSession } from '@novu/application-generic'; import { ApiCommonResponses } from '../shared/framework/response.decorator'; @@ -40,13 +40,15 @@ import { ListWorkflowsCommand } from './usecases/list-workflows/list-workflows.c import { SyncToEnvironmentUseCase } from './usecases/sync-to-environment/sync-to-environment.usecase'; import { SyncToEnvironmentCommand } from './usecases/sync-to-environment/sync-to-environment.command'; import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; -import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview-command'; import { ParseSlugIdPipe } from './pipes/parse-slug-id.pipe'; import { ParseSlugEnvironmentIdPipe } from './pipes/parse-slug-env-id.pipe'; -import { WorkflowTestDataUseCase } from './usecases/test-data/test-data.usecase'; -import { WorkflowTestDataCommand } from './usecases/test-data/test-data.command'; -import { GetStepDataCommand } from './usecases/get-step-schema/get-step-data.command'; -import { GetStepDataUsecase } from './usecases/get-step-schema/get-step-data.usecase'; +import { + BuildStepDataCommand, + BuildStepDataUsecase, + BuildWorkflowTestDataUseCase, + WorkflowTestDataCommand, +} from './usecases'; +import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview.command'; @ApiCommonResponses() @Controller({ path: `/workflows`, version: '2' }) @@ -61,8 +63,8 @@ export class WorkflowController { private deleteWorkflowUsecase: DeleteWorkflowUseCase, private syncToEnvironmentUseCase: SyncToEnvironmentUseCase, private generatePreviewUseCase: GeneratePreviewUsecase, - private workflowTestDataUseCase: WorkflowTestDataUseCase, - private getStepData: GetStepDataUsecase + private buildWorkflowTestDataUseCase: BuildWorkflowTestDataUseCase, + private buildStepDataUsecase: BuildStepDataUsecase ) {} @Post('') @@ -183,8 +185,8 @@ export class WorkflowController { @Param('workflowId', ParseSlugIdPipe) workflowId: IdentifierOrInternalId, @Param('stepId', ParseSlugIdPipe) stepId: IdentifierOrInternalId ): Promise { - return await this.getStepData.execute( - GetStepDataCommand.create({ user, identifierOrInternalId: workflowId, stepId }) + return await this.buildStepDataUsecase.execute( + BuildStepDataCommand.create({ user, identifierOrInternalId: workflowId, stepId }) ); } @@ -194,7 +196,7 @@ export class WorkflowController { @UserSession() user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowId: IdentifierOrInternalId ): Promise { - return this.workflowTestDataUseCase.execute( + return this.buildWorkflowTestDataUseCase.execute( WorkflowTestDataCommand.create({ identifierOrInternalId: workflowId, user }) ); } diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index 09b89eeb475..2e55484e212 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -1,12 +1,12 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { CreateWorkflow, + DeleteWorkflowUseCase, GetPreferences, + GetWorkflowByIdsUseCase, UpdateWorkflow, UpsertControlValuesUseCase, UpsertPreferences, - DeleteWorkflowUseCase, - GetWorkflowByIdsUseCase, } from '@novu/application-generic'; import { SharedModule } from '../shared/shared.module'; @@ -15,22 +15,24 @@ import { ChangeModule } from '../change/change.module'; import { AuthModule } from '../auth/auth.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { WorkflowController } from './workflow.controller'; -import { UpsertWorkflowUseCase } from './usecases/upsert-workflow/upsert-workflow.usecase'; -import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.usecase'; -import { SyncToEnvironmentUseCase } from './usecases/sync-to-environment/sync-to-environment.usecase'; +import { + BuildAvailableVariableSchemaUsecase, + BuildDefaultPayloadUsecase, + BuildStepDataUsecase, + BuildWorkflowTestDataUseCase, + CollectPlaceholderWithDefaultsUsecase, + ExtractDefaultValuesFromSchemaUsecase, + GeneratePreviewUsecase, + GetWorkflowUseCase, + ListWorkflowsUseCase, + PrepareAndValidateContentUsecase, + ProcessWorkflowIssuesUsecase, + SyncToEnvironmentUseCase, + UpsertWorkflowUseCase, + ValidatePlaceholderUsecase, +} from './usecases'; import { BridgeModule } from '../bridge'; -import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; -import { CreateMockPayloadForSingleControlValueUseCase } from './usecases/placeholder-enrichment/payload-preview-value-generator.usecase'; -import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schema/extract-defaults.usecase'; import { HydrateEmailSchemaUseCase } from '../environments-v1/usecases/output-renderers'; -import { WorkflowTestDataUseCase } from './usecases/test-data/test-data.usecase'; -import { GetStepDataUsecase } from './usecases/get-step-schema/get-step-data.usecase'; -import { ProcessWorkflowIssuesUsecase } from './usecases/process-workflow-issues/process-workflow-issues.usecase'; -import { BuildPayloadNestedStructureUsecase } from './usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase'; -import { GetWorkflowUseCase } from './usecases/get-workflow/get-workflow.usecase'; -import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeholder'; -import { ValidateControlValuesAndConstructPassableStructureUsecase } from './usecases/validate-control-values/build-default-control-values-usecase.service'; -import { BuildAvailableVariableSchemaUsecase } from './usecases/get-step-schema/build-available-variable-schema-usecase.service'; @Module({ imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule], @@ -46,18 +48,18 @@ import { BuildAvailableVariableSchemaUsecase } from './usecases/get-step-schema/ GetPreferences, GetWorkflowByIdsUseCase, SyncToEnvironmentUseCase, - GetStepDataUsecase, + BuildStepDataUsecase, GeneratePreviewUsecase, - CreateMockPayloadForSingleControlValueUseCase, - ExtractDefaultsUsecase, - BuildPayloadNestedStructureUsecase, - WorkflowTestDataUseCase, + BuildWorkflowTestDataUseCase, GetWorkflowUseCase, HydrateEmailSchemaUseCase, ProcessWorkflowIssuesUsecase, - BuildDefaultPayloadUseCase, - ValidateControlValuesAndConstructPassableStructureUsecase, + BuildDefaultPayloadUsecase, BuildAvailableVariableSchemaUsecase, + CollectPlaceholderWithDefaultsUsecase, + PrepareAndValidateContentUsecase, + ValidatePlaceholderUsecase, + ExtractDefaultValuesFromSchemaUsecase, ], }) export class WorkflowModule implements NestModule { diff --git a/packages/shared/src/dto/workflows/step-content-issue.enum.ts b/packages/shared/src/dto/workflows/step-content-issue.enum.ts index a5eb75d07c3..6cbcb48ceea 100644 --- a/packages/shared/src/dto/workflows/step-content-issue.enum.ts +++ b/packages/shared/src/dto/workflows/step-content-issue.enum.ts @@ -1,6 +1,6 @@ export enum StepContentIssueEnum { MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD', - VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH', + ILLEGAL_VARIABLE_IN_CONTROL_VALUE = 'ILLEGAL_VARIABLE_IN_CONTROL_VALUE', MISSING_VALUE = 'MISSING_VALUE', } export enum StepIssueEnum { diff --git a/packages/shared/src/dto/workflows/step-data.dto.ts b/packages/shared/src/dto/workflows/step-data.dto.ts index a5c1e31a138..e40c778af12 100644 --- a/packages/shared/src/dto/workflows/step-data.dto.ts +++ b/packages/shared/src/dto/workflows/step-data.dto.ts @@ -1,4 +1,5 @@ import type { JSONSchemaDto } from './json-schema-dto'; +import { StepTypeEnum, WorkflowOriginEnum } from '../../types'; export type StepDataDto = { controls: ControlsMetadata; @@ -6,6 +7,10 @@ export type StepDataDto = { stepId: string; _id: string; name: string; + type: StepTypeEnum; + origin: WorkflowOriginEnum; + workflowId: string; + workflowDatabaseId: string; }; export enum UiSchemaGroupEnum { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 393d0960a53..3f364fee001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37861,7 +37861,7 @@ snapshots: form-data: 3.0.1 node-fetch: 2.7.0(encoding@0.1.13) process: 0.11.10 - tough-cookie: 4.1.3 + tough-cookie: 4.1.4 tslib: 2.7.0 tunnel: 0.0.6 uuid: 8.3.2 @@ -77900,7 +77900,7 @@ snapshots: performance-now: 2.1.0 qs: 6.5.3 safe-buffer: 5.2.1 - tough-cookie: 4.1.3 + tough-cookie: 4.1.4 tunnel-agent: 0.6.0 uuid: 3.4.0