diff --git a/apps/api/src/app/environments-v1/novu-bridge.module.ts b/apps/api/src/app/environments-v1/novu-bridge.module.ts index 9a47fb66cb2..f6801182aa2 100644 --- a/apps/api/src/app/environments-v1/novu-bridge.module.ts +++ b/apps/api/src/app/environments-v1/novu-bridge.module.ts @@ -14,6 +14,7 @@ import { PushOutputRendererUsecase, SmsOutputRendererUsecase, } from './usecases/output-renderers'; +import { HydrateEmailSchemaUseCase } from './usecases/output-renderers/hydrate-email-schema.usecase'; @Module({ controllers: [NovuBridgeController], @@ -34,6 +35,7 @@ import { PushOutputRendererUsecase, EmailOutputRendererUsecase, ExpandEmailEditorSchemaUsecase, + HydrateEmailSchemaUseCase, ], }) export class NovuBridgeModule {} diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts index 26bfeace74d..89d4658ca05 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts @@ -21,6 +21,12 @@ import { SmsOutputRendererUsecase, } from '../output-renderers'; +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface MasterPayload { + subscriber: Record; + payload: Record; + steps: Record; // step.stepId.unkown +} @Injectable() export class ConstructFrameworkWorkflow { constructor( @@ -46,9 +52,14 @@ export class ConstructFrameworkWorkflow { private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow { return workflow( newWorkflow.triggers[0].identifier, - async ({ step }) => { + async ({ step, payload, subscriber }) => { + const masterPayload: MasterPayload = { payload, subscriber, steps: {} }; for await (const staticStep of newWorkflow.steps) { - await this.constructStep(step, staticStep); + masterPayload.steps[staticStep.stepId || staticStep._templateId] = await this.constructStep( + step, + staticStep, + masterPayload + ); } }, { @@ -66,7 +77,11 @@ export class ConstructFrameworkWorkflow { ); } - private constructStep(step: Step, staticStep: NotificationStepEntity): StepOutput> { + private constructStep( + step: Step, + staticStep: NotificationStepEntity, + masterPayload: MasterPayload + ): StepOutput> { const stepTemplate = staticStep.template; if (!stepTemplate) { @@ -100,7 +115,7 @@ export class ConstructFrameworkWorkflow { return step.email( stepId, async (controlValues) => { - return this.emailOutputRendererUseCase.execute({ controlValues }); + return this.emailOutputRendererUseCase.execute({ controlValues, masterPayload }); }, this.constructChannelStepOptions(staticStep) ); diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts index bef8f4008a8..e512403cadd 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts @@ -1,44 +1,49 @@ import { EmailRenderOutput, TipTapNode } from '@novu/shared'; -import { render } from '@maily-to/render'; import { z } from 'zod'; import { Injectable } from '@nestjs/common'; +import { render } from '@maily-to/render'; import { RenderCommand } from './render-command'; +import { MasterPayload } from '../construct-framework-workflow'; import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander.usecase'; +import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase'; + +export class EmailOutputRendererCommand extends RenderCommand { + masterPayload: MasterPayload; +} @Injectable() export class EmailOutputRendererUsecase { - constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {} + constructor( + private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase, + private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase // Inject the new use case + ) {} - async execute(renderCommand: RenderCommand): Promise { - const parse = EmailStepControlSchema.parse(renderCommand.controlValues); - const schema = parse.emailEditor as TipTapNode; - const expandedSchema = this.expendEmailEditorSchemaUseCase.execute({ schema }); - const html = await render(expandedSchema); + async execute(renderCommand: EmailOutputRendererCommand): Promise { + const { emailEditor, subject } = EmailStepControlSchema.parse(renderCommand.controlValues); + const emailSchemaHydrated = this.hydrate(emailEditor, renderCommand); + const expandedSchema = this.transformForAndShowLogic(emailSchemaHydrated); + const htmlRendered = await render(expandedSchema); - return { subject: parse.subject, body: html }; + return { subject, body: htmlRendered }; } -} -const emailContentSchema = z - .object({ - type: z.string(), - content: z.array(z.lazy(() => emailContentSchema)).optional(), - text: z.string().optional(), - attr: z.record(z.unknown()).optional(), - }) - .strict(); -const emailEditorSchema = z - .object({ - type: z.string(), - content: z.array(emailContentSchema).optional(), - text: z.string().optional(), - attr: z.record(z.unknown()).optional(), - }) - .strict(); + private transformForAndShowLogic(body: TipTapNode) { + return this.expendEmailEditorSchemaUseCase.execute({ schema: body }); + } + + private hydrate(emailEditor: string, renderCommand: EmailOutputRendererCommand) { + const { hydratedEmailSchema } = this.hydrateEmailSchemaUseCase.execute({ + emailEditor, + masterPayload: renderCommand.masterPayload, + }); + + return hydratedEmailSchema; + } +} export const EmailStepControlSchema = z .object({ - emailEditor: emailEditorSchema, + emailEditor: z.string(), subject: z.string(), }) .strict(); diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts index 7383deffce2..4304a250d4c 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts @@ -2,76 +2,146 @@ import { TipTapNode } from '@novu/shared'; import { ExpendEmailEditorSchemaCommand } from './expend-email-editor-schema-command'; -// Rename the class to ExpendEmailEditorSchemaUseCase export class ExpandEmailEditorSchemaUsecase { execute(command: ExpendEmailEditorSchemaCommand): TipTapNode { - return this.expendSchema(command.schema); - } - - private expendSchema(schema: TipTapNode): TipTapNode { - // todo: try to avoid ! - const content = schema.content!.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[]; + this.traverseAndAugment(command.schema, undefined); - return { ...schema, content }; + return command.schema; } - private processItemNode(node: TipTapNode, item: any): TipTapNode { - if (node.type === 'text' && typeof node.text === 'string') { - const regex = /{#item\.(\w+)#}/g; - node.text = node.text.replace(regex, (_, key: string) => { - const propertyName = key; - - return item[propertyName] !== undefined ? item[propertyName] : _; + private traverseAndAugment(node: TipTapNode, parentNode?: TipTapNode) { + if (node.content) { + node.content.forEach((innerNode) => { + this.traverseAndAugment(innerNode, node); }); } + if (this.hasShow(node)) { + this.hideShowIfNeeded(node, parentNode); + } else if (this.hasEach(node)) { + const newContent = this.expendedForEach(node); + node.content = newContent; + this.removeForNodeAndUpgradeContent(node, newContent, parentNode); + } + } - if (node.content) { - node.content = node.content.map((innerNode) => this.processItemNode(innerNode, item)); + private removeForNodeAndUpgradeContent(node: TipTapNode, expandedContents: TipTapNode[], parentNode?: TipTapNode) { + if (parentNode && parentNode.content) { + this.insertArrayAt(parentNode.content, parentNode.content.indexOf(node), expandedContents); + parentNode.content.splice(parentNode.content.indexOf(node), 1); + } + } + + private insertArrayAt(array: any[], index: number, newArray: any[]) { + if (index < 0 || index > array.length) { + throw new Error('Index out of bounds'); } + array.splice(index, 0, ...newArray); + } - return node; + private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { each: unknown } } { + return !!(node.attrs && 'each' in node.attrs); } - private processNodeRecursive(node: TipTapNode): TipTapNode | null { - if (node.type === 'show') { - const whenValue = node.attr?.when; - if (whenValue !== 'true') { - return null; - } + private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { show: string } } { + return !!(node.attrs && 'show' in node.attrs); + } + + private regularExpension(eachObject: any, templateContent: TipTapNode[]): TipTapNode[] { + const expandedContent: TipTapNode[] = []; + const jsonArrOfValues = eachObject as unknown as [{ [key: string]: string }]; + + for (const value of jsonArrOfValues) { + const hydratedContent = this.replacePlaceholders(templateContent, value); + expandedContent.push(...hydratedContent); } - if (this.hasEachAttr(node)) { - return { type: 'section', content: this.handleFor(node) }; + return expandedContent; + } + + private isOrderedList(templateContent: TipTapNode[]) { + return templateContent.length === 1 && templateContent[0].type === 'orderedList'; + } + + private isBulletList(templateContent: TipTapNode[]) { + return templateContent.length === 1 && templateContent[0].type === 'bulletList'; + } + + private expendedForEach(node: TipTapNode & { attrs: { each: unknown } }): TipTapNode[] { + const eachObject = node.attrs.each; + const templateContent = node.content || []; + + if (this.isOrderedList(templateContent) && templateContent[0].content) { + return [{ ...templateContent[0], content: this.regularExpension(eachObject, templateContent[0].content) }]; + } + if (this.isBulletList(templateContent) && templateContent[0].content) { + return [{ ...templateContent[0], content: this.regularExpension(eachObject, templateContent[0].content) }]; } - return this.processNodeContent(node); + return this.regularExpension(eachObject, templateContent); } - private processNodeContent(node: TipTapNode): TipTapNode | null { - if (node.content) { - node.content = node.content.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[]; + private removeNodeFromParent(node: TipTapNode, parentNode?: TipTapNode) { + if (parentNode && parentNode.content) { + parentNode.content.splice(parentNode.content.indexOf(node), 1); } + } - return node; + private hideShowIfNeeded(node: TipTapNode & { attrs: { show: unknown } }, parentNode?: TipTapNode): void { + const { show } = node.attrs; + const shouldShow = typeof show === 'boolean' ? show : this.stringToBoolean(show); + + if (!shouldShow) { + this.removeNodeFromParent(node, parentNode); + } else { + delete node.attrs.show; + } } - private hasEachAttr(node: TipTapNode): node is TipTapNode & { attr: { each: any } } { - return node.attr !== undefined && node.attr.each !== undefined; + private stringToBoolean(value: unknown): boolean { + if (typeof value === 'string') { + return value.toLowerCase() === 'true'; + } + + return false; } - private handleFor(node: TipTapNode & { attr: { each: any } }): TipTapNode[] { - const items = node.attr.each; - const newContent: TipTapNode[] = []; + private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { id: string } } { + return newNode.type === 'payloadValue' && newNode.attrs?.id !== undefined; + } - const itemsParsed = JSON.parse(items.replace(/'/g, '"')); - for (const item of itemsParsed) { - const newNode = { ...node }; - newNode.content = newNode.content?.map((innerNode) => this.processItemNode(innerNode, item)) || []; - if (newNode.content) { - newContent.push(...newNode.content); + private replacePlaceholders(nodes: TipTapNode[], payload: Record): TipTapNode[] { + return nodes.map((node) => { + const newNode: TipTapNode = { ...node }; + + if (this.isAVariableNode(newNode)) { + const valueByPath = this.getValueByPath(payload, newNode.attrs.id); + if (valueByPath) { + newNode.text = valueByPath; + newNode.type = 'text'; + // @ts-ignore + delete newNode.attrs; + } + } else if (newNode.content) { + newNode.content = this.replacePlaceholders(newNode.content, payload); } + + return newNode; + }); + } + + private getValueByPath(obj: Record, path: string): any { + if (path in obj) { + return obj[path]; } - return newContent; + const keys = path.split('.'); + + return keys.reduce((currentObj, key) => { + if (currentObj && typeof currentObj === 'object' && key in currentObj) { + return currentObj[key]; + } + + return undefined; + }, obj); } } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts index 5231f047132..35b9101573a 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts @@ -3,7 +3,6 @@ import { BaseCommand } from '@novu/application-generic'; import { TipTapNode } from '@novu/shared'; -// eslint-disable-next-line @typescript-eslint/naming-convention -export interface ExpendEmailEditorSchemaCommand extends BaseCommand { +export class ExpendEmailEditorSchemaCommand extends BaseCommand { schema: TipTapNode; } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts new file mode 100644 index 00000000000..cc494b7d046 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts @@ -0,0 +1,7 @@ +// New HydrateEmailSchemaUseCase class +import { MasterPayload } from '../construct-framework-workflow'; + +export class HydrateEmailSchemaCommand { + emailEditor: string; + masterPayload: MasterPayload; +} 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 new file mode 100644 index 00000000000..c768bf0d4c9 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts @@ -0,0 +1,209 @@ +/* eslint-disable no-param-reassign */ +import { Injectable } from '@nestjs/common'; +import { TipTapNode } from '@novu/shared'; +import { z } from 'zod'; +import { MasterPayload } from '../construct-framework-workflow'; +import { HydrateEmailSchemaCommand } from './hydrate-email-schema.command'; + +@Injectable() +export class HydrateEmailSchemaUseCase { + execute(command: HydrateEmailSchemaCommand): { + hydratedEmailSchema: TipTapNode; + nestedPayload: Record; + } { + const defaultPayload: Record = {}; + const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)); + if (emailEditorSchema.content) { + this.transformContentInPlace(emailEditorSchema.content, defaultPayload, command.masterPayload); + } + + return { hydratedEmailSchema: emailEditorSchema, nestedPayload: this.flattenToNested(defaultPayload) }; + } + + private variableLogic( + masterPayload: MasterPayload, + node: TipTapNode & { attrs: { id: string } }, + defaultPayload: Record, + content: TipTapNode[], + index: number + ) { + const resolvedValueRegularPlaceholder = this.getResolvedValueRegularPlaceholder(masterPayload, node); + defaultPayload[node.attrs.id] = resolvedValueRegularPlaceholder; + content[index] = { + type: 'text', + text: resolvedValueRegularPlaceholder, + }; + } + + private forNodeLogic( + node: TipTapNode & { attrs: { each: string } }, + masterPayload: MasterPayload, + defaultPayload: Record, + content: TipTapNode[], + index: number + ) { + const itemPointerToDefaultRecord = this.collectAllItemPlaceholders(node); + const resolvedValueForPlaceholder = this.getResolvedValueForPlaceholder( + masterPayload, + node, + itemPointerToDefaultRecord + ); + defaultPayload[node.attrs.each] = resolvedValueForPlaceholder; + content[index] = { + type: 'for', + attrs: { each: resolvedValueForPlaceholder }, + content: node.content, + }; + } + + private showLogic( + masterPayload: MasterPayload, + node: TipTapNode & { attrs: { show: string } }, + defaultPayload: Record + ) { + const resolvedValueShowPlaceholder = this.getResolvedValueShowPlaceholder(masterPayload, node); + defaultPayload[node.attrs.show] = resolvedValueShowPlaceholder; + node.attrs.show = resolvedValueShowPlaceholder; + } + + private transformContentInPlace( + content: TipTapNode[], + defaultPayload: Record, + masterPayload: MasterPayload + ) { + content.forEach((node, index) => { + if (this.isVariableNode(node)) { + this.variableLogic(masterPayload, node, defaultPayload, content, index); + } + if (this.isForNode(node)) { + this.forNodeLogic(node, masterPayload, defaultPayload, content, index); + } + if (this.isShowNode(node)) { + this.showLogic(masterPayload, node, defaultPayload); + } + if (node.content) { + this.transformContentInPlace(node.content, defaultPayload, masterPayload); + } + }); + } + + private isForNode(node: TipTapNode): node is TipTapNode & { attrs: { each: string } } { + return !!(node.type === 'for' && node.attrs && 'each' in node.attrs && typeof node.attrs.each === 'string'); + } + + private isShowNode(node: TipTapNode): node is TipTapNode & { attrs: { show: string } } { + return !!(node.attrs && 'show' in node.attrs && typeof node.attrs.show === 'string'); + } + + private isVariableNode(node: TipTapNode): node is TipTapNode & { attrs: { id: string } } { + return !!(node.type === 'variable' && node.attrs && 'id' in node.attrs && typeof node.attrs.id === 'string'); + } + + private getResolvedValueRegularPlaceholder(masterPayload: MasterPayload, node) { + const resolvedValue = this.getValueByPath(masterPayload, node.attrs.id); + const { fallback } = node.attrs; + + return resolvedValue || fallback || `{{${node.attrs.id}}}`; + } + + private getResolvedValueShowPlaceholder(masterPayload: MasterPayload, node) { + const resolvedValue = this.getValueByPath(masterPayload, node.attrs.show); + const { fallback } = node.attrs; + + return resolvedValue || fallback || `true`; + } + + 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; + } + + private getResolvedValueForPlaceholder( + masterPayload: MasterPayload, + node: TipTapNode & { attrs: { each: string } }, + itemPointerToDefaultRecord: Record + ) { + const resolvedValue = this.getValueByPath(masterPayload, node.attrs.each); + if (!resolvedValue) { + return [this.buildElement(itemPointerToDefaultRecord, '1'), this.buildElement(itemPointerToDefaultRecord, '2')]; + } + + return resolvedValue; + } + + private collectAllItemPlaceholders(nodeExt: TipTapNode) { + const payloadValues = {}; + const traverse = (node: TipTapNode) => { + if (node.type === 'for') { + return; + } + if (this.isPayloadValue(node)) { + const { id } = node.attrs; + payloadValues[node.attrs.id] = node.attrs.fallback || `{{item.${id}}}`; + } + if (node.content && Array.isArray(node.content)) { + node.content.forEach(traverse); + } + }; + nodeExt.content?.forEach(traverse); + + return payloadValues; + } + + private getValueByPath(obj: Record, path: string): any { + const keys = path.split('.'); + + return keys.reduce((currentObj, key) => { + if (currentObj && typeof currentObj === 'object' && key in currentObj) { + return currentObj[key]; + } + + return undefined; + }, obj); + } + + private buildElement(itemPointerToDefaultRecord: Record, suffix: string) { + const mockPayload: Record = {}; + Object.keys(itemPointerToDefaultRecord).forEach((key) => { + const keys = key.split('.'); + let current = mockPayload; + keys.forEach((innerKey, index) => { + if (!current[innerKey]) { + current[innerKey] = {}; + } + if (index === keys.length - 1) { + current[innerKey] = itemPointerToDefaultRecord[key] + suffix; + } else { + current = current[innerKey]; + } + }); + }); + + return mockPayload; + } + + private isPayloadValue(node: TipTapNode): node is { type: 'payloadValue'; attrs: { id: string; fallback?: string } } { + return !!(node.type === 'payloadValue' && node.attrs && typeof node.attrs.id === 'string'); + } +} + +export const TipTapSchema = z.object({ + type: z.string().optional(), + content: z.array(z.lazy(() => TipTapSchema)).optional(), + text: z.string().optional(), + attrs: z.record(z.unknown()).optional(), +}); diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts index af36daae8c5..6362d437153 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts @@ -5,3 +5,5 @@ export * from './push-output-renderer.usecase'; export * from './sms-output-renderer.usecase'; export * from './in-app-output-renderer.usecase'; export * from './email-schema-expander.usecase'; +export { HydrateEmailSchemaUseCase } from './hydrate-email-schema-use-case.service'; +export { HydrateEmailSchemaCommand } from './hydrate-email-schema.command'; 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 0a11d5c2175..dcc44476d9a 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -11,12 +11,15 @@ import { GeneratePreviewResponseDto, RedirectTargetEnum, StepTypeEnum, - TipTapNode, } from '@novu/shared'; import { InAppOutput } from '@novu/framework/internal'; import { createWorkflowClient, HttpError, NovuRestResult } from './clients'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; +import { fullCodeSnippet } from './maily-test-data'; +const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; +const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; +const PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE = 'this is the replacement text for the placeholder'; describe('Generate Preview', () => { let session: UserSession; let workflowsClient: ReturnType; @@ -56,6 +59,7 @@ describe('Generate Preview', () => { { type: StepTypeEnum.SMS, description: 'SMS' }, { type: StepTypeEnum.PUSH, description: 'Push' }, { type: StepTypeEnum.CHAT, description: 'Chat' }, + { type: StepTypeEnum.EMAIL, description: 'Email' }, ]; channelTypes.forEach(({ type, description }) => { @@ -74,6 +78,78 @@ describe('Generate Preview', () => { }); }); }); + describe('email specific features', () => { + it('show -> should hide element based on payload', async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL); + const previewResponseDto = await generatePreview( + workflowId, + stepUuid, + { + validationStrategies: [], + controlValues: stepTypeTo[StepTypeEnum.EMAIL], + payloadValues: { params: { isPayedUser: 'false' } }, + }, + 'email' + ); + expect(previewResponseDto.result!.preview).to.exist; + + const preview = previewResponseDto.result!.preview.body; + expect(preview).to.not.contain('should be the fallback value'); + }); + }); + it('show -> should show element based on payload - string', async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL); + const previewResponseDto = await generatePreview( + workflowId, + stepUuid, + { + validationStrategies: [], + controlValues: stepTypeTo[StepTypeEnum.EMAIL], + payloadValues: { params: { isPayedUser: 'true' } }, + }, + 'email' + ); + expect(previewResponseDto.result!.preview).to.exist; + + const preview = previewResponseDto.result!.preview.body; + expect(preview).to.contain('should be the fallback value'); + }); + it('show -> should show element based on payload - boolean', async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL); + const previewResponseDto = await generatePreview( + workflowId, + stepUuid, + { + validationStrategies: [], + controlValues: stepTypeTo[StepTypeEnum.EMAIL], + payloadValues: { params: { isPayedUser: true } }, + }, + 'email' + ); + expect(previewResponseDto.result!.preview).to.exist; + + const preview = previewResponseDto.result!.preview.body; + expect(preview).to.contain('should be the fallback value'); + }); + + it('show -> should show element if payload is missing', async () => { + const { stepUuid, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL); + const previewResponseDto = await generatePreview( + workflowId, + stepUuid, + { + validationStrategies: [], + controlValues: stepTypeTo[StepTypeEnum.EMAIL], + payloadValues: { params: { isPayedUser: 'true' } }, + }, + 'email' + ); + expect(previewResponseDto.result!.preview).to.exist; + + const preview = previewResponseDto.result!.preview.body; + expect(preview).to.contain('should be the fallback value'); + }); + describe('Missing Required ControlValues', () => { const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; @@ -153,64 +229,12 @@ function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GenerateP }; } -const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; +const HEADING_PLACEHOLDER = '{{payload.replacement.subject}}'; -const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; -const PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE = 'this is the replacement text for the placeholder'; -function mailyJsonExample(): TipTapNode { - return { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: '{{payload.intro}} Wow, this editor instance exports its content as JSON.', - }, - ], - }, - { - type: 'for', - attr: { - each: '{{payload.comment}}', - }, - content: [ - { - type: 'h1', - content: [ - { - type: 'text', - text: FOR_ITEM_VALUE_PLACEHOLDER, - }, - ], - }, - ], - }, - { - type: 'show', - attr: { - when: '{{payload.isPremiumPlan}}', - }, - content: [ - { - type: 'h1', - content: [ - { - type: 'text', - text: TEST_SHOW_VALUE, - }, - ], - }, - ], - }, - ], - }; -} function buildEmailControlValuesPayload(): EmailStepControlSchemaDto { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - emailEditor: mailyJsonExample(), + emailEditor: JSON.stringify(fullCodeSnippet), }; } function buildInAppControlValues(): InAppOutput { @@ -285,8 +309,17 @@ function assertEmail(dto: GeneratePreviewResponseDto) { if (dto.result!.type === ChannelTypeEnum.EMAIL) { const preview = dto.result!.preview.body; expect(preview).to.exist; - expect(preview).to.not.contain('{{payload.comment}}'); - expect(preview).to.contain(FOR_ITEM_VALUE_PLACEHOLDER); - expect(preview).to.contain(TEST_SHOW_VALUE); + 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.subject}}1'); + expect(preview).to.contain('{{item.subject}}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/maily-test-data.ts b/apps/api/src/app/workflows-v2/maily-test-data.ts new file mode 100644 index 00000000000..ab4fd391234 --- /dev/null +++ b/apps/api/src/app/workflows-v2/maily-test-data.ts @@ -0,0 +1,532 @@ +export const fullCodeSnippet = { + type: 'doc', + content: [ + { + type: 'logo', + attrs: { + src: 'https://maily.to/brand/logo.png', + alt: null, + title: null, + 'maily-component': 'logo', + size: 'md', + alignment: 'left', + }, + }, + { + type: 'spacer', + attrs: { + height: 'xl', + }, + }, + { + type: 'heading', + attrs: { + textAlign: 'left', + level: 2, + }, + content: [ + { + type: 'text', + marks: [ + { + type: 'bold', + }, + ], + text: 'Discover Maily', + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'Are you ready to transform your email communication? Introducing Maily, the powerful email editor that enables you to craft captivating emails effortlessly.', + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'Elevate your email communication with Maily! Click below to try it out:', + }, + ], + }, + { + type: 'button', + attrs: { + text: 'Try Maily Now →', + url: '', + alignment: 'left', + variant: 'filled', + borderRadius: 'round', + buttonColor: '#000000', + textColor: '#ffffff', + }, + }, + { + type: 'section', + attrs: { + show: 'payload.params.isPayedUser', + borderRadius: 0, + backgroundColor: '#f7f7f7', + align: 'left', + borderWidth: 1, + borderColor: '#e2e2e2', + paddingTop: 5, + paddingRight: 5, + paddingBottom: 5, + paddingLeft: 5, + marginTop: 0, + marginRight: 0, + marginBottom: 0, + marginLeft: 0, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'variable', + attrs: { + id: 'payload.hidden.section', + label: null, + fallback: 'should be the fallback value', + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'Join our vibrant community of users and developers on GitHub, where Maily is an ', + }, + { + type: 'text', + marks: [ + { + type: 'link', + attrs: { + href: 'https://github.com/arikchakma/maily.to', + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: null, + }, + }, + { + type: 'italic', + }, + ], + text: 'open-source', + }, + { + type: 'text', + text: " project. Together, we'll shape the future of email editing.", + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: '@this is a placeholder value of name payload.body|| ', + }, + { + type: 'variable', + attrs: { + id: 'payload.body', + label: null, + fallback: null, + }, + }, + { + type: 'text', + text: ' |||the value should have been here', + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'this is a regular for block showing multiple comments:', + }, + ], + }, + { + type: 'for', + attrs: { + each: 'comments', + isUpdatingKey: false, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'Comment subject: ', + }, + { + type: 'payloadValue', + attrs: { + id: 'subject', + label: null, + }, + }, + { + type: 'text', + text: ' and the body is: ', + }, + { + type: 'payloadValue', + attrs: { + id: 'body', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'This will be two for each one in another column: ', + }, + ], + }, + { + type: 'columns', + attrs: { + width: '100%', + }, + content: [ + { + type: 'column', + attrs: { + columnId: '394bcc6f-c674-4d56-aced-f3f54434482e', + width: 50, + verticalAlign: 'top', + borderRadius: 0, + backgroundColor: 'transparent', + borderWidth: 0, + borderColor: 'transparent', + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, + }, + content: [ + { + type: 'for', + attrs: { + each: 'origins', + isUpdatingKey: false, + }, + content: [ + { + type: 'orderedList', + attrs: { + start: 1, + }, + content: [ + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'a list item: ', + }, + { + type: 'payloadValue', + attrs: { + id: 'origin.country', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'column', + attrs: { + columnId: 'a61ae45e-ea27-4a2b-a356-bfad769ea50f', + width: 50, + verticalAlign: 'top', + borderRadius: 0, + backgroundColor: 'transparent', + borderWidth: 0, + borderColor: 'transparent', + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, + }, + content: [ + { + type: 'for', + attrs: { + each: 'students', + isUpdatingKey: false, + }, + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'bulleted list item: ', + }, + { + type: 'payloadValue', + attrs: { + id: 'id', + label: null, + }, + }, + { + type: 'text', + text: ' and name: ', + }, + { + type: 'payloadValue', + attrs: { + id: 'name', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + ], + }, + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'buffer bullet item', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'This will be a nested for block', + }, + ], + }, + { + type: 'for', + attrs: { + each: 'food.items', + isUpdatingKey: false, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'this is a food item with name ', + }, + { + type: 'payloadValue', + attrs: { + id: 'name', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + { + type: 'for', + attrs: { + each: 'food.warnings', + isUpdatingKey: false, + }, + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + attrs: { + color: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'payloadValue', + attrs: { + id: 'header', + label: null, + }, + }, + { + type: 'text', + text: ' ', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + }, + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'Regards,', + }, + { + type: 'hardBreak', + }, + { + type: 'text', + text: 'Arikko', + }, + ], + }, + ], +}; diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/construct-payload-from-placeholders-with-defaults-use-case.service.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/construct-payload-from-placeholders-with-defaults-use-case.service.ts new file mode 100644 index 00000000000..a5bb24364a5 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/construct-payload-from-placeholders-with-defaults-use-case.service.ts @@ -0,0 +1,151 @@ +/* eslint-disable no-param-reassign */ +import { Injectable } from '@nestjs/common'; +import { ControlPreviewIssue, ControlPreviewIssueTypeEnum } from '@novu/shared'; +import _ = require('lodash'); +import { CreateMockPayloadForSingleControlValueUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase'; + +@Injectable() +export class ConstructPayloadFromPlaceholdersWithDefaultsUseCase { + constructor(private payloadForSingleControlValueUseCase: CreateMockPayloadForSingleControlValueUseCase) {} + + execute(controlValues?: Record, payloadValues?: Record) { + let aggregatedDefaultValues = {}; + const aggregatedDefaultValuesForControl: Record> = {}; + const flattenedValues = flattenJson(controlValues); + + for (const controlValueKey in flattenedValues) { + if (flattenedValues.hasOwnProperty(controlValueKey)) { + const defaultValuesForSingleControlValue = this.payloadForSingleControlValueUseCase.execute({ + controlValues: flattenedValues, + controlValueKey, + }); + + if (defaultValuesForSingleControlValue) { + aggregatedDefaultValuesForControl[controlValueKey] = defaultValuesForSingleControlValue; + } + aggregatedDefaultValues = _.merge(defaultValuesForSingleControlValue, aggregatedDefaultValues); + } + } + + return { + augmentedPayload: _.merge(aggregatedDefaultValues, payloadValues), + issues: this.buildVariableMissingIssueRecord( + aggregatedDefaultValuesForControl, + aggregatedDefaultValues, + payloadValues + ), + }; + } + + private buildVariableMissingIssueRecord( + valueKeyToDefaultsMap: Record>, + aggregatedDefaultValues: Record, + payloadValues: Record | undefined + ) { + const defaultVariableToValueKeyMap = flattenJsonWithArrayValues(valueKeyToDefaultsMap); + const missingRequiredPayloadIssues = this.findMissingKeys(aggregatedDefaultValues, payloadValues || {}); + + return this.buildPayloadIssues(missingRequiredPayloadIssues, defaultVariableToValueKeyMap); + } + + private findMissingKeys(requiredRecord: Record, actualRecord: Record) { + const requiredKeys = this.collectKeys(requiredRecord); + const actualKeys = this.collectKeys(actualRecord); + + return _.difference(requiredKeys, actualKeys); + } + + private collectKeys(obj, prefix = '') { + return _.reduce( + obj, + (result, value, key) => { + const newKey = prefix ? `${prefix}.${key}` : key; + if (_.isObject(value) && !_.isArray(value)) { + result.push(...this.collectKeys(value, newKey)); + } else { + 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: ControlPreviewIssueTypeEnum.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/generate-preview/generate-preview.usecase.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts index eda1c6ed67e..cd4b6ea1eb2 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 @@ -4,42 +4,34 @@ import { ControlPreviewIssue, ControlPreviewIssueTypeEnum, ControlsSchema, - GeneratePreviewRequestDto, GeneratePreviewResponseDto, JSONSchemaDto, StepTypeEnum, WorkflowOriginEnum, } from '@novu/shared'; import { merge } from 'lodash/fp'; -import { difference, isArray, isObject, reduce } from 'lodash'; +import _ = require('lodash'); import { GeneratePreviewCommand } from './generate-preview-command'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; import { GetWorkflowUseCase } from '../get-workflow/get-workflow.usecase'; -import { CreateMockPayloadUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase'; import { StepNotFoundException } from '../../exceptions/step-not-found-exception'; import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase'; +import { ConstructPayloadFromPlaceholdersWithDefaultsUseCase } from './construct-payload-from-placeholders-with-defaults-use-case.service'; @Injectable() export class GeneratePreviewUsecase { constructor( private legacyPreviewStepUseCase: PreviewStep, private getWorkflowUseCase: GetWorkflowUseCase, - private createMockPayloadUseCase: CreateMockPayloadUseCase, - private extractDefaultsUseCase: ExtractDefaultsUsecase + private extractDefaultsUseCase: ExtractDefaultsUsecase, + private constructPayloadUseCase: ConstructPayloadFromPlaceholdersWithDefaultsUseCase ) {} async execute(command: GeneratePreviewCommand): Promise { - const payloadHydrationInfo = this.payloadHydrationLogic(command); + const payloadHydrationInfo = this.buildPayloadIfMissing(command); const workflowInfo = await this.getWorkflowUserIdentifierFromWorkflowObject(command); const controlValuesResult = this.addMissingValuesToControlValues(command, workflowInfo.stepControlSchema); - const executeOutput = await this.executePreviewUsecase( - workflowInfo.workflowId, - workflowInfo.stepId, - workflowInfo.origin, - payloadHydrationInfo.augmentedPayload, - controlValuesResult.augmentedControlValues, - command - ); + const executeOutput = await this.executePreview(workflowInfo, payloadHydrationInfo, controlValuesResult, command); return buildResponse( controlValuesResult.issuesMissingValues, @@ -49,6 +41,40 @@ export class GeneratePreviewUsecase { ); } + private buildPayloadIfMissing(command: GeneratePreviewCommand) { + const { controlValues, payloadValues } = command.generatePreviewRequestDto; + + return this.constructPayloadUseCase.execute(controlValues, payloadValues); + } + + private async executePreview( + workflowInfo: { + stepControlSchema: ControlsSchema; + stepType: StepTypeEnum; + origin: WorkflowOriginEnum; + stepId: string; + workflowId: string; + }, + payloadHydrationInfo: { + augmentedPayload: Record; + issues: Record; + }, + controlValuesResult: { + augmentedControlValues: Record; + issuesMissingValues: Record; + }, + command: GeneratePreviewCommand + ) { + return await this.executePreviewUsecase( + workflowInfo.workflowId, + workflowInfo.stepId, + workflowInfo.origin, + payloadHydrationInfo.augmentedPayload, + controlValuesResult.augmentedControlValues, + command + ); + } + private addMissingValuesToControlValues(command: GeneratePreviewCommand, stepControlSchema: ControlsSchema) { const defaultValues = this.extractDefaultsUseCase.execute({ jsonSchemaDto: stepControlSchema.schema as JSONSchemaDto, @@ -61,7 +87,7 @@ export class GeneratePreviewUsecase { } private buildMissingControlValuesIssuesList(defaultValues: Record, command: GeneratePreviewCommand) { - const missingRequiredControlValues = this.findMissingKeys( + const missingRequiredControlValues = findMissingKeys( defaultValues, command.generatePreviewRequestDto.controlValues || {} ); @@ -76,36 +102,14 @@ export class GeneratePreviewUsecase { record[key] = [ { issueType: ControlPreviewIssueTypeEnum.MISSING_VALUE, - message: `Value is missing on a required control`, // Custom message for the issue + message: `Value is missing on a required control`, }, ]; }); return record; } - private findMissingKeys(requiredRecord: Record, actualRecord: Record) { - const requiredKeys = this.collectKeys(requiredRecord); - const actualKeys = this.collectKeys(actualRecord); - return difference(requiredKeys, actualKeys); - } - private collectKeys(obj, prefix = '') { - return reduce( - obj, - (result, value, key) => { - const newKey = prefix ? `${prefix}.${key}` : key; - if (isObject(value) && !isArray(value)) { - result.push(...this.collectKeys(value, newKey)); - } else { - // Otherwise, just add the key - result.push(newKey); - } - - return result; - }, - [] - ); - } private async executePreviewUsecase( workflowId: string, stepId: string, @@ -147,63 +151,6 @@ export class GeneratePreviewUsecase { origin: workflowResponseDto.origin, }; } - - private payloadHydrationLogic(command: GeneratePreviewCommand) { - const dto = command.generatePreviewRequestDto; - - let aggregatedDefaultValues = {}; - const aggregatedDefaultValuesForControl: Record> = {}; - const flattenedValues = flattenJson(dto.controlValues); - for (const controlValueKey in flattenedValues) { - if (flattenedValues.hasOwnProperty(controlValueKey)) { - const defaultValuesForSingleControlValue = this.createMockPayloadUseCase.execute({ - controlValues: flattenedValues, - controlValueKey, - }); - - if (defaultValuesForSingleControlValue) { - aggregatedDefaultValuesForControl[controlValueKey] = defaultValuesForSingleControlValue; - } - aggregatedDefaultValues = merge(defaultValuesForSingleControlValue, aggregatedDefaultValues); - } - } - - return { - augmentedPayload: merge(aggregatedDefaultValues, dto.payloadValues), - issues: this.buildVariableMissingIssueRecord(aggregatedDefaultValuesForControl, aggregatedDefaultValues, dto), - }; - } - - private buildVariableMissingIssueRecord( - valueKeyToDefaultsMap: Record>, - aggregatedDefaultValues: Record, - dto: GeneratePreviewRequestDto - ) { - const defaultVariableToValueKeyMap = flattenJsonWithArrayValues(valueKeyToDefaultsMap); - const missingRequiredPayloadIssues = this.findMissingKeys(aggregatedDefaultValues, dto.payloadValues || {}); - - return this.buildPayloadIssues(missingRequiredPayloadIssues, defaultVariableToValueKeyMap); - } - private buildPayloadIssues( - missingVariables: string[], - variableToControlValueKeys: Record - ): Record { - const record: Record = {}; - - missingVariables.forEach((missingVariable) => { - variableToControlValueKeys[missingVariable].forEach((controlValueKey) => { - record[controlValueKey] = [ - { - issueType: ControlPreviewIssueTypeEnum.MISSING_VARIABLE_IN_PAYLOAD, // Set issueType to MISSING_VALUE - message: `Variable payload.${missingVariable} is missing in payload`, // Custom message for the issue - variableName: `payload.${missingVariable}`, - }, - ]; - }); - }); - - return record; - } } function buildResponse( @@ -215,73 +162,27 @@ function buildResponse( return { issues: merge(missingValuesIssue, missingPayloadVariablesIssue), result: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any preview: executionOutput.outputs as any, type: stepType as unknown as ChannelTypeEnum, }, }; } -function flattenJsonWithArrayValues(valueKeyToDefaultsMap: Record>) { - const flattened = {}; - Object.keys(valueKeyToDefaultsMap).forEach((controlValue) => { - const defaultPayloads = valueKeyToDefaultsMap[controlValue]; - const defaultPlaceholders = getDotNotationKeys(defaultPayloads); - defaultPlaceholders.forEach((defaultPlaceholder) => { - if (!flattened[defaultPlaceholder]) { - flattened[defaultPlaceholder] = []; - } - flattened[defaultPlaceholder].push(controlValue); - }); - }); - - return flattened; -} -type NestedRecord = Record; - -function getDotNotationKeys(input: NestedRecord, parentKey: string = '', keys: string[] = []): string[] { - for (const key in input) { - if (input.hasOwnProperty(key)) { - const newKey = parentKey ? `${parentKey}.${key}` : key; // Construct dot notation key - if (typeof input[key] === 'object' && input[key] !== null && !Array.isArray(input[key])) { - // Recursively flatten the object and collect keys - getDotNotationKeys(input[key] as NestedRecord, newKey, keys); - } else { - // Push the dot notation key to the keys array - keys.push(newKey); - } - } - } +function findMissingKeys(requiredRecord: Record, actualRecord: Record) { + const requiredKeys = collectKeys(requiredRecord); + const actualKeys = collectKeys(actualRecord); - return keys; + return _.difference(requiredKeys, actualKeys); } -function flattenJson(obj, parentKey = '', result = {}) { - // eslint-disable-next-line guard-for-in - for (const key in obj) { - // Construct the new key using dot notation - const newKey = parentKey ? `${parentKey}.${key}` : key; - - // Check if the value is an object (and not null or an array) - if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { - // Recursively flatten the object - flattenJson(obj[key], newKey, result); - } else if (Array.isArray(obj[key])) { - // Handle arrays by flattening each item - obj[key].forEach((item, index) => { - const arrayKey = `${newKey}[${index}]`; - if (typeof item === 'object' && item !== null) { - flattenJson(item, arrayKey, result); - } else { - // eslint-disable-next-line no-param-reassign - result[arrayKey] = item; - } - }); +function 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 { - // Assign the value to the result with the new key - // eslint-disable-next-line no-param-reassign - result[newKey] = obj[key]; + result.push(newKey); } - } - return result; + return result; + }); } diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts deleted file mode 100644 index a38836713b3..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable no-param-reassign,no-cond-assign */ -// Importing necessary types -import { TipTapNode } from '@novu/shared'; - -// Define the PlaceholderMap type -export type PlaceholderMap = { - for?: { - [key: string]: string[]; - }; - show?: { - [key: string]: any[]; - }; - regular?: { - [key: string]: any[]; - }; -}; - -// Define the command interface for parameters -// eslint-disable-next-line @typescript-eslint/naming-convention -export interface CollectPlaceholdersCommand { - node: TipTapNode; -} - -// Create the main class with a UseCase suffix -export class CollectPlaceholdersFromTipTapSchemaUsecase { - /** - * The main entry point for executing the use case. - * - * @param {CollectPlaceholdersCommand} command - The command containing parameters. - * @returns {PlaceholderMap} An object mapping main placeholders to their nested placeholders. - */ - public execute(command: CollectPlaceholdersCommand): PlaceholderMap { - const placeholders: Required = { - for: {}, - show: {}, - regular: {}, - }; - - this.traverse(command.node, placeholders); - - return placeholders; - } - - private traverse(node: TipTapNode, placeholders: Required) { - if (node.type === 'for' && node.attr) { - this.handleForTraversal(node, placeholders); - } else if (node.type === 'show' && node.attr && node.attr.when) { - this.handleShowTraversal(node, placeholders); - } else if (node.type === 'text' && node.text) { - const regularPlaceholders = extractPlaceholders(node.text).filter( - (placeholder) => !placeholder.startsWith('item') - ); - for (const regularPlaceholder of regularPlaceholders) { - placeholders.regular[regularPlaceholder] = []; - } - } - - if (node.content) { - node.content.forEach((childNode) => this.traverse(childNode, placeholders)); - } - } - - private handleForTraversal(node: TipTapNode, placeholders: Required) { - if (node.type === 'show' && node.attr && typeof node.attr.each === 'string') { - const mainPlaceholder = extractPlaceholders(node.attr.each); - if (mainPlaceholder && mainPlaceholder.length === 1) { - if (!placeholders.for[mainPlaceholder[0]]) { - placeholders.for[mainPlaceholder[0]] = []; - } - - if (node.content) { - node.content.forEach((nestedNode) => { - if (nestedNode.content) { - nestedNode.content.forEach((childNode) => { - if (childNode.type === 'text' && childNode.text) { - const nestedPlaceholders = extractPlaceholders(childNode.text); - for (const nestedPlaceholder of nestedPlaceholders) { - placeholders.for[mainPlaceholder[0]].push(nestedPlaceholder); - } - } - }); - } - }); - } - } - } - } - - private handleShowTraversal(node: TipTapNode, placeholders: Required) { - if (node.type === 'show' && node.attr && typeof node.attr.when === 'string') { - const nestedPlaceholders = extractPlaceholders(node.attr.when); - placeholders.show[nestedPlaceholders[0]] = []; - } - } -} -export function extractPlaceholders(text: string): string[] { - const regex = /\{\{\{(.*?)\}\}\}|\{\{(.*?)\}\}|\{#(.*?)#\}/g; // todo: add support for nested placeholders - const matches: string[] = []; - let match: RegExpExecArray | null; - - while ((match = regex.exec(text)) !== null) { - const placeholder = match[1] || match[2] || match[3]; - if (placeholder) { - matches.push(placeholder.trim()); - } - } - - return matches; -} diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts index f27123948e3..12b6a1ba94d 100644 --- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-preview-value-generator.usecase.ts @@ -1,17 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { TipTapNode } from '@novu/shared'; import { TransformPlaceholderMapUseCase } from './transform-placeholder.usecase'; -import { - CollectPlaceholdersFromTipTapSchemaUsecase, - extractPlaceholders, -} from './collect-placeholders-from-tip-tap-schema.usecase'; import { AddKeysToPayloadBasedOnHydrationStrategyCommand } from './add-keys-to-payload-based-on-hydration-strategy-command'; +import { HydrateEmailSchemaUseCase } from '../../../environments-v1/usecases/output-renderers/hydrate-email-schema.usecase'; @Injectable() -export class CreateMockPayloadUseCase { +export class CreateMockPayloadForSingleControlValueUseCase { constructor( - private readonly collectPlaceholdersFromTipTapSchemaUsecase: CollectPlaceholdersFromTipTapSchemaUsecase, - private readonly transformPlaceholderMapUseCase: TransformPlaceholderMapUseCase + private readonly transformPlaceholderMapUseCase: TransformPlaceholderMapUseCase, + private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase ) {} public execute(command: AddKeysToPayloadBasedOnHydrationStrategyCommand): Record { @@ -22,20 +18,29 @@ export class CreateMockPayloadUseCase { } const controlValue = controlValues[controlValueKey]; - if (typeof controlValue === 'object') { - return this.buildPayloadForEmailEditor(controlValue); + const safeAttemptToParseEmailSchema = this.safeAttemptToParseEmailSchema(controlValue); + if (safeAttemptToParseEmailSchema) { + return safeAttemptToParseEmailSchema; } return this.buildPayloadForRegularText(controlValue); } - private buildPayloadForEmailEditor(controlValue: unknown): Record { - const collectPlaceholderMappings = this.collectPlaceholdersFromTipTapSchemaUsecase.execute({ - node: controlValue as TipTapNode, - }); - const transformPlaceholderMap = this.transformPlaceholderMapUseCase.execute({ input: collectPlaceholderMappings }); + private safeAttemptToParseEmailSchema(controlValue: string) { + try { + const { nestedPayload } = this.hydrateEmailSchemaUseCase.execute({ + emailEditor: controlValue, + masterPayload: { + payload: {}, + subscriber: {}, + steps: {}, + }, + }); - return transformPlaceholderMap.payload; + return nestedPayload; + } catch (e) { + return undefined; + } } private buildPayloadForRegularText(controlValue: unknown) { @@ -48,7 +53,21 @@ export class CreateMockPayloadUseCase { }).payload; } } +export function extractPlaceholders(text: string): string[] { + 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(text)) !== 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) => { diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index d011c0480da..b8c39656acc 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -20,9 +20,8 @@ import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-work import { GetStepSchemaUseCase } from '../step-schemas/usecases/get-step-schema/get-step-schema.usecase'; import { BridgeModule } from '../bridge'; import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; -import { CreateMockPayloadUseCase } from './usecases/placeholder-enrichment/payload-preview-value-generator.usecase'; +import { CreateMockPayloadForSingleControlValueUseCase } from './usecases/placeholder-enrichment/payload-preview-value-generator.usecase'; import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schema/extract-defaults.usecase'; -import { CollectPlaceholdersFromTipTapSchemaUsecase } from './usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase'; import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichment/transform-placeholder.usecase'; @Module({ @@ -43,9 +42,8 @@ import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichmen GeneratePreviewUsecase, GetWorkflowUseCase, GetPreferences, - CreateMockPayloadUseCase, + CreateMockPayloadForSingleControlValueUseCase, ExtractDefaultsUsecase, - CollectPlaceholdersFromTipTapSchemaUsecase, TransformPlaceholderMapUseCase, ], }) diff --git a/packages/shared/src/dto/step-schemas/control-schemas.ts b/packages/shared/src/dto/step-schemas/control-schemas.ts index 2b0226c96f6..dc95c37a747 100644 --- a/packages/shared/src/dto/step-schemas/control-schemas.ts +++ b/packages/shared/src/dto/step-schemas/control-schemas.ts @@ -1,15 +1,16 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { JSONSchema } from 'json-schema-to-ts'; +import { JSONSchemaDto } from './json-schema-dto'; export interface TipTapNode { - type: string; + type?: string; content?: TipTapNode[]; + marks?: unknown; text?: string; - attr?: Record; + attrs?: Record; } export interface EmailStepControlSchemaDto { - emailEditor: TipTapNode; + emailEditor: string; subject: string; } @@ -18,12 +19,11 @@ export enum CustomComponentsEnum { TEXT_AREA = 'TEXT_FIELD', } -export const EmailStepControlSchema: JSONSchema = { +export const EmailStepControlSchema: JSONSchemaDto = { type: 'object', properties: { emailEditor: { - type: 'object', - additionalProperties: true, // Allows any properties in emailEditor + type: 'string', }, subject: { type: 'string', diff --git a/packages/shared/src/dto/step-schemas/json-schema-dto.ts b/packages/shared/src/dto/step-schemas/json-schema-dto.ts index b92311149f7..9721181c708 100644 --- a/packages/shared/src/dto/step-schemas/json-schema-dto.ts +++ b/packages/shared/src/dto/step-schemas/json-schema-dto.ts @@ -71,4 +71,11 @@ export interface JSONSchemaDto { readOnly?: boolean | undefined; writeOnly?: boolean | undefined; examples?: JSONSchemaType | undefined; + + // Custom extensions property + extensions?: + | { + [key: string]: unknown; // Allows for any additional properties + } + | undefined; }