From 3f9027c1247a7d32908a46a93690df78e431bbf0 Mon Sep 17 00:00:00 2001
From: GalT <39020298+tatarco@users.noreply.github.com>
Date: Tue, 29 Oct 2024 09:21:46 +0100
Subject: [PATCH 01/11] feat(api): complete email preview
---
.cspell.json | 1 +
.idea/codeStyles/Project.xml | 1 +
.../preview-step/preview-step.command.ts | 11 +-
.../preview-step/preview-step.usecase.ts | 4 +-
.../app/environments-v1/novu-bridge.module.ts | 8 +-
.../construct-framework-workflow.usecase.ts | 30 +-
.../email-output-renderer.usecase.ts | 44 --
.../email-schema-expander.usecase.ts | 77 ---
.../expand-email-editor-schema-command.ts | 9 +
.../expand-email-editor-schema.usecase.ts | 170 ++++++
.../expend-email-editor-schema-command.ts | 9 -
.../hydrate-email-schema.command.ts | 8 +
.../hydrate-email-schema.usecase.ts | 211 +++++++
.../usecases/output-renderers/index.ts | 6 +-
.../output-renderers/render-command.ts | 6 +
.../render-email-output.usecase.ts | 33 +
.../app/workflows-v2/generate-preview.e2e.ts | 226 ++++---
.../src/app/workflows-v2/maily-test-data.ts | 570 ++++++++++++++++++
.../build-default-payload-use-case.service.ts | 159 +++++
.../build-payload-from-placeholder/index.ts | 1 +
.../generate-preview-command.ts | 2 +-
.../generate-preview.usecase.ts | 213 ++-----
.../buildPayloadNestedStructureUsecase.ts | 29 +
...laceholders-from-tip-tap-schema.usecase.ts | 109 ----
...yload-defaults-engine-failure.exception.ts | 7 +
...payload-preview-value-generator.usecase.ts | 81 ++-
.../transform-placeholder.usecase.ts | 60 --
.../workflows-v2/workflow.controller.e2e.ts | 35 +-
.../src/app/workflows-v2/workflow.module.ts | 15 +-
apps/api/src/exception-filter.ts | 40 +-
.../shared/src}/clients/index.ts | 0
.../shared/src}/clients/novu-base-client.ts | 0
.../shared/src}/clients/workflows-client.ts | 8 +-
packages/shared/src/dto/index.ts | 1 -
.../control-preview-issue-type.enum.ts | 5 -
.../src/dto/step-schemas/control-schemas.ts | 13 -
.../generate-preview-response.dto.ts | 86 ---
packages/shared/src/dto/step-schemas/index.ts | 5 -
.../src/dto/workflows/control-schemas.ts | 33 +
.../generate-preview-request.dto.ts | 6 +-
packages/shared/src/dto/workflows/index.ts | 4 +
.../json-schema-dto.ts | 0
.../workflows/preview-step-response.dto.ts | 13 +-
packages/shared/src/index.ts | 1 +
44 files changed, 1638 insertions(+), 712 deletions(-)
delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts
delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts
delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts
create mode 100644 apps/api/src/app/workflows-v2/maily-test-data.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts
delete mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts
delete mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts
rename {apps/api/src/app/workflows-v2 => packages/shared/src}/clients/index.ts (100%)
rename {apps/api/src/app/workflows-v2 => packages/shared/src}/clients/novu-base-client.ts (100%)
rename {apps/api/src/app/workflows-v2 => packages/shared/src}/clients/workflows-client.ts (97%)
delete mode 100644 packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts
delete mode 100644 packages/shared/src/dto/step-schemas/control-schemas.ts
delete mode 100644 packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts
delete mode 100644 packages/shared/src/dto/step-schemas/index.ts
create mode 100644 packages/shared/src/dto/workflows/control-schemas.ts
rename packages/shared/src/dto/{step-schemas => workflows}/generate-preview-request.dto.ts (65%)
rename packages/shared/src/dto/{step-schemas => workflows}/json-schema-dto.ts (100%)
diff --git a/.cspell.json b/.cspell.json
index 5c8000f827f..b8ae9adfc23 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -753,6 +753,7 @@
"projectIds"
],
"ignorePaths": [
+ "apps/api/src/app/workflows-v2/maily-test-data.ts",
"apps/api/src/.env.test",
"apps/ws/src/.env.test",
"apps/ws/src/.example.env",
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index f986f2fe824..fcf2ca85553 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -19,6 +19,7 @@
+
diff --git a/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts b/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts
index fc110899c54..9458b50e5ea 100644
--- a/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts
+++ b/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts
@@ -1,6 +1,6 @@
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { Subscriber } from '@novu/framework/internal';
-import { WorkflowOriginEnum } from '@novu/shared';
+import { JobStatusEnum, WorkflowOriginEnum } from '@novu/shared';
export class PreviewStepCommand extends EnvironmentWithUserCommand {
workflowId: string;
@@ -9,4 +9,13 @@ export class PreviewStepCommand extends EnvironmentWithUserCommand {
payload: Record;
subscriber?: Subscriber;
workflowOrigin: WorkflowOriginEnum;
+ state?: FrameworkPreviousStepsOutputState[];
}
+export type FrameworkPreviousStepsOutputState = {
+ stepId: string;
+ outputs: Record;
+ state: {
+ status: JobStatusEnum;
+ error?: string;
+ };
+};
diff --git a/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts b/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
index cabcaef8451..22db671f9f4 100644
--- a/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
+++ b/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { Event, ExecuteOutput, HttpQueryKeysEnum, JobStatusEnum, PostActionEnum } from '@novu/framework/internal';
+import { Event, ExecuteOutput, HttpQueryKeysEnum, PostActionEnum } from '@novu/framework/internal';
import { ExecuteBridgeRequest, ExecuteBridgeRequestCommand } from '@novu/application-generic';
import { PreviewStepCommand } from './preview-step.command';
@@ -35,7 +35,7 @@ export class PreviewStep {
return {
controls: command.controls || {},
payload: command.payload || {},
- state: [],
+ state: command.state || [],
subscriber: command.subscriber || {},
stepId: command.stepId,
workflowId: command.workflowId,
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..5f1fdec55d2 100644
--- a/apps/api/src/app/environments-v1/novu-bridge.module.ts
+++ b/apps/api/src/app/environments-v1/novu-bridge.module.ts
@@ -8,10 +8,11 @@ import { ConstructFrameworkWorkflow } from './usecases/construct-framework-workf
import { NovuBridgeController } from './novu-bridge.controller';
import {
ChatOutputRendererUsecase,
- EmailOutputRendererUsecase,
ExpandEmailEditorSchemaUsecase,
+ HydrateEmailSchemaUseCase,
InAppOutputRendererUsecase,
PushOutputRendererUsecase,
+ RenderEmailOutputUsecase,
SmsOutputRendererUsecase,
} from './usecases/output-renderers';
@@ -28,12 +29,13 @@ import {
ConstructFrameworkWorkflow,
GetDecryptedSecretKey,
InAppOutputRendererUsecase,
- EmailOutputRendererUsecase,
+ RenderEmailOutputUsecase,
SmsOutputRendererUsecase,
ChatOutputRendererUsecase,
PushOutputRendererUsecase,
- EmailOutputRendererUsecase,
+ RenderEmailOutputUsecase,
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 eaea7963c17..aeb160a568f 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
@@ -15,9 +15,10 @@ import { StepTypeEnum } from '@novu/shared';
import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflow.command';
import {
ChatOutputRendererUsecase,
- EmailOutputRendererUsecase,
+ FullPayloadForRender,
InAppOutputRendererUsecase,
PushOutputRendererUsecase,
+ RenderEmailOutputUsecase,
SmsOutputRendererUsecase,
} from '../output-renderers';
@@ -26,7 +27,7 @@ export class ConstructFrameworkWorkflow {
constructor(
private workflowsRepository: NotificationTemplateRepository,
private inAppOutputRendererUseCase: InAppOutputRendererUsecase,
- private emailOutputRendererUseCase: EmailOutputRendererUsecase,
+ private emailOutputRendererUseCase: RenderEmailOutputUsecase,
private smsOutputRendererUseCase: SmsOutputRendererUsecase,
private chatOutputRendererUseCase: ChatOutputRendererUsecase,
private pushOutputRendererUseCase: PushOutputRendererUsecase
@@ -46,9 +47,14 @@ export class ConstructFrameworkWorkflow {
private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow {
return workflow(
newWorkflow.triggers[0].identifier,
- async ({ step }) => {
+ async ({ step, payload, subscriber }) => {
+ const fullPayloadForRender: FullPayloadForRender = { payload, subscriber, steps: {} };
for await (const staticStep of newWorkflow.steps) {
- await this.constructStep(step, staticStep);
+ fullPayloadForRender.steps[staticStep.stepId || staticStep._templateId] = await this.constructStep(
+ step,
+ staticStep,
+ fullPayloadForRender
+ );
}
},
{
@@ -66,7 +72,11 @@ export class ConstructFrameworkWorkflow {
);
}
- private constructStep(step: Step, staticStep: NotificationStepEntity): StepOutput> {
+ private constructStep(
+ step: Step,
+ staticStep: NotificationStepEntity,
+ fullPayloadForRender: FullPayloadForRender
+ ): StepOutput> {
const stepTemplate = staticStep.template;
if (!stepTemplate) {
@@ -91,7 +101,7 @@ export class ConstructFrameworkWorkflow {
stepId,
// The step callback function. Takes controls and returns the step outputs
async (controlValues) => {
- return this.inAppOutputRendererUseCase.execute({ controlValues });
+ return this.inAppOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
// Step options
this.constructChannelStepOptions(staticStep)
@@ -100,7 +110,7 @@ export class ConstructFrameworkWorkflow {
return step.email(
stepId,
async (controlValues) => {
- return this.emailOutputRendererUseCase.execute({ controlValues });
+ return this.emailOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
this.constructChannelStepOptions(staticStep)
);
@@ -108,7 +118,7 @@ export class ConstructFrameworkWorkflow {
return step.inApp(
stepId,
async (controlValues) => {
- return this.smsOutputRendererUseCase.execute({ controlValues });
+ return this.smsOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
this.constructChannelStepOptions(staticStep)
);
@@ -116,7 +126,7 @@ export class ConstructFrameworkWorkflow {
return step.inApp(
stepId,
async (controlValues) => {
- return this.chatOutputRendererUseCase.execute({ controlValues });
+ return this.chatOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
this.constructChannelStepOptions(staticStep)
);
@@ -124,7 +134,7 @@ export class ConstructFrameworkWorkflow {
return step.inApp(
stepId,
async (controlValues) => {
- return this.pushOutputRendererUseCase.execute({ controlValues });
+ return this.pushOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
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
deleted file mode 100644
index bef8f4008a8..00000000000
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { EmailRenderOutput, TipTapNode } from '@novu/shared';
-import { render } from '@maily-to/render';
-import { z } from 'zod';
-import { Injectable } from '@nestjs/common';
-import { RenderCommand } from './render-command';
-import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander.usecase';
-
-@Injectable()
-export class EmailOutputRendererUsecase {
- constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {}
-
- async execute(renderCommand: RenderCommand): Promise {
- const parse = EmailStepControlSchema.parse(renderCommand.controlValues);
- const schema = parse.emailEditor as TipTapNode;
- const expandedSchema = this.expendEmailEditorSchemaUseCase.execute({ schema });
- const html = await render(expandedSchema);
-
- return { subject: parse.subject, body: html };
- }
-}
-const emailContentSchema = z
- .object({
- type: z.string(),
- content: z.array(z.lazy(() => emailContentSchema)).optional(),
- text: z.string().optional(),
- attr: z.record(z.unknown()).optional(),
- })
- .strict();
-
-const emailEditorSchema = z
- .object({
- type: z.string(),
- content: z.array(emailContentSchema).optional(),
- text: z.string().optional(),
- attr: z.record(z.unknown()).optional(),
- })
- .strict();
-
-export const EmailStepControlSchema = z
- .object({
- emailEditor: emailEditorSchema,
- subject: z.string(),
- })
- .strict();
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts
deleted file mode 100644
index 7383deffce2..00000000000
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/* eslint-disable no-param-reassign */
-import { TipTapNode } from '@novu/shared';
-import { ExpendEmailEditorSchemaCommand } from './expend-email-editor-schema-command';
-
-// Rename the class to ExpendEmailEditorSchemaUseCase
-export class ExpandEmailEditorSchemaUsecase {
- execute(command: ExpendEmailEditorSchemaCommand): TipTapNode {
- return this.expendSchema(command.schema);
- }
-
- private expendSchema(schema: TipTapNode): TipTapNode {
- // todo: try to avoid !
- const content = schema.content!.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[];
-
- return { ...schema, content };
- }
-
- private processItemNode(node: TipTapNode, item: any): TipTapNode {
- if (node.type === 'text' && typeof node.text === 'string') {
- const regex = /{#item\.(\w+)#}/g;
- node.text = node.text.replace(regex, (_, key: string) => {
- const propertyName = key;
-
- return item[propertyName] !== undefined ? item[propertyName] : _;
- });
- }
-
- if (node.content) {
- node.content = node.content.map((innerNode) => this.processItemNode(innerNode, item));
- }
-
- return node;
- }
-
- private processNodeRecursive(node: TipTapNode): TipTapNode | null {
- if (node.type === 'show') {
- const whenValue = node.attr?.when;
- if (whenValue !== 'true') {
- return null;
- }
- }
-
- if (this.hasEachAttr(node)) {
- return { type: 'section', content: this.handleFor(node) };
- }
-
- return this.processNodeContent(node);
- }
-
- private processNodeContent(node: TipTapNode): TipTapNode | null {
- if (node.content) {
- node.content = node.content.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[];
- }
-
- return node;
- }
-
- private hasEachAttr(node: TipTapNode): node is TipTapNode & { attr: { each: any } } {
- return node.attr !== undefined && node.attr.each !== undefined;
- }
-
- private handleFor(node: TipTapNode & { attr: { each: any } }): TipTapNode[] {
- const items = node.attr.each;
- const newContent: TipTapNode[] = [];
-
- const itemsParsed = JSON.parse(items.replace(/'/g, '"'));
- for (const item of itemsParsed) {
- const newNode = { ...node };
- newNode.content = newNode.content?.map((innerNode) => this.processItemNode(innerNode, item)) || [];
- if (newNode.content) {
- newContent.push(...newNode.content);
- }
- }
-
- return newContent;
- }
-}
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts
new file mode 100644
index 00000000000..ea8c2d09509
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts
@@ -0,0 +1,9 @@
+// Define the command interface
+
+import { BaseCommand } from '@novu/application-generic';
+import { FullPayloadForRender } from './render-command';
+
+export class ExpandEmailEditorSchemaCommand extends BaseCommand {
+ body: 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
new file mode 100644
index 00000000000..e12603fab99
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts
@@ -0,0 +1,170 @@
+/* eslint-disable no-param-reassign */
+import { TipTapNode } from '@novu/shared';
+import { Injectable } from '@nestjs/common';
+import { ExpandEmailEditorSchemaCommand } from './expand-email-editor-schema-command';
+import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase';
+
+@Injectable()
+export class ExpandEmailEditorSchemaUsecase {
+ constructor(private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase) {}
+
+ execute(command: ExpandEmailEditorSchemaCommand): TipTapNode {
+ const emailSchemaHydrated = this.hydrate(command);
+ this.processShowAndForControls(emailSchemaHydrated, undefined);
+
+ return emailSchemaHydrated;
+ }
+ private hydrate(command: ExpandEmailEditorSchemaCommand) {
+ const { hydratedEmailSchema } = this.hydrateEmailSchemaUseCase.execute({
+ emailEditor: command.body,
+ fullPayloadForRender: command.fullPayloadForRender,
+ });
+
+ return hydratedEmailSchema;
+ }
+
+ private processShowAndForControls(node: TipTapNode, parentNode?: TipTapNode) {
+ if (node.content) {
+ node.content.forEach((innerNode) => {
+ this.processShowAndForControls(innerNode, node);
+ });
+ }
+ if (this.hasShow(node)) {
+ this.hideShowIfNeeded(node, parentNode);
+ } else if (this.hasEach(node)) {
+ const newContent = this.expendedForEach(node);
+ node.content = newContent;
+ if (parentNode && parentNode.content) {
+ this.insertArrayAt(parentNode.content, parentNode.content.indexOf(node), newContent);
+ 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);
+ }
+
+ private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { each: unknown } } {
+ return !!(node.attrs && 'each' in node.attrs);
+ }
+
+ private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { show: string } } {
+ return !!(node.attrs && 'show' in node.attrs);
+ }
+
+ private regularExpansion(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);
+ }
+
+ 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 || [];
+
+ /*
+ * Due to maily limitations in the current implementation, the location of the for
+ * element is situated on the container of the list making the list a
+ * child of the for element, if we iterate it we will get the
+ * wrong behavior of multiple lists instead of list with multiple items.
+ * due to that when we choose the content to iterate in case we find a list we drill down additional level
+ * and iterate on the list items
+ * this prevents us from
+ * 1. item1
+ * 1. item2
+ *
+ * and turns it into
+ * 1.item1
+ * 2.item2
+ * which is the correct behavior
+ *
+ */
+ if ((this.isOrderedList(templateContent) || this.isBulletList(templateContent)) && templateContent[0].content) {
+ return [{ ...templateContent[0], content: this.regularExpansion(eachObject, templateContent[0].content) }];
+ }
+
+ return this.regularExpansion(eachObject, templateContent);
+ }
+
+ private removeNodeFromParent(node: TipTapNode, parentNode?: TipTapNode) {
+ if (parentNode && parentNode.content) {
+ parentNode.content.splice(parentNode.content.indexOf(node), 1);
+ }
+ }
+
+ 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 stringToBoolean(value: unknown): boolean {
+ if (typeof value === 'string') {
+ return value.toLowerCase() === 'true';
+ }
+
+ return false;
+ }
+
+ private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { id: string } } {
+ return newNode.type === 'payloadValue' && newNode.attrs?.id !== undefined;
+ }
+
+ 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];
+ }
+
+ 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
deleted file mode 100644
index 5231f047132..00000000000
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// Define the command interface
-
-import { BaseCommand } from '@novu/application-generic';
-import { TipTapNode } from '@novu/shared';
-
-// eslint-disable-next-line @typescript-eslint/naming-convention
-export interface ExpendEmailEditorSchemaCommand extends BaseCommand {
- schema: TipTapNode;
-}
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/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..ade4d70210b
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts
@@ -0,0 +1,8 @@
+// New HydrateEmailSchemaUseCase class
+
+import { FullPayloadForRender } from './render-command';
+
+export class HydrateEmailSchemaCommand {
+ emailEditor: string;
+ fullPayloadForRender: 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
new file mode 100644
index 00000000000..386caa79277
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts
@@ -0,0 +1,211 @@
+/* eslint-disable no-param-reassign */
+import { Injectable } from '@nestjs/common';
+import { PreviewPayload, TipTapNode } from '@novu/shared';
+import { z } from 'zod';
+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.fullPayloadForRender);
+ }
+
+ return { hydratedEmailSchema: emailEditorSchema, nestedPayload: this.flattenToNested(defaultPayload) };
+ }
+
+ private variableLogic(
+ masterPayload: PreviewPayload,
+ 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: PreviewPayload,
+ 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: PreviewPayload,
+ 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: PreviewPayload
+ ) {
+ 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: PreviewPayload, node) {
+ const resolvedValue = this.getValueByPath(masterPayload, node.attrs.id);
+ const { fallback } = node.attrs;
+
+ return resolvedValue || fallback || `{{${node.attrs.id}}}`;
+ }
+
+ private getResolvedValueShowPlaceholder(masterPayload: PreviewPayload, 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: PreviewPayload,
+ 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) {
+ const nextObj = currentObj[key];
+
+ return nextObj;
+ }
+
+ 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..8f220cfd907 100644
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts
@@ -1,7 +1,9 @@
export * from './chat-output-renderer.usecase';
-export * from './email-output-renderer.usecase';
export * from './render-command';
export * from './push-output-renderer.usecase';
export * from './sms-output-renderer.usecase';
export * from './in-app-output-renderer.usecase';
-export * from './email-schema-expander.usecase';
+export * from './render-email-output.usecase';
+export * from './hydrate-email-schema.usecase';
+export * from './hydrate-email-schema.command';
+export * from './expand-email-editor-schema.usecase';
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts
index c6b569963d2..97d89926d3d 100644
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts
@@ -2,4 +2,10 @@ import { BaseCommand } from '@novu/application-generic';
export class RenderCommand extends BaseCommand {
controlValues: Record;
+ fullPayloadForRender: FullPayloadForRender;
+}
+export class FullPayloadForRender {
+ subscriber: Record;
+ payload: Record;
+ steps: Record; // step.stepId.unknown
}
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
new file mode 100644
index 00000000000..790fbfceb79
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts
@@ -0,0 +1,33 @@
+import { EmailRenderOutput } from '@novu/shared';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import { render } from '@maily-to/render';
+import { FullPayloadForRender, RenderCommand } from './render-command';
+import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase';
+
+export class RenderEmailOutputCommand extends RenderCommand {}
+
+@Injectable()
+export class RenderEmailOutputUsecase {
+ constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {}
+
+ async execute(renderCommand: RenderEmailOutputCommand): Promise {
+ const { emailEditor, subject } = EmailStepControlSchema.parse(renderCommand.controlValues);
+ console.log('renderCommand.fullPayloadForRender', renderCommand.fullPayloadForRender);
+ const expandedSchema = this.transformForAndShowLogic(emailEditor, renderCommand.fullPayloadForRender);
+ const htmlRendered = await render(expandedSchema);
+
+ return { subject, body: htmlRendered };
+ }
+
+ private transformForAndShowLogic(body: string, fullPayloadForRender: FullPayloadForRender) {
+ return this.expendEmailEditorSchemaUseCase.execute({ body, fullPayloadForRender });
+ }
+}
+
+export const EmailStepControlSchema = z
+ .object({
+ emailEditor: z.string(),
+ subject: z.string(),
+ })
+ .strict();
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 0b92ac8ebd5..accd1238d81 100644
--- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
+++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
@@ -5,17 +5,22 @@ import { after, beforeEach } from 'mocha';
import { sleep } from '@nestjs/terminus/dist/utils';
import {
ChannelTypeEnum,
+ createWorkflowClient,
EmailStepControlSchemaDto,
GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
+ HttpError,
+ NovuRestResult,
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 { forSnippet, 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;
@@ -53,24 +58,119 @@ 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 }) => {
it(`${type}:should match the body in the preview response`, async () => {
- const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(type);
- const requestDto = buildDtoNoPayload(type);
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(type);
+ const requestDto = buildDtoNoPayload(type, stepId);
const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, description);
expect(previewResponseDto.result!.preview).to.exist;
expect(previewResponseDto.issues).to.exist;
+ expect(previewResponseDto.previewPayloadExample).to.exist;
+ expect(previewResponseDto.previewPayloadExample.subscriber, 'Expecting to find subscriber in the payload').to
+ .exist;
if (type !== StepTypeEnum.EMAIL) {
- expect(previewResponseDto.result!.preview).to.deep.equal(stepTypeTo[type]);
+ expect(previewResponseDto.result!.preview).to.deep.equal(getControlValues(stepId)[type]);
} else {
assertEmail(previewResponseDto);
}
});
});
});
+ describe('email specific features', () => {
+ describe('show', () => {
+ it('show -> should hide element based on payload', async () => {
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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 { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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 { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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 { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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('for', () => {
+ it('should populate for if payload exist with actual values', async () => {
+ const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const name1 = 'ball is round';
+ const name2 = 'square is square';
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: buildSimpleForEmail() as unknown as Record,
+ previewPayload: { payload: { food: { items: [{ name: name1 }, { name: name2 }] } } },
+ },
+ 'email'
+ );
+ expect(previewResponseDto.result!.preview).to.exist;
+ const preview = previewResponseDto.result!.preview.body;
+ expect(preview).not.to.contain('{{item.name}}1');
+ expect(preview).not.to.contain('{{item.name}}2');
+ expect(preview).to.contain(name1);
+ expect(preview).to.contain(name2);
+ });
+ });
+ });
+
describe('Missing Required ControlValues', () => {
const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }];
@@ -104,8 +204,14 @@ describe('Generate Preview', () => {
): Promise {
const novuRestResult = await workflowsClient.generatePreview(workflowId, stepDatabaseId, dto);
if (novuRestResult.isSuccessResult()) {
+ console.log(
+ 'previewResponseDto.exampleMasterPayload',
+ JSON.stringify(novuRestResult.value.previewPayloadExample)
+ );
+
return novuRestResult.value;
}
+
throw await assertHttpError(description, novuRestResult);
}
@@ -117,26 +223,29 @@ describe('Generate Preview', () => {
throw new Error(`Failed to create workflow ${JSON.stringify(workflowResult.error)}`);
}
- return { workflowId: workflowResult.value._id, stepDatabaseId: workflowResult.value.steps[0]._id };
+ return {
+ workflowId: workflowResult.value._id,
+ stepDatabaseId: workflowResult.value.steps[0]._id,
+ stepId: workflowResult.value.steps[0].stepId,
+ };
}
});
-function buildDtoNoPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
+function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
return {
- validationStrategies: [],
- controlValues: stepTypeTo[stepTypeEnum],
+ controlValues: getControlValues(stepId)[stepTypeEnum],
};
}
+
function buildDtoWithPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
return {
- validationStrategies: [],
- controlValues: stepTypeTo[stepTypeEnum],
- payloadValues: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE },
+ controlValues: getControlValues[stepTypeEnum],
+ previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } },
};
}
function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
- const stepTypeToElement = stepTypeTo[stepTypeEnum];
+ const stepTypeToElement = getControlValues[stepTypeEnum];
if (stepTypeEnum === StepTypeEnum.EMAIL) {
delete stepTypeToElement.subject;
} else {
@@ -144,75 +253,26 @@ function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GenerateP
}
return {
- validationStrategies: [],
controlValues: stepTypeToElement,
- payloadValues: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE },
+ previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } },
};
}
-const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}';
-
-const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}';
-const PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE = 'this is the replacement text for the placeholder';
-function mailyJsonExample(): TipTapNode {
+function buildEmailControlValuesPayload(stepId: string): EmailStepControlSchemaDto {
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,
- },
- ],
- },
- ],
- },
- ],
+ subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`,
+ emailEditor: JSON.stringify(fullCodeSnippet(stepId)),
};
}
-function buildEmailControlValuesPayload(): EmailStepControlSchemaDto {
+function buildSimpleForEmail(): EmailStepControlSchemaDto {
return {
subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`,
- emailEditor: JSON.stringify(mailyJsonExample()),
+ emailEditor: JSON.stringify(forSnippet),
};
}
function buildInAppControlValues(): InAppOutput {
return {
- subject: `Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`,
+ subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`,
body: 'Hello, World! {{payload.placeholder.body}}',
avatar: 'https://www.example.com/avatar.png',
primaryAction: {
@@ -241,31 +301,30 @@ function buildInAppControlValues(): InAppOutput {
function buildSmsControlValuesPayload() {
return {
- body: 'Hello, World!',
+ body: 'Hello, World! {{subscriber.firstName}}',
};
}
function buildPushControlValuesPayload() {
return {
subject: 'Hello, World!',
- body: 'Hello, World!',
+ body: 'Hello, World! {{subscriber.firstName}}',
};
}
function buildChatControlValuesPayload() {
return {
- body: 'Hello, World!',
+ body: 'Hello, World! {{subscriber.firstName}}',
};
}
-const FOR_ITEM_VALUE_PLACEHOLDER = '{#item.body#}';
-const TEST_SHOW_VALUE = 'TEST_SHOW_VALUE';
-const stepTypeTo = {
+
+const getControlValues = (stepId: string) => ({
[StepTypeEnum.SMS]: buildSmsControlValuesPayload(),
- [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload() as unknown as Record,
+ [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(stepId) as unknown as Record,
[StepTypeEnum.PUSH]: buildPushControlValuesPayload(),
[StepTypeEnum.CHAT]: buildChatControlValuesPayload(),
[StepTypeEnum.IN_APP]: buildInAppControlValues(),
-};
+});
async function assertHttpError(
description: string,
@@ -282,8 +341,15 @@ 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('{{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..c0452241f44
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/maily-test-data.ts
@@ -0,0 +1,570 @@
+export const forSnippet = {
+ type: 'doc',
+ content: [
+ {
+ type: 'for',
+ attrs: {
+ each: 'payload.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: 'payload.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: ' ',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+export const fullCodeSnippet = (stepId) => ({
+ 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 emaly.',
+ },
+ ],
+ },
+ {
+ 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: 'variable',
+ attrs: {
+ id: 'subscriber.fullName',
+ label: null,
+ fallback: 'should be the fallback value',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ 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: '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: `steps.${stepId}.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: 'payload.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: 'payload.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: 'payload.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/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
new file mode 100644
index 00000000000..770b7fd0677
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts
@@ -0,0 +1,159 @@
+/* eslint-disable no-param-reassign */
+import { Injectable } from '@nestjs/common';
+import { ControlPreviewIssue, ControlPreviewIssueTypeEnum, PreviewPayload } 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> = {};
+ 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, { payload: command.payloadValues }),
+ issues: this.buildVariableMissingIssueRecord(
+ aggregatedDefaultValuesForControl,
+ aggregatedDefaultValues,
+ command.payloadValues
+ ),
+ };
+ }
+
+ 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: 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/build-payload-from-placeholder/index.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts
new file mode 100644
index 00000000000..d2e73ea7104
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts
@@ -0,0 +1 @@
+export * from './build-default-payload-use-case.service';
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
index 9277d118eed..d17f8bff2e9 100644
--- 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
@@ -1,5 +1,5 @@
-import { GeneratePreviewRequestDto } from '@novu/shared';
import { EnvironmentWithUserObjectCommand } from '@novu/application-generic';
+import { GeneratePreviewRequestDto } from '@novu/shared/dist/cjs/dto/workflows/generate-preview-request.dto';
export class GeneratePreviewCommand extends EnvironmentWithUserObjectCommand {
workflowId: string;
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 65fb5fe7bdf..27b9ad5c577 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,52 +4,66 @@ import {
ControlPreviewIssue,
ControlPreviewIssueTypeEnum,
ControlSchemas,
- GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
+ JobStatusEnum,
JSONSchemaDto,
+ PreviewPayload,
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 { CreateMockPayloadUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase';
import { StepMissingControlsException, StepNotFoundException } from '../../exceptions/step-not-found-exception';
import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
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';
@Injectable()
export class GeneratePreviewUsecase {
constructor(
private legacyPreviewStepUseCase: PreviewStep,
private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase,
- private createMockPayloadUseCase: CreateMockPayloadUseCase,
- private extractDefaultsUseCase: ExtractDefaultsUsecase
+ private extractDefaultsUseCase: ExtractDefaultsUsecase,
+ private constructPayloadUseCase: BuildDefaultPayloadUseCase
) {}
async execute(command: GeneratePreviewCommand): Promise {
- const payloadHydrationInfo = this.payloadHydrationLogic(command);
+ const payloadInfo = this.buildPayloadWithDefaults(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,
+ payloadInfo.previewPayload,
controlValuesResult.augmentedControlValues,
command
);
return buildResponse(
controlValuesResult.issuesMissingValues,
- payloadHydrationInfo.issues,
+ payloadInfo.issues,
executeOutput,
- workflowInfo.stepType
+ workflowInfo.stepType,
+ payloadInfo.previewPayload
);
}
+ private buildPayloadWithDefaults(command: GeneratePreviewCommand) {
+ const dto = command.generatePreviewRequestDto;
+ const { previewPayload, issues } = this.constructPayloadUseCase.execute({
+ controlValues: dto.controlValues,
+ payloadValues: dto.previewPayload,
+ });
+
+ return { previewPayload, issues };
+ }
+
+ 3;
private addMissingValuesToControlValues(command: GeneratePreviewCommand, stepControlSchema: ControlSchemas) {
const defaultValues = this.extractDefaultsUseCase.execute({
jsonSchemaDto: stepControlSchema.schema as JSONSchemaDto,
@@ -62,7 +76,7 @@ export class GeneratePreviewUsecase {
}
private buildMissingControlValuesIssuesList(defaultValues: Record, command: GeneratePreviewCommand) {
- const missingRequiredControlValues = this.findMissingKeys(
+ const missingRequiredControlValues = findMissingKeys(
defaultValues,
command.generatePreviewRequestDto.controlValues || {}
);
@@ -77,41 +91,19 @@ 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 | undefined,
origin: WorkflowOriginEnum | undefined,
- hydratedPayload: Record,
+ hydratedPayload: PreviewPayload,
updatedControlValues: Record,
command: GeneratePreviewCommand
) {
@@ -122,9 +114,13 @@ export class GeneratePreviewUsecase {
throw new OriginMissingException(stepId);
}
+ const state = buildState(hydratedPayload.steps);
+ console.log('state', JSON.stringify(state, null, 2));
+
return await this.legacyPreviewStepUseCase.execute(
PreviewStepCommand.create({
- payload: hydratedPayload,
+ payload: hydratedPayload.payload || {},
+ subscriber: hydratedPayload.subscriber,
controls: updatedControlValues || {},
environmentId: command.user.environmentId,
organizationId: command.user.organizationId,
@@ -132,6 +128,7 @@ export class GeneratePreviewUsecase {
userId: command.user._id,
workflowId,
workflowOrigin: origin,
+ state,
})
);
}
@@ -158,141 +155,61 @@ export class GeneratePreviewUsecase {
origin: persistedWorkflow.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(
missingValuesIssue: Record,
missingPayloadVariablesIssue: Record,
executionOutput,
- stepType: StepTypeEnum
+ stepType: StepTypeEnum,
+ augmentedPayload: PreviewPayload
): GeneratePreviewResponseDto {
return {
issues: merge(missingValuesIssue, missingPayloadVariablesIssue),
result: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
preview: executionOutput.outputs as any,
type: stepType as unknown as ChannelTypeEnum,
},
+ previewPayloadExample: augmentedPayload,
};
}
-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 findMissingKeys(requiredRecord: Record, actualRecord: Record): string[] {
+ const requiredKeys = collectKeys(requiredRecord);
+ const actualKeys = collectKeys(actualRecord);
-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
+ return _.difference(requiredKeys, actualKeys);
+}
- 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);
+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 {
- // Push the dot notation key to the keys array
- keys.push(newKey);
+ result.push(newKey);
}
- }
- }
- return keys;
+ return result;
+ },
+ [] // Pass an empty array as the initial value
+ );
}
-function flattenJson(obj, parentKey = '', result = {}) {
- // eslint-disable-next-line guard-for-in
- for (const key in obj) {
- // Construct the new key using dot notation
- const newKey = parentKey ? `${parentKey}.${key}` : key;
-
- // Check if the value is an object (and not null or an array)
- if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
- // Recursively flatten the object
- flattenJson(obj[key], newKey, result);
- } else if (Array.isArray(obj[key])) {
- // Handle arrays by flattening each item
- obj[key].forEach((item, index) => {
- const arrayKey = `${newKey}[${index}]`;
- if (typeof item === 'object' && item !== null) {
- flattenJson(item, arrayKey, result);
- } else {
- // eslint-disable-next-line no-param-reassign
- result[arrayKey] = item;
- }
- });
- } else {
- // Assign the value to the result with the new key
- // eslint-disable-next-line no-param-reassign
- result[newKey] = obj[key];
- }
+function buildState(steps: Record | undefined): FrameworkPreviousStepsOutputState[] {
+ const outputArray: FrameworkPreviousStepsOutputState[] = [];
+ for (const [stepId, value] of Object.entries(steps || {})) {
+ outputArray.push({
+ stepId,
+ outputs: value as Record,
+ state: {
+ status: JobStatusEnum.COMPLETED,
+ },
+ });
}
- return result;
+ return outputArray;
}
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
new file mode 100644
index 00000000000..966bc3e6722
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts
@@ -0,0 +1,29 @@
+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/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-defaults-engine-failure.exception.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts
new file mode 100644
index 00000000000..40787c160e6
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts
@@ -0,0 +1,7 @@
+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
index f27123948e3..114c49bce77 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,19 +1,19 @@
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';
+import {
+ BuildPayloadNestedStructureCommand,
+ BuildPayloadNestedStructureUsecase,
+} from './buildPayloadNestedStructureUsecase';
+import { PayloadDefaultsEngineFailureException } from './payload-defaults-engine-failure.exception';
+const unsupportedPrefixes: string[] = ['actor', 'steps'];
@Injectable()
-export class CreateMockPayloadUseCase {
+export class CreateMockPayloadForSingleControlValueUseCase {
constructor(
- private readonly collectPlaceholdersFromTipTapSchemaUsecase: CollectPlaceholdersFromTipTapSchemaUsecase,
- private readonly transformPlaceholderMapUseCase: TransformPlaceholderMapUseCase
+ private readonly transformPlaceholderMapUseCase: BuildPayloadNestedStructureUsecase,
+ private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase
) {}
-
public execute(command: AddKeysToPayloadBasedOnHydrationStrategyCommand): Record {
const { controlValues, controlValueKey } = command;
@@ -22,33 +22,68 @@ export class CreateMockPayloadUseCase {
}
const controlValue = controlValues[controlValueKey];
- if (typeof controlValue === 'object') {
- return this.buildPayloadForEmailEditor(controlValue);
+ const payloadFromEmailSchema = this.safeAttemptToParseEmailSchema(controlValue);
+ if (payloadFromEmailSchema) {
+ return payloadFromEmailSchema;
}
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,
+ fullPayloadForRender: {
+ payload: {},
+ subscriber: {},
+ steps: {},
+ },
+ });
- return transformPlaceholderMap.payload;
+ return nestedPayload;
+ } catch (e) {
+ return undefined;
+ }
}
private buildPayloadForRegularText(controlValue: unknown) {
- const strings = extractPlaceholders(controlValue as string).filter(
- (placeholder) => !placeholder.startsWith('subscriber') && !placeholder.startsWith('actor')
+ const placeholders = extractPlaceholders(controlValue).filter(
+ (placeholder) => !unsupportedPrefixes.some((prefix) => placeholder.startsWith(prefix))
);
- return this.transformPlaceholderMapUseCase.execute({
- input: { regular: convertToRecord(strings) },
- }).payload;
+ 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) => {
diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts
deleted file mode 100644
index 7000216ab19..00000000000
--- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { PlaceholderMap } from './collect-placeholders-from-tip-tap-schema.usecase';
-
-export class TransformPlaceholderMapCommand {
- input: PlaceholderMap;
-}
-
-export class TransformPlaceholderMapUseCase {
- public execute(command: TransformPlaceholderMapCommand): Record {
- const defaultPayload: Record = {};
-
- const setNestedValue = (obj: Record, path: string, value: any) => {
- const keys = path.split('.');
- let current = obj;
-
- keys.forEach((key, index) => {
- if (!current.hasOwnProperty(key)) {
- current[key] = index === keys.length - 1 ? value : {};
- }
- current = current[key];
- });
- };
-
- this.processFor(command.input, setNestedValue, defaultPayload);
-
- for (const key in command.input.show) {
- if (command.input.show.hasOwnProperty(key)) {
- setNestedValue(defaultPayload, key, 'true');
- }
- }
-
- for (const key in command.input.regular) {
- if (command.input.regular.hasOwnProperty(key)) {
- setNestedValue(defaultPayload, key, `{{${key}}}`);
- }
- }
-
- return defaultPayload;
- }
-
- private processFor(
- input: PlaceholderMap,
- setNestedValue: (obj: Record, path: string, value: any) => void,
- defaultPayload: Record
- ) {
- for (const key in input.for) {
- if (input.for.hasOwnProperty(key)) {
- const items = input.for[key];
- const finalValue = [{}, {}];
- setNestedValue(defaultPayload, key, finalValue);
- items.forEach((item) => {
- const extractedKey = item.replace('item.', '');
- // TODO: extract to const
- const valueFunc = (suffix) => `{#${item}#}-${suffix}`;
- setNestedValue(finalValue[0], extractedKey, valueFunc('1'));
- setNestedValue(finalValue[1], extractedKey, valueFunc('2'));
- });
- }
- }
- }
-}
diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
index 42e4b88c0c5..961af38c516 100644
--- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
+++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
@@ -2,6 +2,7 @@ import { expect } from 'chai';
import { UserSession } from '@novu/testing';
import { randomBytes } from 'crypto';
import {
+ createWorkflowClient,
CreateWorkflowDto,
DEFAULT_WORKFLOW_PREFERENCES,
isStepUpdateBody,
@@ -22,7 +23,6 @@ import {
WorkflowListResponseDto,
WorkflowResponseDto,
} from '@novu/shared';
-import { createWorkflowClient } from './clients';
import { encodeBase62 } from '../shared/helpers';
import { stepTypeToDefaultDashboardControlSchema } from './shared';
@@ -73,21 +73,7 @@ describe('Workflow Controller E2E API Testing', () => {
if (res.isSuccessResult()) {
const workflowCreated: WorkflowResponseDto = res.value;
expect(workflowCreated.workflowId).to.include(`${slugify(nameSuffix)}-`);
- for (const step of workflowCreated.steps) {
- const stepDataDto = await getStepData(workflowCreated._id, step._id);
- expect(stepDataDto).to.be.ok;
- expect(stepDataDto.controls).to.be.ok;
- if (stepDataDto.controls) {
- expect(stepDataDto.controls.values).to.be.ok;
- expect(stepDataDto.controls.dataSchema).to.be.ok;
- expect(stepDataDto.controls.dataSchema).to.deep.equal(
- stepTypeToDefaultDashboardControlSchema[step.type].schema
- );
- expect(stepDataDto.controls.uiSchema).to.deep.equal(
- stepTypeToDefaultDashboardControlSchema[step.type].uiSchema
- );
- }
- }
+ await assertValuesInSteps(workflowCreated);
}
});
});
@@ -529,6 +515,23 @@ describe('Workflow Controller E2E API Testing', () => {
}
expect(convertToDate(updatedWorkflow.updatedAt)).to.be.greaterThan(convertToDate(expectedPastUpdatedAt));
}
+ async function assertValuesInSteps(workflowCreated: WorkflowResponseDto) {
+ for (const step of workflowCreated.steps) {
+ const stepDataDto = await getStepData(workflowCreated._id, step._id);
+ expect(stepDataDto).to.be.ok;
+ expect(stepDataDto.controls).to.be.ok;
+ if (stepDataDto.controls) {
+ expect(stepDataDto.controls.values).to.be.ok;
+ expect(stepDataDto.controls.dataSchema).to.be.ok;
+ expect(stepDataDto.controls.dataSchema).to.deep.equal(
+ stepTypeToDefaultDashboardControlSchema[step.type].schema
+ );
+ expect(stepDataDto.controls.uiSchema).to.deep.equal(
+ stepTypeToDefaultDashboardControlSchema[step.type].uiSchema
+ );
+ }
+ }
+ }
});
async function createWorkflowAndValidate(nameSuffix: string = ''): Promise {
diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts
index aff3b6bb9fd..c70cb692e99 100644
--- a/apps/api/src/app/workflows-v2/workflow.module.ts
+++ b/apps/api/src/app/workflows-v2/workflow.module.ts
@@ -20,12 +20,13 @@ import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-work
import { SyncToEnvironmentUseCase } from './usecases/sync-to-environment/sync-to-environment.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';
+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 { BuildPayloadNestedStructureUsecase } from './usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase';
+import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeholder/build-default-payload-use-case.service';
@Module({
imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule],
@@ -44,12 +45,12 @@ import { GetStepDataUsecase } from './usecases/get-step-schema/get-step-data.use
SyncToEnvironmentUseCase,
GetStepDataUsecase,
GeneratePreviewUsecase,
- GetWorkflowUseCase,
- CreateMockPayloadUseCase,
+ CreateMockPayloadForSingleControlValueUseCase,
ExtractDefaultsUsecase,
- CollectPlaceholdersFromTipTapSchemaUsecase,
- TransformPlaceholderMapUseCase,
+ BuildPayloadNestedStructureUsecase,
WorkflowTestDataUseCase,
+ BuildDefaultPayloadUseCase,
+ HydrateEmailSchemaUseCase,
],
})
export class WorkflowModule implements NestModule {
diff --git a/apps/api/src/exception-filter.ts b/apps/api/src/exception-filter.ts
index c5ece85e5ff..fe562cef6ff 100644
--- a/apps/api/src/exception-filter.ts
+++ b/apps/api/src/exception-filter.ts
@@ -73,6 +73,12 @@ export class AllExceptionsFilter implements ExceptionFilter {
let status: number;
let message: string | object;
+ if (exception instanceof ZodError) {
+ return handleZod(exception);
+ }
+ if (this.isMongoError(exception)) {
+ return handleMongo(exception);
+ }
if (exception instanceof ZodError) {
return handleZod(exception);
}
@@ -93,6 +99,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
};
}
+ private isMongoError(exception: unknown): exception is MongoServerError {
+ return (
+ typeof exception === 'object' &&
+ exception !== null &&
+ 'type' in exception &&
+ (exception as { type: string }).type === 'MongoServerError'
+ );
+ }
private getUuid(exception: unknown) {
if (process.env.SENTRY_DSN) {
try {
@@ -122,7 +136,7 @@ export class ErrorDto {
message: string | object;
}
-function handleZod(exception: ZodError) {
+function handleZod(exception: ZodError) {
const status = HttpStatus.BAD_REQUEST; // Set appropriate status for ZodError
const message = {
errors: exception.errors.map((err) => ({
@@ -133,6 +147,17 @@ function handleZod(exception: ZodError) {
return { status, message };
}
+function handleMongo(exception: MongoServerError) {
+ const status = HttpStatus.INTERNAL_SERVER_ERROR; // Set appropriate status for ZodError
+
+ let msg = `MongoDB Error: Code: ${exception.code}, Message: ${exception.errmsg}`;
+ if (exception.writeErrors) {
+ msg += `, Write Errors: ${exception.writeErrors.map((err) => `Index: ${err.index}, Message: ${err.errmsg}`).join('; ')}`;
+ }
+ msg += `, Operation Time: ${exception.operationTime || 'N/A'}, Cluster Time: ${exception.clusterTime || 'N/A'}`;
+
+ return { status, message: msg };
+}
function handleCommandValidation(exception: CommandValidationException) {
const { mappedErrors } = exception;
@@ -140,3 +165,16 @@ function handleCommandValidation(exception: CommandValidationException) {
return { message: { message, cause: mappedErrors }, status: HttpStatus.BAD_REQUEST };
}
+class MongoServerError {
+ code: number;
+ errmsg: string;
+ ok: number;
+ writeErrors?: {
+ index: number;
+ code: number;
+ errmsg: string;
+ op: any;
+ }[];
+ operationTime?: string;
+ clusterTime?: string;
+}
diff --git a/apps/api/src/app/workflows-v2/clients/index.ts b/packages/shared/src/clients/index.ts
similarity index 100%
rename from apps/api/src/app/workflows-v2/clients/index.ts
rename to packages/shared/src/clients/index.ts
diff --git a/apps/api/src/app/workflows-v2/clients/novu-base-client.ts b/packages/shared/src/clients/novu-base-client.ts
similarity index 100%
rename from apps/api/src/app/workflows-v2/clients/novu-base-client.ts
rename to packages/shared/src/clients/novu-base-client.ts
diff --git a/apps/api/src/app/workflows-v2/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts
similarity index 97%
rename from apps/api/src/app/workflows-v2/clients/workflows-client.ts
rename to packages/shared/src/clients/workflows-client.ts
index 529f90bd175..55a20ac23b0 100644
--- a/apps/api/src/app/workflows-v2/clients/workflows-client.ts
+++ b/packages/shared/src/clients/workflows-client.ts
@@ -1,16 +1,16 @@
+import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client';
import {
CreateWorkflowDto,
- GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
GetListQueryParams,
ListWorkflowResponse,
- SyncWorkflowDto,
StepDataDto,
+ SyncWorkflowDto,
UpdateWorkflowDto,
WorkflowResponseDto,
WorkflowTestDataResponseDto,
-} from '@novu/shared';
-import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client';
+} from '../dto';
+import { GeneratePreviewRequestDto } from '../dto/workflows/generate-preview-request.dto';
// Define the WorkflowClient as a function that utilizes the base client
export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) => {
diff --git a/packages/shared/src/dto/index.ts b/packages/shared/src/dto/index.ts
index 67c6d22f09b..78c02ec3498 100644
--- a/packages/shared/src/dto/index.ts
+++ b/packages/shared/src/dto/index.ts
@@ -13,5 +13,4 @@ export * from './workflow-override';
export * from './widget';
export * from './session';
export * from './subscription';
-export * from './step-schemas';
export * from './controls';
diff --git a/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts b/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts
deleted file mode 100644
index 3199add41f3..00000000000
--- a/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export enum ControlPreviewIssueTypeEnum {
- MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD',
- VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH',
- MISSING_VALUE = 'MISSING_VALUE',
-}
diff --git a/packages/shared/src/dto/step-schemas/control-schemas.ts b/packages/shared/src/dto/step-schemas/control-schemas.ts
deleted file mode 100644
index ad7435c7909..00000000000
--- a/packages/shared/src/dto/step-schemas/control-schemas.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/* eslint-disable @typescript-eslint/naming-convention */
-
-export interface TipTapNode {
- type: string;
- content?: TipTapNode[];
- text?: string;
- attr?: Record;
-}
-
-export interface EmailStepControlSchemaDto {
- emailEditor: string;
- subject: string;
-}
diff --git a/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts b/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts
deleted file mode 100644
index 866a11ad8f2..00000000000
--- a/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { ChannelTypeEnum } from '../../types';
-import { ControlPreviewIssueTypeEnum } from './control-preview-issue-type.enum';
-
-export class RenderOutput {}
-
-export class ChatRenderOutput extends RenderOutput {
- body: string;
-}
-
-export class SmsRenderOutput extends RenderOutput {
- body: string;
-}
-
-export class PushRenderOutput extends RenderOutput {
- subject: string;
- body: string;
-}
-
-export class EmailRenderOutput extends RenderOutput {
- subject: string;
- body: string;
-}
-
-export enum RedirectTargetEnum {
- SELF = '_self',
- BLANK = '_blank',
- PARENT = '_parent',
- TOP = '_top',
- UNFENCED_TOP = '_unfencedTop',
-}
-
-export class InAppRenderOutput extends RenderOutput {
- subject?: string;
- body: string;
- avatar?: string;
- primaryAction?: {
- label: string;
- redirect: {
- url: string;
- target?: RedirectTargetEnum;
- };
- };
- secondaryAction?: {
- label: string;
- redirect: {
- url: string;
- target?: RedirectTargetEnum;
- };
- };
- data?: Record;
- redirect?: {
- url: string;
- target?: RedirectTargetEnum;
- };
-}
-
-export class ControlPreviewIssue {
- issueType: ControlPreviewIssueTypeEnum;
- variableName?: string;
- message: string;
-}
-
-export class GeneratePreviewResponseDto {
- issues: Record;
- result?:
- | {
- type: ChannelTypeEnum.EMAIL;
- preview: EmailRenderOutput;
- }
- | {
- type: ChannelTypeEnum.IN_APP;
- preview: InAppRenderOutput;
- }
- | {
- type: ChannelTypeEnum.SMS;
- preview: SmsRenderOutput;
- }
- | {
- type: ChannelTypeEnum.PUSH;
- preview: PushRenderOutput;
- }
- | {
- type: ChannelTypeEnum.CHAT;
- preview: ChatRenderOutput;
- };
-}
diff --git a/packages/shared/src/dto/step-schemas/index.ts b/packages/shared/src/dto/step-schemas/index.ts
deleted file mode 100644
index d9190b172df..00000000000
--- a/packages/shared/src/dto/step-schemas/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from './generate-preview-request.dto';
-export * from './generate-preview-response.dto';
-export * from './control-schemas';
-export * from './json-schema-dto';
-export * from './control-preview-issue-type.enum';
diff --git a/packages/shared/src/dto/workflows/control-schemas.ts b/packages/shared/src/dto/workflows/control-schemas.ts
new file mode 100644
index 00000000000..998fa944e7b
--- /dev/null
+++ b/packages/shared/src/dto/workflows/control-schemas.ts
@@ -0,0 +1,33 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { JSONSchemaDto } from './json-schema-dto';
+
+export interface TipTapNode {
+ type?: string;
+ attrs?: Record;
+ content?: TipTapNode[];
+ marks?: {
+ type: string;
+ attrs?: Record;
+ [key: string]: any;
+ }[];
+ text?: string;
+ [key: string]: any;
+}
+export interface EmailStepControlSchemaDto {
+ emailEditor: string;
+ subject: string;
+}
+
+export const EmailStepControlSchema: JSONSchemaDto = {
+ type: 'object',
+ properties: {
+ emailEditor: {
+ type: 'string',
+ },
+ subject: {
+ type: 'string',
+ },
+ },
+ required: ['emailEditor', 'subject'],
+ additionalProperties: false,
+};
diff --git a/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts b/packages/shared/src/dto/workflows/generate-preview-request.dto.ts
similarity index 65%
rename from packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts
rename to packages/shared/src/dto/workflows/generate-preview-request.dto.ts
index b8cff65e6bf..592a0c9ce96 100644
--- a/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts
+++ b/packages/shared/src/dto/workflows/generate-preview-request.dto.ts
@@ -1,4 +1,4 @@
-import { JSONSchemaDto } from './json-schema-dto';
+import { PreviewPayload } from './preview-step-response.dto';
export enum ValidationStrategyEnum {
VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION = 'VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION',
@@ -9,9 +9,7 @@ export enum ValidationStrategyEnum {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface GeneratePreviewRequestDto {
controlValues?: Record; // Optional control values
- payloadValues?: Record; // Optional payload values
- variablesSchema?: JSONSchemaDto; // Optional variables schema
- validationStrategies?: ValidationStrategyEnum[]; // Array of validation strategies
+ previewPayload?: PreviewPayload; // Optional payload values
}
// Export the GeneratePreviewRequestDto type
diff --git a/packages/shared/src/dto/workflows/index.ts b/packages/shared/src/dto/workflows/index.ts
index ee631adad42..f21be1f4d29 100644
--- a/packages/shared/src/dto/workflows/index.ts
+++ b/packages/shared/src/dto/workflows/index.ts
@@ -12,3 +12,7 @@ export * from './workflow-status-enum';
export * from './get-list-query-params';
export * from './workflow-test-data-response-dto';
export * from './step-data.dto';
+export * from './preview-step-response.dto';
+export * from './generate-preview-request.dto';
+export * from './control-schemas';
+export * from './json-schema-dto';
diff --git a/packages/shared/src/dto/step-schemas/json-schema-dto.ts b/packages/shared/src/dto/workflows/json-schema-dto.ts
similarity index 100%
rename from packages/shared/src/dto/step-schemas/json-schema-dto.ts
rename to packages/shared/src/dto/workflows/json-schema-dto.ts
diff --git a/packages/shared/src/dto/workflows/preview-step-response.dto.ts b/packages/shared/src/dto/workflows/preview-step-response.dto.ts
index 6ad46f93b9b..f9a9f7b8ccd 100644
--- a/packages/shared/src/dto/workflows/preview-step-response.dto.ts
+++ b/packages/shared/src/dto/workflows/preview-step-response.dto.ts
@@ -1,5 +1,5 @@
import { ChannelTypeEnum } from '../../types';
-import { ControlPreviewIssueTypeEnum } from '../step-schemas';
+import { SubscriberDto } from '../subscriber';
export class RenderOutput {}
@@ -53,14 +53,25 @@ export class InAppRenderOutput extends RenderOutput {
target?: RedirectTargetEnum;
};
}
+export enum ControlPreviewIssueTypeEnum {
+ MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD',
+ VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH',
+ MISSING_VALUE = 'MISSING_VALUE',
+}
export class ControlPreviewIssue {
issueType: ControlPreviewIssueTypeEnum;
variableName?: string;
message: string;
}
+export class PreviewPayload {
+ subscriber?: Partial;
+ payload?: Record;
+ steps?: Record; // step.stepId.unknown
+}
export class GeneratePreviewResponseDto {
+ previewPayloadExample: PreviewPayload;
issues: Record;
result?:
| {
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index dcd6580a561..5229abd137b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -28,3 +28,4 @@ export * from './ui';
export * from './utils';
export * from './services';
export * from './config';
+export * from './clients';
From f1826f24339c9c419eed607571eec5c14c38f383 Mon Sep 17 00:00:00 2001
From: GalT <39020298+tatarco@users.noreply.github.com>
Date: Wed, 6 Nov 2024 10:20:39 +0100
Subject: [PATCH 02/11] bug(api): fix all tests
---
.../app/workflows-v2/generate-preview.e2e.ts | 27 ++++++++++---------
.../build-default-payload-use-case.service.ts | 18 ++++++++++---
2 files changed, 30 insertions(+), 15 deletions(-)
diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
index accd1238d81..240495c520a 100644
--- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
+++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
@@ -39,8 +39,8 @@ describe('Generate Preview', () => {
channelTypes.forEach(({ type, description }) => {
it(`${type}:should match the body in the preview response`, async () => {
- const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(type);
- const requestDto = buildDtoWithPayload(type);
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(type);
+ const requestDto = buildDtoWithPayload(type, stepId);
const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, description);
expect(previewResponseDto.result!.preview).to.exist;
const expectedRenderedResult = buildInAppControlValues();
@@ -176,8 +176,8 @@ describe('Generate Preview', () => {
channelTypes.forEach(({ type, description }) => {
it(`${type}: should assign default values to missing elements`, async () => {
- const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(type);
- const requestDto = buildDtoWithMissingControlValues(type);
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(type);
+ const requestDto = buildDtoWithMissingControlValues(type, stepId);
const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, description);
expect(previewResponseDto.result!.preview.body).to.exist;
expect(previewResponseDto.result!.preview.body).to.equal('PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING');
@@ -206,13 +206,13 @@ describe('Generate Preview', () => {
if (novuRestResult.isSuccessResult()) {
console.log(
'previewResponseDto.exampleMasterPayload',
- JSON.stringify(novuRestResult.value.previewPayloadExample)
+ JSON.stringify(novuRestResult.value.previewPayloadExample, null, 2)
);
return novuRestResult.value;
}
- throw await assertHttpError(description, novuRestResult);
+ throw await assertHttpError(description, novuRestResult, dto);
}
async function createWorkflowAndReturnId(type: StepTypeEnum) {
@@ -237,15 +237,15 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId: string): Generate
};
}
-function buildDtoWithPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
+function buildDtoWithPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
return {
- controlValues: getControlValues[stepTypeEnum],
+ controlValues: getControlValues(stepId)[stepTypeEnum],
previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } },
};
}
-function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
- const stepTypeToElement = getControlValues[stepTypeEnum];
+function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
+ const stepTypeToElement = getControlValues(stepId)[stepTypeEnum];
if (stepTypeEnum === StepTypeEnum.EMAIL) {
delete stepTypeToElement.subject;
} else {
@@ -328,10 +328,13 @@ const getControlValues = (stepId: string) => ({
async function assertHttpError(
description: string,
- novuRestResult: NovuRestResult
+ novuRestResult: NovuRestResult,
+ dto: GeneratePreviewRequestDto
) {
if (novuRestResult.error) {
- return new Error(`${description}: Failed to generate preview: ${novuRestResult.error.message}`);
+ return new Error(
+ `${description}: Failed to generate preview: ${novuRestResult.error.message}payload: ${JSON.stringify(dto, null, 2)} `
+ );
}
return new Error(`${description}: Failed to generate preview, bug in response error mapping `);
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
index 770b7fd0677..5bd1d6e9f7c 100644
--- 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
@@ -20,8 +20,14 @@ export class BuildDefaultPayloadUseCase {
} {
let aggregatedDefaultValues = {};
const aggregatedDefaultValuesForControl: Record> = {};
- const flattenedValues = flattenJson(command.controlValues);
+ 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({
@@ -36,7 +42,7 @@ export class BuildDefaultPayloadUseCase {
}
return {
- previewPayload: _.merge(aggregatedDefaultValues, { payload: command.payloadValues }),
+ previewPayload: _.merge(aggregatedDefaultValues, command.payloadValues),
issues: this.buildVariableMissingIssueRecord(
aggregatedDefaultValuesForControl,
aggregatedDefaultValues,
@@ -45,6 +51,13 @@ export class BuildDefaultPayloadUseCase {
};
}
+ private hasNoValues(command: BuildDefaultPayloadCommand) {
+ return (
+ !command.controlValues ||
+ (Object.keys(command.controlValues).length === 0 && command.controlValues.constructor === Object)
+ );
+ }
+
private buildVariableMissingIssueRecord(
valueKeyToDefaultsMap: Record>,
aggregatedDefaultValues: Record,
@@ -85,7 +98,6 @@ export class BuildDefaultPayloadUseCase {
variableToControlValueKeys: Record
): Record {
const record: Record = {};
-
missingVariables.forEach((missingVariable) => {
variableToControlValueKeys[missingVariable].forEach((controlValueKey) => {
record[controlValueKey] = [
From 7431048e449c05c94cb786e86b0afecfe2bc3212 Mon Sep 17 00:00:00 2001
From: GalT <39020298+tatarco@users.noreply.github.com>
Date: Wed, 6 Nov 2024 10:59:26 +0100
Subject: [PATCH 03/11] bug(api): add test for prev step
---
.../app/workflows-v2/generate-preview.e2e.ts | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
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 240495c520a..41d2e6f6201 100644
--- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
+++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
@@ -6,6 +6,7 @@ import { sleep } from '@nestjs/terminus/dist/utils';
import {
ChannelTypeEnum,
createWorkflowClient,
+ CreateWorkflowDto,
EmailStepControlSchemaDto,
GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
@@ -13,6 +14,7 @@ import {
NovuRestResult,
RedirectTargetEnum,
StepTypeEnum,
+ WorkflowCreationSourceEnum,
} from '@novu/shared';
import { InAppOutput } from '@novu/framework/internal';
import { buildCreateWorkflowDto } from './workflow.controller.e2e';
@@ -50,6 +52,15 @@ describe('Generate Preview', () => {
);
expect(previewResponseDto.result!.preview).to.deep.equal(expectedRenderedResult);
});
+ it(`${type} should hydrate previous step`, async () => {
+ const { workflowId, emailStepDatabaseId, digestStepId } = await createWorkflowWithDigest(type);
+ const requestDto = buildDtoWithPayload(type, digestStepId);
+ const previewResponseDto = await generatePreview(workflowId, emailStepDatabaseId, requestDto, description);
+ expect(previewResponseDto.result!.preview).to.exist;
+ expect(previewResponseDto.previewPayloadExample).to.exist;
+ expect(previewResponseDto.previewPayloadExample?.steps?.digestStepId).to.be.ok;
+ console.log(previewResponseDto.previewPayloadExample);
+ });
});
});
describe('Happy Path, no payload, expected same response as requested', () => {
@@ -229,6 +240,36 @@ describe('Generate Preview', () => {
stepId: workflowResult.value.steps[0].stepId,
};
}
+ async function createWorkflowWithDigest(type: StepTypeEnum) {
+ const createWorkflowDto: CreateWorkflowDto = {
+ __source: WorkflowCreationSourceEnum.EDITOR,
+ name: 'somename',
+ workflowId: `somename`,
+ description: 'This is a test workflow',
+ active: true,
+ steps: [
+ {
+ name: 'DigestStep',
+ type: StepTypeEnum.DIGEST,
+ },
+ {
+ name: 'Email Test Step',
+ type: StepTypeEnum.IN_APP,
+ },
+ ],
+ };
+ const workflowResult = await workflowsClient.createWorkflow(createWorkflowDto);
+ if (!workflowResult.isSuccessResult()) {
+ throw new Error(`Failed to create workflow ${JSON.stringify(workflowResult.error)}`);
+ }
+ console.log(workflowResult.value);
+
+ return {
+ workflowId: workflowResult.value._id,
+ emailStepDatabaseId: workflowResult.value.steps[1]._id,
+ digestStepId: workflowResult.value.steps[0].stepId,
+ };
+ }
});
function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
From e6987036ae43d9654c097dcdaae3cc934d72ae4e Mon Sep 17 00:00:00 2001
From: GalT <39020298+tatarco@users.noreply.github.com>
Date: Tue, 29 Oct 2024 09:21:46 +0100
Subject: [PATCH 04/11] feat(api): complete email preview
---
.cspell.json | 1 +
.idea/codeStyles/Project.xml | 1 +
.../preview-step/preview-step.command.ts | 11 +-
.../preview-step/preview-step.usecase.ts | 4 +-
.../app/environments-v1/novu-bridge-client.ts | 3 +-
.../app/environments-v1/novu-bridge.module.ts | 8 +-
.../construct-framework-workflow.command.ts | 6 +-
.../construct-framework-workflow.usecase.ts | 37 +-
.../email-output-renderer.usecase.ts | 44 --
.../email-schema-expander.usecase.ts | 77 ---
.../expand-email-editor-schema-command.ts | 9 +
.../expand-email-editor-schema.usecase.ts | 170 ++++++
.../expend-email-editor-schema-command.ts | 9 -
.../hydrate-email-schema.command.ts | 8 +
.../hydrate-email-schema.usecase.ts | 211 +++++++
.../usecases/output-renderers/index.ts | 6 +-
.../output-renderers/render-command.ts | 6 +
.../render-email-output.usecase.ts | 33 +
.../app/workflows-v2/generate-preview.e2e.ts | 293 ++++++---
.../src/app/workflows-v2/maily-test-data.ts | 570 ++++++++++++++++++
.../build-default-payload-use-case.service.ts | 171 ++++++
.../build-payload-from-placeholder/index.ts | 1 +
.../generate-preview-command.ts | 2 +-
.../generate-preview.usecase.ts | 213 ++-----
.../buildPayloadNestedStructureUsecase.ts | 29 +
...laceholders-from-tip-tap-schema.usecase.ts | 109 ----
...yload-defaults-engine-failure.exception.ts | 7 +
...payload-preview-value-generator.usecase.ts | 81 ++-
.../transform-placeholder.usecase.ts | 60 --
.../workflows-v2/workflow.controller.e2e.ts | 35 +-
.../src/app/workflows-v2/workflow.module.ts | 15 +-
apps/api/src/exception-filter.ts | 18 +-
.../shared/src}/clients/index.ts | 0
.../shared/src}/clients/novu-base-client.ts | 0
.../shared/src}/clients/workflows-client.ts | 8 +-
packages/shared/src/dto/index.ts | 1 -
.../control-preview-issue-type.enum.ts | 5 -
.../src/dto/step-schemas/control-schemas.ts | 13 -
.../generate-preview-response.dto.ts | 86 ---
packages/shared/src/dto/step-schemas/index.ts | 5 -
.../src/dto/workflows/control-schemas.ts | 33 +
.../generate-preview-request.dto.ts | 6 +-
packages/shared/src/dto/workflows/index.ts | 4 +
.../json-schema-dto.ts | 0
.../workflows/preview-step-response.dto.ts | 13 +-
packages/shared/src/index.ts | 1 +
46 files changed, 1692 insertions(+), 731 deletions(-)
delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts
delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts
delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts
create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts
create mode 100644 apps/api/src/app/workflows-v2/maily-test-data.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts
delete mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase.ts
create mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts
delete mode 100644 apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts
rename {apps/api/src/app/workflows-v2 => packages/shared/src}/clients/index.ts (100%)
rename {apps/api/src/app/workflows-v2 => packages/shared/src}/clients/novu-base-client.ts (100%)
rename {apps/api/src/app/workflows-v2 => packages/shared/src}/clients/workflows-client.ts (97%)
delete mode 100644 packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts
delete mode 100644 packages/shared/src/dto/step-schemas/control-schemas.ts
delete mode 100644 packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts
delete mode 100644 packages/shared/src/dto/step-schemas/index.ts
create mode 100644 packages/shared/src/dto/workflows/control-schemas.ts
rename packages/shared/src/dto/{step-schemas => workflows}/generate-preview-request.dto.ts (65%)
rename packages/shared/src/dto/{step-schemas => workflows}/json-schema-dto.ts (100%)
diff --git a/.cspell.json b/.cspell.json
index 5c8000f827f..b8ae9adfc23 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -753,6 +753,7 @@
"projectIds"
],
"ignorePaths": [
+ "apps/api/src/app/workflows-v2/maily-test-data.ts",
"apps/api/src/.env.test",
"apps/ws/src/.env.test",
"apps/ws/src/.example.env",
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index f986f2fe824..fcf2ca85553 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -19,6 +19,7 @@
+
diff --git a/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts b/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts
index fc110899c54..9458b50e5ea 100644
--- a/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts
+++ b/apps/api/src/app/bridge/usecases/preview-step/preview-step.command.ts
@@ -1,6 +1,6 @@
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { Subscriber } from '@novu/framework/internal';
-import { WorkflowOriginEnum } from '@novu/shared';
+import { JobStatusEnum, WorkflowOriginEnum } from '@novu/shared';
export class PreviewStepCommand extends EnvironmentWithUserCommand {
workflowId: string;
@@ -9,4 +9,13 @@ export class PreviewStepCommand extends EnvironmentWithUserCommand {
payload: Record;
subscriber?: Subscriber;
workflowOrigin: WorkflowOriginEnum;
+ state?: FrameworkPreviousStepsOutputState[];
}
+export type FrameworkPreviousStepsOutputState = {
+ stepId: string;
+ outputs: Record;
+ state: {
+ status: JobStatusEnum;
+ error?: string;
+ };
+};
diff --git a/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts b/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
index cabcaef8451..22db671f9f4 100644
--- a/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
+++ b/apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { Event, ExecuteOutput, HttpQueryKeysEnum, JobStatusEnum, PostActionEnum } from '@novu/framework/internal';
+import { Event, ExecuteOutput, HttpQueryKeysEnum, PostActionEnum } from '@novu/framework/internal';
import { ExecuteBridgeRequest, ExecuteBridgeRequestCommand } from '@novu/application-generic';
import { PreviewStepCommand } from './preview-step.command';
@@ -35,7 +35,7 @@ export class PreviewStep {
return {
controls: command.controls || {},
payload: command.payload || {},
- state: [],
+ state: command.state || [],
subscriber: command.subscriber || {},
stepId: command.stepId,
workflowId: command.workflowId,
diff --git a/apps/api/src/app/environments-v1/novu-bridge-client.ts b/apps/api/src/app/environments-v1/novu-bridge-client.ts
index af051e831a5..058c0124b4e 100644
--- a/apps/api/src/app/environments-v1/novu-bridge-client.ts
+++ b/apps/api/src/app/environments-v1/novu-bridge-client.ts
@@ -1,7 +1,7 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import type { Request, Response } from 'express';
import { PostActionEnum, type Workflow } from '@novu/framework/internal';
-import { Client, NovuRequestHandler, NovuHandler } from '@novu/framework/nest';
+import { Client, NovuHandler, NovuRequestHandler } from '@novu/framework/nest';
import { GetDecryptedSecretKey, GetDecryptedSecretKeyCommand } from '@novu/application-generic';
import { ConstructFrameworkWorkflow, ConstructFrameworkWorkflowCommand } from './usecases/construct-framework-workflow';
@@ -45,6 +45,7 @@ export class NovuBridgeClient {
environmentId: req.params.environmentId,
workflowId: req.query.workflowId as string,
controlValues: req.body.controls,
+ action: req.query.action as PostActionEnum,
})
);
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..5f1fdec55d2 100644
--- a/apps/api/src/app/environments-v1/novu-bridge.module.ts
+++ b/apps/api/src/app/environments-v1/novu-bridge.module.ts
@@ -8,10 +8,11 @@ import { ConstructFrameworkWorkflow } from './usecases/construct-framework-workf
import { NovuBridgeController } from './novu-bridge.controller';
import {
ChatOutputRendererUsecase,
- EmailOutputRendererUsecase,
ExpandEmailEditorSchemaUsecase,
+ HydrateEmailSchemaUseCase,
InAppOutputRendererUsecase,
PushOutputRendererUsecase,
+ RenderEmailOutputUsecase,
SmsOutputRendererUsecase,
} from './usecases/output-renderers';
@@ -28,12 +29,13 @@ import {
ConstructFrameworkWorkflow,
GetDecryptedSecretKey,
InAppOutputRendererUsecase,
- EmailOutputRendererUsecase,
+ RenderEmailOutputUsecase,
SmsOutputRendererUsecase,
ChatOutputRendererUsecase,
PushOutputRendererUsecase,
- EmailOutputRendererUsecase,
+ RenderEmailOutputUsecase,
ExpandEmailEditorSchemaUsecase,
+ HydrateEmailSchemaUseCase,
],
})
export class NovuBridgeModule {}
diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts
index 7e90ec97427..702162f712d 100644
--- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts
+++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.command.ts
@@ -1,5 +1,6 @@
import { EnvironmentLevelCommand } from '@novu/application-generic';
-import { IsDefined, IsObject, IsString } from 'class-validator';
+import { IsDefined, IsEnum, IsObject, IsString } from 'class-validator';
+import { PostActionEnum } from '@novu/framework/internal';
export class ConstructFrameworkWorkflowCommand extends EnvironmentLevelCommand {
@IsString()
@@ -9,4 +10,7 @@ export class ConstructFrameworkWorkflowCommand extends EnvironmentLevelCommand {
@IsObject()
@IsDefined()
controlValues: Record;
+
+ @IsEnum(PostActionEnum)
+ action: PostActionEnum;
}
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 eaea7963c17..f8b650accba 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
@@ -1,4 +1,4 @@
-import { Injectable, InternalServerErrorException } from '@nestjs/common';
+import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { workflow } from '@novu/framework/express';
import {
ActionStep,
@@ -15,9 +15,10 @@ import { StepTypeEnum } from '@novu/shared';
import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflow.command';
import {
ChatOutputRendererUsecase,
- EmailOutputRendererUsecase,
+ FullPayloadForRender,
InAppOutputRendererUsecase,
PushOutputRendererUsecase,
+ RenderEmailOutputUsecase,
SmsOutputRendererUsecase,
} from '../output-renderers';
@@ -26,7 +27,7 @@ export class ConstructFrameworkWorkflow {
constructor(
private workflowsRepository: NotificationTemplateRepository,
private inAppOutputRendererUseCase: InAppOutputRendererUsecase,
- private emailOutputRendererUseCase: EmailOutputRendererUsecase,
+ private emailOutputRendererUseCase: RenderEmailOutputUsecase,
private smsOutputRendererUseCase: SmsOutputRendererUsecase,
private chatOutputRendererUseCase: ChatOutputRendererUsecase,
private pushOutputRendererUseCase: PushOutputRendererUsecase
@@ -40,15 +41,21 @@ export class ConstructFrameworkWorkflow {
}
}
- return this.constructFrameworkWorkflow(dbWorkflow);
+ return this.constructFrameworkWorkflow(dbWorkflow, command.action);
}
- private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow {
+ private constructFrameworkWorkflow(newWorkflow, action) {
return workflow(
newWorkflow.triggers[0].identifier,
- async ({ step }) => {
+ async ({ step, payload, subscriber }) => {
+ const fullPayloadForRender: FullPayloadForRender = { payload, subscriber, steps: {} };
for await (const staticStep of newWorkflow.steps) {
- await this.constructStep(step, staticStep);
+ try {
+ const stepOutputs = await this.constructStep(step, staticStep, fullPayloadForRender);
+ fullPayloadForRender.steps[staticStep.stepId || staticStep._templateId] = stepOutputs;
+ } catch (e) {
+ Logger.log(`Cannot Construct Step ${staticStep.stepId || staticStep._templateId}`, e);
+ }
}
},
{
@@ -66,7 +73,11 @@ export class ConstructFrameworkWorkflow {
);
}
- private constructStep(step: Step, staticStep: NotificationStepEntity): StepOutput> {
+ private constructStep(
+ step: Step,
+ staticStep: NotificationStepEntity,
+ fullPayloadForRender: FullPayloadForRender
+ ): StepOutput> {
const stepTemplate = staticStep.template;
if (!stepTemplate) {
@@ -91,7 +102,7 @@ export class ConstructFrameworkWorkflow {
stepId,
// The step callback function. Takes controls and returns the step outputs
async (controlValues) => {
- return this.inAppOutputRendererUseCase.execute({ controlValues });
+ return this.inAppOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
// Step options
this.constructChannelStepOptions(staticStep)
@@ -100,7 +111,7 @@ export class ConstructFrameworkWorkflow {
return step.email(
stepId,
async (controlValues) => {
- return this.emailOutputRendererUseCase.execute({ controlValues });
+ return this.emailOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
this.constructChannelStepOptions(staticStep)
);
@@ -108,7 +119,7 @@ export class ConstructFrameworkWorkflow {
return step.inApp(
stepId,
async (controlValues) => {
- return this.smsOutputRendererUseCase.execute({ controlValues });
+ return this.smsOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
this.constructChannelStepOptions(staticStep)
);
@@ -116,7 +127,7 @@ export class ConstructFrameworkWorkflow {
return step.inApp(
stepId,
async (controlValues) => {
- return this.chatOutputRendererUseCase.execute({ controlValues });
+ return this.chatOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
this.constructChannelStepOptions(staticStep)
);
@@ -124,7 +135,7 @@ export class ConstructFrameworkWorkflow {
return step.inApp(
stepId,
async (controlValues) => {
- return this.pushOutputRendererUseCase.execute({ controlValues });
+ return this.pushOutputRendererUseCase.execute({ controlValues, fullPayloadForRender });
},
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
deleted file mode 100644
index bef8f4008a8..00000000000
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { EmailRenderOutput, TipTapNode } from '@novu/shared';
-import { render } from '@maily-to/render';
-import { z } from 'zod';
-import { Injectable } from '@nestjs/common';
-import { RenderCommand } from './render-command';
-import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander.usecase';
-
-@Injectable()
-export class EmailOutputRendererUsecase {
- constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {}
-
- async execute(renderCommand: RenderCommand): Promise {
- const parse = EmailStepControlSchema.parse(renderCommand.controlValues);
- const schema = parse.emailEditor as TipTapNode;
- const expandedSchema = this.expendEmailEditorSchemaUseCase.execute({ schema });
- const html = await render(expandedSchema);
-
- return { subject: parse.subject, body: html };
- }
-}
-const emailContentSchema = z
- .object({
- type: z.string(),
- content: z.array(z.lazy(() => emailContentSchema)).optional(),
- text: z.string().optional(),
- attr: z.record(z.unknown()).optional(),
- })
- .strict();
-
-const emailEditorSchema = z
- .object({
- type: z.string(),
- content: z.array(emailContentSchema).optional(),
- text: z.string().optional(),
- attr: z.record(z.unknown()).optional(),
- })
- .strict();
-
-export const EmailStepControlSchema = z
- .object({
- emailEditor: emailEditorSchema,
- subject: z.string(),
- })
- .strict();
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts
deleted file mode 100644
index 7383deffce2..00000000000
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/* eslint-disable no-param-reassign */
-import { TipTapNode } from '@novu/shared';
-import { ExpendEmailEditorSchemaCommand } from './expend-email-editor-schema-command';
-
-// Rename the class to ExpendEmailEditorSchemaUseCase
-export class ExpandEmailEditorSchemaUsecase {
- execute(command: ExpendEmailEditorSchemaCommand): TipTapNode {
- return this.expendSchema(command.schema);
- }
-
- private expendSchema(schema: TipTapNode): TipTapNode {
- // todo: try to avoid !
- const content = schema.content!.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[];
-
- return { ...schema, content };
- }
-
- private processItemNode(node: TipTapNode, item: any): TipTapNode {
- if (node.type === 'text' && typeof node.text === 'string') {
- const regex = /{#item\.(\w+)#}/g;
- node.text = node.text.replace(regex, (_, key: string) => {
- const propertyName = key;
-
- return item[propertyName] !== undefined ? item[propertyName] : _;
- });
- }
-
- if (node.content) {
- node.content = node.content.map((innerNode) => this.processItemNode(innerNode, item));
- }
-
- return node;
- }
-
- private processNodeRecursive(node: TipTapNode): TipTapNode | null {
- if (node.type === 'show') {
- const whenValue = node.attr?.when;
- if (whenValue !== 'true') {
- return null;
- }
- }
-
- if (this.hasEachAttr(node)) {
- return { type: 'section', content: this.handleFor(node) };
- }
-
- return this.processNodeContent(node);
- }
-
- private processNodeContent(node: TipTapNode): TipTapNode | null {
- if (node.content) {
- node.content = node.content.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[];
- }
-
- return node;
- }
-
- private hasEachAttr(node: TipTapNode): node is TipTapNode & { attr: { each: any } } {
- return node.attr !== undefined && node.attr.each !== undefined;
- }
-
- private handleFor(node: TipTapNode & { attr: { each: any } }): TipTapNode[] {
- const items = node.attr.each;
- const newContent: TipTapNode[] = [];
-
- const itemsParsed = JSON.parse(items.replace(/'/g, '"'));
- for (const item of itemsParsed) {
- const newNode = { ...node };
- newNode.content = newNode.content?.map((innerNode) => this.processItemNode(innerNode, item)) || [];
- if (newNode.content) {
- newContent.push(...newNode.content);
- }
- }
-
- return newContent;
- }
-}
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts
new file mode 100644
index 00000000000..ea8c2d09509
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts
@@ -0,0 +1,9 @@
+// Define the command interface
+
+import { BaseCommand } from '@novu/application-generic';
+import { FullPayloadForRender } from './render-command';
+
+export class ExpandEmailEditorSchemaCommand extends BaseCommand {
+ body: 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
new file mode 100644
index 00000000000..e12603fab99
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts
@@ -0,0 +1,170 @@
+/* eslint-disable no-param-reassign */
+import { TipTapNode } from '@novu/shared';
+import { Injectable } from '@nestjs/common';
+import { ExpandEmailEditorSchemaCommand } from './expand-email-editor-schema-command';
+import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase';
+
+@Injectable()
+export class ExpandEmailEditorSchemaUsecase {
+ constructor(private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase) {}
+
+ execute(command: ExpandEmailEditorSchemaCommand): TipTapNode {
+ const emailSchemaHydrated = this.hydrate(command);
+ this.processShowAndForControls(emailSchemaHydrated, undefined);
+
+ return emailSchemaHydrated;
+ }
+ private hydrate(command: ExpandEmailEditorSchemaCommand) {
+ const { hydratedEmailSchema } = this.hydrateEmailSchemaUseCase.execute({
+ emailEditor: command.body,
+ fullPayloadForRender: command.fullPayloadForRender,
+ });
+
+ return hydratedEmailSchema;
+ }
+
+ private processShowAndForControls(node: TipTapNode, parentNode?: TipTapNode) {
+ if (node.content) {
+ node.content.forEach((innerNode) => {
+ this.processShowAndForControls(innerNode, node);
+ });
+ }
+ if (this.hasShow(node)) {
+ this.hideShowIfNeeded(node, parentNode);
+ } else if (this.hasEach(node)) {
+ const newContent = this.expendedForEach(node);
+ node.content = newContent;
+ if (parentNode && parentNode.content) {
+ this.insertArrayAt(parentNode.content, parentNode.content.indexOf(node), newContent);
+ 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);
+ }
+
+ private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { each: unknown } } {
+ return !!(node.attrs && 'each' in node.attrs);
+ }
+
+ private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { show: string } } {
+ return !!(node.attrs && 'show' in node.attrs);
+ }
+
+ private regularExpansion(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);
+ }
+
+ 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 || [];
+
+ /*
+ * Due to maily limitations in the current implementation, the location of the for
+ * element is situated on the container of the list making the list a
+ * child of the for element, if we iterate it we will get the
+ * wrong behavior of multiple lists instead of list with multiple items.
+ * due to that when we choose the content to iterate in case we find a list we drill down additional level
+ * and iterate on the list items
+ * this prevents us from
+ * 1. item1
+ * 1. item2
+ *
+ * and turns it into
+ * 1.item1
+ * 2.item2
+ * which is the correct behavior
+ *
+ */
+ if ((this.isOrderedList(templateContent) || this.isBulletList(templateContent)) && templateContent[0].content) {
+ return [{ ...templateContent[0], content: this.regularExpansion(eachObject, templateContent[0].content) }];
+ }
+
+ return this.regularExpansion(eachObject, templateContent);
+ }
+
+ private removeNodeFromParent(node: TipTapNode, parentNode?: TipTapNode) {
+ if (parentNode && parentNode.content) {
+ parentNode.content.splice(parentNode.content.indexOf(node), 1);
+ }
+ }
+
+ 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 stringToBoolean(value: unknown): boolean {
+ if (typeof value === 'string') {
+ return value.toLowerCase() === 'true';
+ }
+
+ return false;
+ }
+
+ private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { id: string } } {
+ return newNode.type === 'payloadValue' && newNode.attrs?.id !== undefined;
+ }
+
+ 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];
+ }
+
+ 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
deleted file mode 100644
index 5231f047132..00000000000
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/expend-email-editor-schema-command.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// Define the command interface
-
-import { BaseCommand } from '@novu/application-generic';
-import { TipTapNode } from '@novu/shared';
-
-// eslint-disable-next-line @typescript-eslint/naming-convention
-export interface ExpendEmailEditorSchemaCommand extends BaseCommand {
- schema: TipTapNode;
-}
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/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..ade4d70210b
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts
@@ -0,0 +1,8 @@
+// New HydrateEmailSchemaUseCase class
+
+import { FullPayloadForRender } from './render-command';
+
+export class HydrateEmailSchemaCommand {
+ emailEditor: string;
+ fullPayloadForRender: 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
new file mode 100644
index 00000000000..386caa79277
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts
@@ -0,0 +1,211 @@
+/* eslint-disable no-param-reassign */
+import { Injectable } from '@nestjs/common';
+import { PreviewPayload, TipTapNode } from '@novu/shared';
+import { z } from 'zod';
+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.fullPayloadForRender);
+ }
+
+ return { hydratedEmailSchema: emailEditorSchema, nestedPayload: this.flattenToNested(defaultPayload) };
+ }
+
+ private variableLogic(
+ masterPayload: PreviewPayload,
+ 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: PreviewPayload,
+ 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: PreviewPayload,
+ 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: PreviewPayload
+ ) {
+ 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: PreviewPayload, node) {
+ const resolvedValue = this.getValueByPath(masterPayload, node.attrs.id);
+ const { fallback } = node.attrs;
+
+ return resolvedValue || fallback || `{{${node.attrs.id}}}`;
+ }
+
+ private getResolvedValueShowPlaceholder(masterPayload: PreviewPayload, 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: PreviewPayload,
+ 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) {
+ const nextObj = currentObj[key];
+
+ return nextObj;
+ }
+
+ 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..8f220cfd907 100644
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/index.ts
@@ -1,7 +1,9 @@
export * from './chat-output-renderer.usecase';
-export * from './email-output-renderer.usecase';
export * from './render-command';
export * from './push-output-renderer.usecase';
export * from './sms-output-renderer.usecase';
export * from './in-app-output-renderer.usecase';
-export * from './email-schema-expander.usecase';
+export * from './render-email-output.usecase';
+export * from './hydrate-email-schema.usecase';
+export * from './hydrate-email-schema.command';
+export * from './expand-email-editor-schema.usecase';
diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts
index c6b569963d2..97d89926d3d 100644
--- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-command.ts
@@ -2,4 +2,10 @@ import { BaseCommand } from '@novu/application-generic';
export class RenderCommand extends BaseCommand {
controlValues: Record;
+ fullPayloadForRender: FullPayloadForRender;
+}
+export class FullPayloadForRender {
+ subscriber: Record;
+ payload: Record;
+ steps: Record; // step.stepId.unknown
}
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
new file mode 100644
index 00000000000..790fbfceb79
--- /dev/null
+++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts
@@ -0,0 +1,33 @@
+import { EmailRenderOutput } from '@novu/shared';
+import { z } from 'zod';
+import { Injectable } from '@nestjs/common';
+import { render } from '@maily-to/render';
+import { FullPayloadForRender, RenderCommand } from './render-command';
+import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase';
+
+export class RenderEmailOutputCommand extends RenderCommand {}
+
+@Injectable()
+export class RenderEmailOutputUsecase {
+ constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {}
+
+ async execute(renderCommand: RenderEmailOutputCommand): Promise {
+ const { emailEditor, subject } = EmailStepControlSchema.parse(renderCommand.controlValues);
+ console.log('renderCommand.fullPayloadForRender', renderCommand.fullPayloadForRender);
+ const expandedSchema = this.transformForAndShowLogic(emailEditor, renderCommand.fullPayloadForRender);
+ const htmlRendered = await render(expandedSchema);
+
+ return { subject, body: htmlRendered };
+ }
+
+ private transformForAndShowLogic(body: string, fullPayloadForRender: FullPayloadForRender) {
+ return this.expendEmailEditorSchemaUseCase.execute({ body, fullPayloadForRender });
+ }
+}
+
+export const EmailStepControlSchema = z
+ .object({
+ emailEditor: z.string(),
+ subject: z.string(),
+ })
+ .strict();
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 0b92ac8ebd5..4e9d71b527d 100644
--- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
+++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
@@ -5,17 +5,23 @@ import { after, beforeEach } from 'mocha';
import { sleep } from '@nestjs/terminus/dist/utils';
import {
ChannelTypeEnum,
+ createWorkflowClient,
+ CreateWorkflowDto,
EmailStepControlSchemaDto,
GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
+ HttpError,
+ NovuRestResult,
RedirectTargetEnum,
StepTypeEnum,
- TipTapNode,
+ WorkflowCreationSourceEnum,
} from '@novu/shared';
-import { InAppOutput } from '@novu/framework/internal';
-import { createWorkflowClient, HttpError, NovuRestResult } from './clients';
import { buildCreateWorkflowDto } from './workflow.controller.e2e';
+import { forSnippet, 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;
@@ -30,16 +36,26 @@ describe('Generate Preview', () => {
});
describe('Generate Preview', () => {
describe('Hydration testing', () => {
+ it(` should hydrate previous step`, async () => {
+ const { workflowId, emailStepDatabaseId, digestStepId } = await createWorkflowWithDigest();
+ const requestDto = buildDtoWithPayload(StepTypeEnum.EMAIL, digestStepId);
+ const previewResponseDto = await generatePreview(workflowId, emailStepDatabaseId, requestDto, 'testing steps');
+ expect(previewResponseDto.result!.preview).to.exist;
+ expect(previewResponseDto.previewPayloadExample).to.exist;
+ console.log(previewResponseDto.previewPayloadExample);
+ expect(previewResponseDto.previewPayloadExample?.steps?.digeststep).to.be.ok;
+ });
+
const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }];
channelTypes.forEach(({ type, description }) => {
it(`${type}:should match the body in the preview response`, async () => {
- const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(type);
- const requestDto = buildDtoWithPayload(type);
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(type);
+ const requestDto = buildDtoWithPayload(type, stepId);
const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, description);
expect(previewResponseDto.result!.preview).to.exist;
- const expectedRenderedResult = buildInAppControlValues();
- expectedRenderedResult.subject = buildInAppControlValues().subject!.replace(
+ const expectedRenderedResult = buildInAppControlValues(stepId);
+ expectedRenderedResult.subject = buildInAppControlValues(stepId).subject!.replace(
PLACEHOLDER_SUBJECT_INAPP,
PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE
);
@@ -53,31 +69,126 @@ 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 }) => {
it(`${type}:should match the body in the preview response`, async () => {
- const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(type);
- const requestDto = buildDtoNoPayload(type);
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(type);
+ const requestDto = buildDtoNoPayload(type, stepId);
const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, description);
expect(previewResponseDto.result!.preview).to.exist;
expect(previewResponseDto.issues).to.exist;
+ expect(previewResponseDto.previewPayloadExample).to.exist;
+ expect(previewResponseDto.previewPayloadExample.subscriber, 'Expecting to find subscriber in the payload').to
+ .exist;
if (type !== StepTypeEnum.EMAIL) {
- expect(previewResponseDto.result!.preview).to.deep.equal(stepTypeTo[type]);
+ expect(previewResponseDto.result!.preview).to.deep.equal(getControlValues(stepId)[type]);
} else {
assertEmail(previewResponseDto);
}
});
});
});
+ describe('email specific features', () => {
+ describe('show', () => {
+ it('show -> should hide element based on payload', async () => {
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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 { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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 { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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 { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { 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('for', () => {
+ it('should populate for if payload exist with actual values', async () => {
+ const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(StepTypeEnum.EMAIL);
+ const name1 = 'ball is round';
+ const name2 = 'square is square';
+ const previewResponseDto = await generatePreview(
+ workflowId,
+ stepDatabaseId,
+ {
+ controlValues: buildSimpleForEmail() as unknown as Record,
+ previewPayload: { payload: { food: { items: [{ name: name1 }, { name: name2 }] } } },
+ },
+ 'email'
+ );
+ expect(previewResponseDto.result!.preview).to.exist;
+ const preview = previewResponseDto.result!.preview.body;
+ expect(preview).not.to.contain('{{item.name}}1');
+ expect(preview).not.to.contain('{{item.name}}2');
+ expect(preview).to.contain(name1);
+ expect(preview).to.contain(name2);
+ });
+ });
+ });
+
describe('Missing Required ControlValues', () => {
const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }];
channelTypes.forEach(({ type, description }) => {
it(`${type}: should assign default values to missing elements`, async () => {
- const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(type);
- const requestDto = buildDtoWithMissingControlValues(type);
+ const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(type);
+ const requestDto = buildDtoWithMissingControlValues(type, stepId);
const previewResponseDto = await generatePreview(workflowId, stepDatabaseId, requestDto, description);
expect(previewResponseDto.result!.preview.body).to.exist;
expect(previewResponseDto.result!.preview.body).to.equal('PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING');
@@ -106,7 +217,8 @@ describe('Generate Preview', () => {
if (novuRestResult.isSuccessResult()) {
return novuRestResult.value;
}
- throw await assertHttpError(description, novuRestResult);
+
+ throw await assertHttpError(description, novuRestResult, dto);
}
async function createWorkflowAndReturnId(type: StepTypeEnum) {
@@ -117,26 +229,59 @@ describe('Generate Preview', () => {
throw new Error(`Failed to create workflow ${JSON.stringify(workflowResult.error)}`);
}
- return { workflowId: workflowResult.value._id, stepDatabaseId: workflowResult.value.steps[0]._id };
+ return {
+ workflowId: workflowResult.value._id,
+ stepDatabaseId: workflowResult.value.steps[0]._id,
+ stepId: workflowResult.value.steps[0].stepId,
+ };
+ }
+ async function createWorkflowWithDigest() {
+ const createWorkflowDto: CreateWorkflowDto = {
+ __source: WorkflowCreationSourceEnum.EDITOR,
+ name: 'John',
+ workflowId: `john:${randomUUID()}`,
+ description: 'This is a test workflow',
+ active: true,
+ steps: [
+ {
+ name: 'DigestStep',
+ type: StepTypeEnum.DIGEST,
+ },
+ {
+ name: 'Email Test Step',
+ type: StepTypeEnum.EMAIL,
+ },
+ ],
+ };
+ const workflowResult = await workflowsClient.createWorkflow(createWorkflowDto);
+ if (!workflowResult.isSuccessResult()) {
+ throw new Error(`Failed to create workflow ${JSON.stringify(workflowResult.error)}`);
+ }
+ console.log(workflowResult.value);
+
+ return {
+ workflowId: workflowResult.value._id,
+ emailStepDatabaseId: workflowResult.value.steps[1]._id,
+ digestStepId: workflowResult.value.steps[0].stepId,
+ };
}
});
-function buildDtoNoPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
+function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
return {
- validationStrategies: [],
- controlValues: stepTypeTo[stepTypeEnum],
+ controlValues: getControlValues(stepId)[stepTypeEnum],
};
}
-function buildDtoWithPayload(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
+
+function buildDtoWithPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
return {
- validationStrategies: [],
- controlValues: stepTypeTo[stepTypeEnum],
- payloadValues: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE },
+ controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } },
};
}
-function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GeneratePreviewRequestDto {
- const stepTypeToElement = stepTypeTo[stepTypeEnum];
+function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
+ const stepTypeToElement = getControlValues(stepId)[stepTypeEnum];
if (stepTypeEnum === StepTypeEnum.EMAIL) {
delete stepTypeToElement.subject;
} else {
@@ -144,75 +289,26 @@ function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum): GenerateP
}
return {
- validationStrategies: [],
controlValues: stepTypeToElement,
- payloadValues: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE },
+ previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } },
};
}
-const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}';
-
-const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}';
-const PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE = 'this is the replacement text for the placeholder';
-function mailyJsonExample(): TipTapNode {
+function buildEmailControlValuesPayload(stepId: string): EmailStepControlSchemaDto {
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,
- },
- ],
- },
- ],
- },
- ],
+ subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`,
+ emailEditor: JSON.stringify(fullCodeSnippet(stepId)),
};
}
-function buildEmailControlValuesPayload(): EmailStepControlSchemaDto {
+function buildSimpleForEmail(): EmailStepControlSchemaDto {
return {
subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`,
- emailEditor: JSON.stringify(mailyJsonExample()),
+ emailEditor: JSON.stringify(forSnippet),
};
}
-function buildInAppControlValues(): InAppOutput {
+function buildInAppControlValues(stepId: string) {
return {
- subject: `Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`,
+ subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`,
body: 'Hello, World! {{payload.placeholder.body}}',
avatar: 'https://www.example.com/avatar.png',
primaryAction: {
@@ -241,38 +337,40 @@ function buildInAppControlValues(): InAppOutput {
function buildSmsControlValuesPayload() {
return {
- body: 'Hello, World!',
+ body: 'Hello, World! {{subscriber.firstName}}',
};
}
function buildPushControlValuesPayload() {
return {
subject: 'Hello, World!',
- body: 'Hello, World!',
+ body: 'Hello, World! {{subscriber.firstName}}',
};
}
function buildChatControlValuesPayload() {
return {
- body: 'Hello, World!',
+ body: 'Hello, World! {{subscriber.firstName}}',
};
}
-const FOR_ITEM_VALUE_PLACEHOLDER = '{#item.body#}';
-const TEST_SHOW_VALUE = 'TEST_SHOW_VALUE';
-const stepTypeTo = {
+
+const getControlValues = (stepId: string) => ({
[StepTypeEnum.SMS]: buildSmsControlValuesPayload(),
- [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload() as unknown as Record,
+ [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(stepId) as unknown as Record,
[StepTypeEnum.PUSH]: buildPushControlValuesPayload(),
[StepTypeEnum.CHAT]: buildChatControlValuesPayload(),
- [StepTypeEnum.IN_APP]: buildInAppControlValues(),
-};
+ [StepTypeEnum.IN_APP]: buildInAppControlValues(stepId),
+});
async function assertHttpError(
description: string,
- novuRestResult: NovuRestResult
+ novuRestResult: NovuRestResult,
+ dto: GeneratePreviewRequestDto
) {
if (novuRestResult.error) {
- return new Error(`${description}: Failed to generate preview: ${novuRestResult.error.message}`);
+ return new Error(
+ `${description}: Failed to generate preview: ${novuRestResult.error.message}payload: ${JSON.stringify(dto, null, 2)} `
+ );
}
return new Error(`${description}: Failed to generate preview, bug in response error mapping `);
@@ -282,8 +380,15 @@ 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('{{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..c0452241f44
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/maily-test-data.ts
@@ -0,0 +1,570 @@
+export const forSnippet = {
+ type: 'doc',
+ content: [
+ {
+ type: 'for',
+ attrs: {
+ each: 'payload.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: 'payload.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: ' ',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+export const fullCodeSnippet = (stepId) => ({
+ 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 emaly.',
+ },
+ ],
+ },
+ {
+ 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: 'variable',
+ attrs: {
+ id: 'subscriber.fullName',
+ label: null,
+ fallback: 'should be the fallback value',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ 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: '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: `steps.${stepId}.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: 'payload.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: 'payload.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: 'payload.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/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
new file mode 100644
index 00000000000..5bd1d6e9f7c
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/build-default-payload-use-case.service.ts
@@ -0,0 +1,171 @@
+/* eslint-disable no-param-reassign */
+import { Injectable } from '@nestjs/common';
+import { ControlPreviewIssue, ControlPreviewIssueTypeEnum, PreviewPayload } 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: 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/build-payload-from-placeholder/index.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts
new file mode 100644
index 00000000000..d2e73ea7104
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/build-payload-from-placeholder/index.ts
@@ -0,0 +1 @@
+export * from './build-default-payload-use-case.service';
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
index 9277d118eed..9de0cafb86f 100644
--- 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
@@ -1,5 +1,5 @@
-import { GeneratePreviewRequestDto } from '@novu/shared';
import { EnvironmentWithUserObjectCommand } from '@novu/application-generic';
+import { GeneratePreviewRequestDto } from '@novu/shared';
export class GeneratePreviewCommand extends EnvironmentWithUserObjectCommand {
workflowId: string;
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 65fb5fe7bdf..27b9ad5c577 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,52 +4,66 @@ import {
ControlPreviewIssue,
ControlPreviewIssueTypeEnum,
ControlSchemas,
- GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
+ JobStatusEnum,
JSONSchemaDto,
+ PreviewPayload,
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 { CreateMockPayloadUseCase } from '../placeholder-enrichment/payload-preview-value-generator.usecase';
import { StepMissingControlsException, StepNotFoundException } from '../../exceptions/step-not-found-exception';
import { ExtractDefaultsUsecase } from '../get-default-values-from-schema/extract-defaults.usecase';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
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';
@Injectable()
export class GeneratePreviewUsecase {
constructor(
private legacyPreviewStepUseCase: PreviewStep,
private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase,
- private createMockPayloadUseCase: CreateMockPayloadUseCase,
- private extractDefaultsUseCase: ExtractDefaultsUsecase
+ private extractDefaultsUseCase: ExtractDefaultsUsecase,
+ private constructPayloadUseCase: BuildDefaultPayloadUseCase
) {}
async execute(command: GeneratePreviewCommand): Promise {
- const payloadHydrationInfo = this.payloadHydrationLogic(command);
+ const payloadInfo = this.buildPayloadWithDefaults(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,
+ payloadInfo.previewPayload,
controlValuesResult.augmentedControlValues,
command
);
return buildResponse(
controlValuesResult.issuesMissingValues,
- payloadHydrationInfo.issues,
+ payloadInfo.issues,
executeOutput,
- workflowInfo.stepType
+ workflowInfo.stepType,
+ payloadInfo.previewPayload
);
}
+ private buildPayloadWithDefaults(command: GeneratePreviewCommand) {
+ const dto = command.generatePreviewRequestDto;
+ const { previewPayload, issues } = this.constructPayloadUseCase.execute({
+ controlValues: dto.controlValues,
+ payloadValues: dto.previewPayload,
+ });
+
+ return { previewPayload, issues };
+ }
+
+ 3;
private addMissingValuesToControlValues(command: GeneratePreviewCommand, stepControlSchema: ControlSchemas) {
const defaultValues = this.extractDefaultsUseCase.execute({
jsonSchemaDto: stepControlSchema.schema as JSONSchemaDto,
@@ -62,7 +76,7 @@ export class GeneratePreviewUsecase {
}
private buildMissingControlValuesIssuesList(defaultValues: Record, command: GeneratePreviewCommand) {
- const missingRequiredControlValues = this.findMissingKeys(
+ const missingRequiredControlValues = findMissingKeys(
defaultValues,
command.generatePreviewRequestDto.controlValues || {}
);
@@ -77,41 +91,19 @@ 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 | undefined,
origin: WorkflowOriginEnum | undefined,
- hydratedPayload: Record,
+ hydratedPayload: PreviewPayload,
updatedControlValues: Record,
command: GeneratePreviewCommand
) {
@@ -122,9 +114,13 @@ export class GeneratePreviewUsecase {
throw new OriginMissingException(stepId);
}
+ const state = buildState(hydratedPayload.steps);
+ console.log('state', JSON.stringify(state, null, 2));
+
return await this.legacyPreviewStepUseCase.execute(
PreviewStepCommand.create({
- payload: hydratedPayload,
+ payload: hydratedPayload.payload || {},
+ subscriber: hydratedPayload.subscriber,
controls: updatedControlValues || {},
environmentId: command.user.environmentId,
organizationId: command.user.organizationId,
@@ -132,6 +128,7 @@ export class GeneratePreviewUsecase {
userId: command.user._id,
workflowId,
workflowOrigin: origin,
+ state,
})
);
}
@@ -158,141 +155,61 @@ export class GeneratePreviewUsecase {
origin: persistedWorkflow.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(
missingValuesIssue: Record,
missingPayloadVariablesIssue: Record,
executionOutput,
- stepType: StepTypeEnum
+ stepType: StepTypeEnum,
+ augmentedPayload: PreviewPayload
): GeneratePreviewResponseDto {
return {
issues: merge(missingValuesIssue, missingPayloadVariablesIssue),
result: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
preview: executionOutput.outputs as any,
type: stepType as unknown as ChannelTypeEnum,
},
+ previewPayloadExample: augmentedPayload,
};
}
-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 findMissingKeys(requiredRecord: Record, actualRecord: Record): string[] {
+ const requiredKeys = collectKeys(requiredRecord);
+ const actualKeys = collectKeys(actualRecord);
-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
+ return _.difference(requiredKeys, actualKeys);
+}
- 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);
+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 {
- // Push the dot notation key to the keys array
- keys.push(newKey);
+ result.push(newKey);
}
- }
- }
- return keys;
+ return result;
+ },
+ [] // Pass an empty array as the initial value
+ );
}
-function flattenJson(obj, parentKey = '', result = {}) {
- // eslint-disable-next-line guard-for-in
- for (const key in obj) {
- // Construct the new key using dot notation
- const newKey = parentKey ? `${parentKey}.${key}` : key;
-
- // Check if the value is an object (and not null or an array)
- if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
- // Recursively flatten the object
- flattenJson(obj[key], newKey, result);
- } else if (Array.isArray(obj[key])) {
- // Handle arrays by flattening each item
- obj[key].forEach((item, index) => {
- const arrayKey = `${newKey}[${index}]`;
- if (typeof item === 'object' && item !== null) {
- flattenJson(item, arrayKey, result);
- } else {
- // eslint-disable-next-line no-param-reassign
- result[arrayKey] = item;
- }
- });
- } else {
- // Assign the value to the result with the new key
- // eslint-disable-next-line no-param-reassign
- result[newKey] = obj[key];
- }
+function buildState(steps: Record | undefined): FrameworkPreviousStepsOutputState[] {
+ const outputArray: FrameworkPreviousStepsOutputState[] = [];
+ for (const [stepId, value] of Object.entries(steps || {})) {
+ outputArray.push({
+ stepId,
+ outputs: value as Record,
+ state: {
+ status: JobStatusEnum.COMPLETED,
+ },
+ });
}
- return result;
+ return outputArray;
}
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
new file mode 100644
index 00000000000..966bc3e6722
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase.ts
@@ -0,0 +1,29 @@
+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/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-defaults-engine-failure.exception.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts
new file mode 100644
index 00000000000..40787c160e6
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/payload-defaults-engine-failure.exception.ts
@@ -0,0 +1,7 @@
+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
index f27123948e3..114c49bce77 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,19 +1,19 @@
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';
+import {
+ BuildPayloadNestedStructureCommand,
+ BuildPayloadNestedStructureUsecase,
+} from './buildPayloadNestedStructureUsecase';
+import { PayloadDefaultsEngineFailureException } from './payload-defaults-engine-failure.exception';
+const unsupportedPrefixes: string[] = ['actor', 'steps'];
@Injectable()
-export class CreateMockPayloadUseCase {
+export class CreateMockPayloadForSingleControlValueUseCase {
constructor(
- private readonly collectPlaceholdersFromTipTapSchemaUsecase: CollectPlaceholdersFromTipTapSchemaUsecase,
- private readonly transformPlaceholderMapUseCase: TransformPlaceholderMapUseCase
+ private readonly transformPlaceholderMapUseCase: BuildPayloadNestedStructureUsecase,
+ private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase
) {}
-
public execute(command: AddKeysToPayloadBasedOnHydrationStrategyCommand): Record {
const { controlValues, controlValueKey } = command;
@@ -22,33 +22,68 @@ export class CreateMockPayloadUseCase {
}
const controlValue = controlValues[controlValueKey];
- if (typeof controlValue === 'object') {
- return this.buildPayloadForEmailEditor(controlValue);
+ const payloadFromEmailSchema = this.safeAttemptToParseEmailSchema(controlValue);
+ if (payloadFromEmailSchema) {
+ return payloadFromEmailSchema;
}
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,
+ fullPayloadForRender: {
+ payload: {},
+ subscriber: {},
+ steps: {},
+ },
+ });
- return transformPlaceholderMap.payload;
+ return nestedPayload;
+ } catch (e) {
+ return undefined;
+ }
}
private buildPayloadForRegularText(controlValue: unknown) {
- const strings = extractPlaceholders(controlValue as string).filter(
- (placeholder) => !placeholder.startsWith('subscriber') && !placeholder.startsWith('actor')
+ const placeholders = extractPlaceholders(controlValue).filter(
+ (placeholder) => !unsupportedPrefixes.some((prefix) => placeholder.startsWith(prefix))
);
- return this.transformPlaceholderMapUseCase.execute({
- input: { regular: convertToRecord(strings) },
- }).payload;
+ 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) => {
diff --git a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts b/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts
deleted file mode 100644
index 7000216ab19..00000000000
--- a/apps/api/src/app/workflows-v2/usecases/placeholder-enrichment/transform-placeholder.usecase.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { PlaceholderMap } from './collect-placeholders-from-tip-tap-schema.usecase';
-
-export class TransformPlaceholderMapCommand {
- input: PlaceholderMap;
-}
-
-export class TransformPlaceholderMapUseCase {
- public execute(command: TransformPlaceholderMapCommand): Record {
- const defaultPayload: Record = {};
-
- const setNestedValue = (obj: Record, path: string, value: any) => {
- const keys = path.split('.');
- let current = obj;
-
- keys.forEach((key, index) => {
- if (!current.hasOwnProperty(key)) {
- current[key] = index === keys.length - 1 ? value : {};
- }
- current = current[key];
- });
- };
-
- this.processFor(command.input, setNestedValue, defaultPayload);
-
- for (const key in command.input.show) {
- if (command.input.show.hasOwnProperty(key)) {
- setNestedValue(defaultPayload, key, 'true');
- }
- }
-
- for (const key in command.input.regular) {
- if (command.input.regular.hasOwnProperty(key)) {
- setNestedValue(defaultPayload, key, `{{${key}}}`);
- }
- }
-
- return defaultPayload;
- }
-
- private processFor(
- input: PlaceholderMap,
- setNestedValue: (obj: Record, path: string, value: any) => void,
- defaultPayload: Record
- ) {
- for (const key in input.for) {
- if (input.for.hasOwnProperty(key)) {
- const items = input.for[key];
- const finalValue = [{}, {}];
- setNestedValue(defaultPayload, key, finalValue);
- items.forEach((item) => {
- const extractedKey = item.replace('item.', '');
- // TODO: extract to const
- const valueFunc = (suffix) => `{#${item}#}-${suffix}`;
- setNestedValue(finalValue[0], extractedKey, valueFunc('1'));
- setNestedValue(finalValue[1], extractedKey, valueFunc('2'));
- });
- }
- }
- }
-}
diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
index 42e4b88c0c5..961af38c516 100644
--- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
+++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
@@ -2,6 +2,7 @@ import { expect } from 'chai';
import { UserSession } from '@novu/testing';
import { randomBytes } from 'crypto';
import {
+ createWorkflowClient,
CreateWorkflowDto,
DEFAULT_WORKFLOW_PREFERENCES,
isStepUpdateBody,
@@ -22,7 +23,6 @@ import {
WorkflowListResponseDto,
WorkflowResponseDto,
} from '@novu/shared';
-import { createWorkflowClient } from './clients';
import { encodeBase62 } from '../shared/helpers';
import { stepTypeToDefaultDashboardControlSchema } from './shared';
@@ -73,21 +73,7 @@ describe('Workflow Controller E2E API Testing', () => {
if (res.isSuccessResult()) {
const workflowCreated: WorkflowResponseDto = res.value;
expect(workflowCreated.workflowId).to.include(`${slugify(nameSuffix)}-`);
- for (const step of workflowCreated.steps) {
- const stepDataDto = await getStepData(workflowCreated._id, step._id);
- expect(stepDataDto).to.be.ok;
- expect(stepDataDto.controls).to.be.ok;
- if (stepDataDto.controls) {
- expect(stepDataDto.controls.values).to.be.ok;
- expect(stepDataDto.controls.dataSchema).to.be.ok;
- expect(stepDataDto.controls.dataSchema).to.deep.equal(
- stepTypeToDefaultDashboardControlSchema[step.type].schema
- );
- expect(stepDataDto.controls.uiSchema).to.deep.equal(
- stepTypeToDefaultDashboardControlSchema[step.type].uiSchema
- );
- }
- }
+ await assertValuesInSteps(workflowCreated);
}
});
});
@@ -529,6 +515,23 @@ describe('Workflow Controller E2E API Testing', () => {
}
expect(convertToDate(updatedWorkflow.updatedAt)).to.be.greaterThan(convertToDate(expectedPastUpdatedAt));
}
+ async function assertValuesInSteps(workflowCreated: WorkflowResponseDto) {
+ for (const step of workflowCreated.steps) {
+ const stepDataDto = await getStepData(workflowCreated._id, step._id);
+ expect(stepDataDto).to.be.ok;
+ expect(stepDataDto.controls).to.be.ok;
+ if (stepDataDto.controls) {
+ expect(stepDataDto.controls.values).to.be.ok;
+ expect(stepDataDto.controls.dataSchema).to.be.ok;
+ expect(stepDataDto.controls.dataSchema).to.deep.equal(
+ stepTypeToDefaultDashboardControlSchema[step.type].schema
+ );
+ expect(stepDataDto.controls.uiSchema).to.deep.equal(
+ stepTypeToDefaultDashboardControlSchema[step.type].uiSchema
+ );
+ }
+ }
+ }
});
async function createWorkflowAndValidate(nameSuffix: string = ''): Promise {
diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts
index aff3b6bb9fd..c70cb692e99 100644
--- a/apps/api/src/app/workflows-v2/workflow.module.ts
+++ b/apps/api/src/app/workflows-v2/workflow.module.ts
@@ -20,12 +20,13 @@ import { GetWorkflowByIdsUseCase } from './usecases/get-workflow-by-ids/get-work
import { SyncToEnvironmentUseCase } from './usecases/sync-to-environment/sync-to-environment.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';
+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 { BuildPayloadNestedStructureUsecase } from './usecases/placeholder-enrichment/buildPayloadNestedStructureUsecase';
+import { BuildDefaultPayloadUseCase } from './usecases/build-payload-from-placeholder/build-default-payload-use-case.service';
@Module({
imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule],
@@ -44,12 +45,12 @@ import { GetStepDataUsecase } from './usecases/get-step-schema/get-step-data.use
SyncToEnvironmentUseCase,
GetStepDataUsecase,
GeneratePreviewUsecase,
- GetWorkflowUseCase,
- CreateMockPayloadUseCase,
+ CreateMockPayloadForSingleControlValueUseCase,
ExtractDefaultsUsecase,
- CollectPlaceholdersFromTipTapSchemaUsecase,
- TransformPlaceholderMapUseCase,
+ BuildPayloadNestedStructureUsecase,
WorkflowTestDataUseCase,
+ BuildDefaultPayloadUseCase,
+ HydrateEmailSchemaUseCase,
],
})
export class WorkflowModule implements NestModule {
diff --git a/apps/api/src/exception-filter.ts b/apps/api/src/exception-filter.ts
index c5ece85e5ff..00649e0983c 100644
--- a/apps/api/src/exception-filter.ts
+++ b/apps/api/src/exception-filter.ts
@@ -73,6 +73,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
let status: number;
let message: string | object;
+ if (exception instanceof ZodError) {
+ return handleZod(exception);
+ }
if (exception instanceof ZodError) {
return handleZod(exception);
}
@@ -122,7 +125,7 @@ export class ErrorDto {
message: string | object;
}
-function handleZod(exception: ZodError) {
+function handleZod(exception: ZodError) {
const status = HttpStatus.BAD_REQUEST; // Set appropriate status for ZodError
const message = {
errors: exception.errors.map((err) => ({
@@ -140,3 +143,16 @@ function handleCommandValidation(exception: CommandValidationException) {
return { message: { message, cause: mappedErrors }, status: HttpStatus.BAD_REQUEST };
}
+class MongoServerError {
+ code: number;
+ errmsg: string;
+ ok: number;
+ writeErrors?: {
+ index: number;
+ code: number;
+ errmsg: string;
+ op: any;
+ }[];
+ operationTime?: string;
+ clusterTime?: string;
+}
diff --git a/apps/api/src/app/workflows-v2/clients/index.ts b/packages/shared/src/clients/index.ts
similarity index 100%
rename from apps/api/src/app/workflows-v2/clients/index.ts
rename to packages/shared/src/clients/index.ts
diff --git a/apps/api/src/app/workflows-v2/clients/novu-base-client.ts b/packages/shared/src/clients/novu-base-client.ts
similarity index 100%
rename from apps/api/src/app/workflows-v2/clients/novu-base-client.ts
rename to packages/shared/src/clients/novu-base-client.ts
diff --git a/apps/api/src/app/workflows-v2/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts
similarity index 97%
rename from apps/api/src/app/workflows-v2/clients/workflows-client.ts
rename to packages/shared/src/clients/workflows-client.ts
index 529f90bd175..55a20ac23b0 100644
--- a/apps/api/src/app/workflows-v2/clients/workflows-client.ts
+++ b/packages/shared/src/clients/workflows-client.ts
@@ -1,16 +1,16 @@
+import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client';
import {
CreateWorkflowDto,
- GeneratePreviewRequestDto,
GeneratePreviewResponseDto,
GetListQueryParams,
ListWorkflowResponse,
- SyncWorkflowDto,
StepDataDto,
+ SyncWorkflowDto,
UpdateWorkflowDto,
WorkflowResponseDto,
WorkflowTestDataResponseDto,
-} from '@novu/shared';
-import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client';
+} from '../dto';
+import { GeneratePreviewRequestDto } from '../dto/workflows/generate-preview-request.dto';
// Define the WorkflowClient as a function that utilizes the base client
export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) => {
diff --git a/packages/shared/src/dto/index.ts b/packages/shared/src/dto/index.ts
index 67c6d22f09b..78c02ec3498 100644
--- a/packages/shared/src/dto/index.ts
+++ b/packages/shared/src/dto/index.ts
@@ -13,5 +13,4 @@ export * from './workflow-override';
export * from './widget';
export * from './session';
export * from './subscription';
-export * from './step-schemas';
export * from './controls';
diff --git a/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts b/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts
deleted file mode 100644
index 3199add41f3..00000000000
--- a/packages/shared/src/dto/step-schemas/control-preview-issue-type.enum.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export enum ControlPreviewIssueTypeEnum {
- MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD',
- VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH',
- MISSING_VALUE = 'MISSING_VALUE',
-}
diff --git a/packages/shared/src/dto/step-schemas/control-schemas.ts b/packages/shared/src/dto/step-schemas/control-schemas.ts
deleted file mode 100644
index ad7435c7909..00000000000
--- a/packages/shared/src/dto/step-schemas/control-schemas.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/* eslint-disable @typescript-eslint/naming-convention */
-
-export interface TipTapNode {
- type: string;
- content?: TipTapNode[];
- text?: string;
- attr?: Record;
-}
-
-export interface EmailStepControlSchemaDto {
- emailEditor: string;
- subject: string;
-}
diff --git a/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts b/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts
deleted file mode 100644
index 866a11ad8f2..00000000000
--- a/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { ChannelTypeEnum } from '../../types';
-import { ControlPreviewIssueTypeEnum } from './control-preview-issue-type.enum';
-
-export class RenderOutput {}
-
-export class ChatRenderOutput extends RenderOutput {
- body: string;
-}
-
-export class SmsRenderOutput extends RenderOutput {
- body: string;
-}
-
-export class PushRenderOutput extends RenderOutput {
- subject: string;
- body: string;
-}
-
-export class EmailRenderOutput extends RenderOutput {
- subject: string;
- body: string;
-}
-
-export enum RedirectTargetEnum {
- SELF = '_self',
- BLANK = '_blank',
- PARENT = '_parent',
- TOP = '_top',
- UNFENCED_TOP = '_unfencedTop',
-}
-
-export class InAppRenderOutput extends RenderOutput {
- subject?: string;
- body: string;
- avatar?: string;
- primaryAction?: {
- label: string;
- redirect: {
- url: string;
- target?: RedirectTargetEnum;
- };
- };
- secondaryAction?: {
- label: string;
- redirect: {
- url: string;
- target?: RedirectTargetEnum;
- };
- };
- data?: Record;
- redirect?: {
- url: string;
- target?: RedirectTargetEnum;
- };
-}
-
-export class ControlPreviewIssue {
- issueType: ControlPreviewIssueTypeEnum;
- variableName?: string;
- message: string;
-}
-
-export class GeneratePreviewResponseDto {
- issues: Record;
- result?:
- | {
- type: ChannelTypeEnum.EMAIL;
- preview: EmailRenderOutput;
- }
- | {
- type: ChannelTypeEnum.IN_APP;
- preview: InAppRenderOutput;
- }
- | {
- type: ChannelTypeEnum.SMS;
- preview: SmsRenderOutput;
- }
- | {
- type: ChannelTypeEnum.PUSH;
- preview: PushRenderOutput;
- }
- | {
- type: ChannelTypeEnum.CHAT;
- preview: ChatRenderOutput;
- };
-}
diff --git a/packages/shared/src/dto/step-schemas/index.ts b/packages/shared/src/dto/step-schemas/index.ts
deleted file mode 100644
index d9190b172df..00000000000
--- a/packages/shared/src/dto/step-schemas/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from './generate-preview-request.dto';
-export * from './generate-preview-response.dto';
-export * from './control-schemas';
-export * from './json-schema-dto';
-export * from './control-preview-issue-type.enum';
diff --git a/packages/shared/src/dto/workflows/control-schemas.ts b/packages/shared/src/dto/workflows/control-schemas.ts
new file mode 100644
index 00000000000..998fa944e7b
--- /dev/null
+++ b/packages/shared/src/dto/workflows/control-schemas.ts
@@ -0,0 +1,33 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { JSONSchemaDto } from './json-schema-dto';
+
+export interface TipTapNode {
+ type?: string;
+ attrs?: Record;
+ content?: TipTapNode[];
+ marks?: {
+ type: string;
+ attrs?: Record;
+ [key: string]: any;
+ }[];
+ text?: string;
+ [key: string]: any;
+}
+export interface EmailStepControlSchemaDto {
+ emailEditor: string;
+ subject: string;
+}
+
+export const EmailStepControlSchema: JSONSchemaDto = {
+ type: 'object',
+ properties: {
+ emailEditor: {
+ type: 'string',
+ },
+ subject: {
+ type: 'string',
+ },
+ },
+ required: ['emailEditor', 'subject'],
+ additionalProperties: false,
+};
diff --git a/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts b/packages/shared/src/dto/workflows/generate-preview-request.dto.ts
similarity index 65%
rename from packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts
rename to packages/shared/src/dto/workflows/generate-preview-request.dto.ts
index b8cff65e6bf..592a0c9ce96 100644
--- a/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts
+++ b/packages/shared/src/dto/workflows/generate-preview-request.dto.ts
@@ -1,4 +1,4 @@
-import { JSONSchemaDto } from './json-schema-dto';
+import { PreviewPayload } from './preview-step-response.dto';
export enum ValidationStrategyEnum {
VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION = 'VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION',
@@ -9,9 +9,7 @@ export enum ValidationStrategyEnum {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface GeneratePreviewRequestDto {
controlValues?: Record; // Optional control values
- payloadValues?: Record; // Optional payload values
- variablesSchema?: JSONSchemaDto; // Optional variables schema
- validationStrategies?: ValidationStrategyEnum[]; // Array of validation strategies
+ previewPayload?: PreviewPayload; // Optional payload values
}
// Export the GeneratePreviewRequestDto type
diff --git a/packages/shared/src/dto/workflows/index.ts b/packages/shared/src/dto/workflows/index.ts
index ee631adad42..f21be1f4d29 100644
--- a/packages/shared/src/dto/workflows/index.ts
+++ b/packages/shared/src/dto/workflows/index.ts
@@ -12,3 +12,7 @@ export * from './workflow-status-enum';
export * from './get-list-query-params';
export * from './workflow-test-data-response-dto';
export * from './step-data.dto';
+export * from './preview-step-response.dto';
+export * from './generate-preview-request.dto';
+export * from './control-schemas';
+export * from './json-schema-dto';
diff --git a/packages/shared/src/dto/step-schemas/json-schema-dto.ts b/packages/shared/src/dto/workflows/json-schema-dto.ts
similarity index 100%
rename from packages/shared/src/dto/step-schemas/json-schema-dto.ts
rename to packages/shared/src/dto/workflows/json-schema-dto.ts
diff --git a/packages/shared/src/dto/workflows/preview-step-response.dto.ts b/packages/shared/src/dto/workflows/preview-step-response.dto.ts
index 6ad46f93b9b..f9a9f7b8ccd 100644
--- a/packages/shared/src/dto/workflows/preview-step-response.dto.ts
+++ b/packages/shared/src/dto/workflows/preview-step-response.dto.ts
@@ -1,5 +1,5 @@
import { ChannelTypeEnum } from '../../types';
-import { ControlPreviewIssueTypeEnum } from '../step-schemas';
+import { SubscriberDto } from '../subscriber';
export class RenderOutput {}
@@ -53,14 +53,25 @@ export class InAppRenderOutput extends RenderOutput {
target?: RedirectTargetEnum;
};
}
+export enum ControlPreviewIssueTypeEnum {
+ MISSING_VARIABLE_IN_PAYLOAD = 'MISSING_VARIABLE_IN_PAYLOAD',
+ VARIABLE_TYPE_MISMATCH = 'VARIABLE_TYPE_MISMATCH',
+ MISSING_VALUE = 'MISSING_VALUE',
+}
export class ControlPreviewIssue {
issueType: ControlPreviewIssueTypeEnum;
variableName?: string;
message: string;
}
+export class PreviewPayload {
+ subscriber?: Partial;
+ payload?: Record;
+ steps?: Record; // step.stepId.unknown
+}
export class GeneratePreviewResponseDto {
+ previewPayloadExample: PreviewPayload;
issues: Record;
result?:
| {
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index dcd6580a561..5229abd137b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -28,3 +28,4 @@ export * from './ui';
export * from './utils';
export * from './services';
export * from './config';
+export * from './clients';
From 41ac91cb0700c730a1f49faaeead3fd336a35f3c Mon Sep 17 00:00:00 2001
From: GalT <39020298+tatarco@users.noreply.github.com>
Date: Wed, 6 Nov 2024 13:33:46 +0100
Subject: [PATCH 05/11] bug(api): spellcheck
---
.source | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.source b/.source
index 876e8a83c40..eaa283b302c 160000
--- a/.source
+++ b/.source
@@ -1 +1 @@
-Subproject commit 876e8a83c40a289f8359b75368efb5ea657bc359
+Subproject commit eaa283b302c4ae7ede66b89d628971e6a8d96b8a
From 9925031b2f47e24797ba122f336ccce0045e054f Mon Sep 17 00:00:00 2001
From: Gosha
Date: Wed, 6 Nov 2024 14:47:08 +0200
Subject: [PATCH 06/11] feat(api): get dynamic test data
---
.../usecases/test-data/test-data.usecase.ts | 108 ++++++++++++------
.../workflows-v2/workflow.controller.e2e.ts | 67 +++++++++++
.../shared/src/clients/workflows-client.ts | 1 +
3 files changed, 138 insertions(+), 38 deletions(-)
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
index bf1e5d8f583..77be4395345 100644
--- 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
@@ -1,57 +1,89 @@
import { JSONSchema } from 'json-schema-to-ts';
import { Injectable } from '@nestjs/common';
-import { NotificationTemplateEntity } from '@novu/dal';
-import { UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';
+import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal';
+import { ControlValuesLevelEnum, StepTypeEnum, UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';
import { WorkflowTestDataCommand } from './test-data.command';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command';
-
-const buildToFieldSchema = ({ user }: { user: UserSessionData }) =>
- ({
- type: 'object',
- properties: {
- subscriberId: { type: 'string', default: user._id },
- /*
- * TODO: the email and phone fields should be dynamic based on the workflow steps
- * if the workflow has has an email step, then email is required etc
- */
- email: { type: 'string', default: user.email ?? '', format: 'email' },
- phone: { type: 'string', default: '' },
- },
- required: ['subscriberId', 'email', 'phone'],
- additionalProperties: false,
- }) as const satisfies JSONSchema;
-
-const buildPayloadSchema = () =>
- ({
- type: 'object',
- description: 'Schema representing the workflow payload',
- properties: {
- /*
- * TODO: the properties should be dynamic based on the workflow variables
- */
- example: { type: 'string', description: 'Example field', default: 'payload.example' },
- },
- required: ['subscriberId', 'email', 'phone'],
- additionalProperties: false,
- }) as const satisfies JSONSchema;
+import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
+import { buildStringSchema } from '../../shared/build-string-schema';
@Injectable()
export class WorkflowTestDataUseCase {
- constructor(private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase) {}
+ constructor(
+ private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase,
+ private controlValuesRepository: ControlValuesRepository,
+ private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase
+ ) {}
async execute(command: WorkflowTestDataCommand): Promise {
- const _workflowEntity: NotificationTemplateEntity | null = await this.getWorkflowByIdsUseCase.execute(
+ const _workflowEntity: NotificationTemplateEntity = await this.fetchWorkflow(command);
+ const toSchema = buildToFieldSchema({ user: command.user, steps: _workflowEntity.steps });
+ const payloadSchema = await this.buildPayloadSchema(command, _workflowEntity);
+
+ return {
+ to: toSchema,
+ payload: payloadSchema,
+ };
+ }
+
+ private async fetchWorkflow(command: WorkflowTestDataCommand): Promise {
+ return await this.getWorkflowByIdsUseCase.execute(
GetWorkflowByIdsCommand.create({
...command,
identifierOrInternalId: command.identifierOrInternalId,
})
);
+ }
- return {
- to: buildToFieldSchema({ user: command.user }),
- payload: buildPayloadSchema(),
- };
+ private async buildPayloadSchema(command: WorkflowTestDataCommand, _workflowEntity: NotificationTemplateEntity) {
+ let payloadVariables: Record = {};
+ for (const step of _workflowEntity.steps) {
+ const newValues = await this.getValues(command.user, step._templateId, _workflowEntity._id);
+
+ /*
+ * we need to build the payload defaults for each step,
+ * because of possible duplicated values (like subject, body, etc...)
+ */
+ const currPayloadVariables = this.buildDefaultPayloadUseCase.execute({
+ controlValues: newValues,
+ }).previewPayload.payload;
+ payloadVariables = { ...payloadVariables, ...currPayloadVariables };
+ }
+
+ return buildStringSchema(payloadVariables || {});
+ }
+
+ 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 JSONSchema;
+};
+
+function isContainsStepType(steps: NotificationStepEntity[], type: StepTypeEnum) {
+ return steps.some((step) => step.template?.type === type);
+}
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 961af38c516..7e5fea0bef5 100644
--- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
+++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
@@ -424,6 +424,61 @@ describe('Workflow Controller E2E API Testing', () => {
});
});
+ describe('Get Test Data Permutations', () => {
+ it('should get step payload variables', async () => {
+ const steps = [
+ {
+ ...buildEmailStep(),
+ controlValues: {
+ body: 'Welcome to our newsletter {{bodyText}}{{bodyText2}}{{payload.emailPrefixBodyText}}',
+ subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}',
+ },
+ },
+ { ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{payload.inAppSubjectText}}' } },
+ ];
+ const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('', { steps });
+ const res = await session.testAgent.post(`${v2Prefix}/workflows`).send(createWorkflowDto);
+ expect(res.status).to.be.equal(201);
+ const workflowCreated: WorkflowResponseDto = res.body.data;
+ const workflowTestData = await getWorkflowTestData(workflowCreated._id);
+
+ expect(workflowTestData).to.be.ok;
+ expect(workflowTestData.payload).to.deep.equal({
+ type: 'object',
+ properties: {
+ emailPrefixBodyText: {
+ type: 'string',
+ default: '{{payload.emailPrefixBodyText}}',
+ },
+ prefixSubjectText: {
+ type: 'string',
+ default: '{{payload.prefixSubjectText}}',
+ },
+ inAppSubjectText: {
+ type: 'string',
+ default: '{{payload.inAppSubjectText}}',
+ },
+ },
+ });
+ expect(workflowTestData.to).to.deep.equal({
+ type: 'object',
+ properties: {
+ subscriberId: {
+ type: 'string',
+ default: `${session.user._id}`,
+ },
+ email: {
+ type: 'string',
+ default: `${session.user.email}`,
+ format: 'email',
+ },
+ },
+ required: ['subscriberId', 'email'],
+ additionalProperties: false,
+ });
+ });
+ });
+
async function updateWorkflowRest(id: string, workflow: UpdateWorkflowDto): Promise {
const novuRestResult = await workflowsClient.updateWorkflow(id, workflow);
if (novuRestResult.isSuccessResult()) {
@@ -484,6 +539,18 @@ describe('Workflow Controller E2E API Testing', () => {
return value;
}
+ async function getWorkflowTestData(workflowId: string, envId?: string) {
+ const novuRestResult = await createWorkflowClient(session.serverUrl, getHeaders(envId)).getWorkflowTestData(
+ workflowId
+ );
+ if (!novuRestResult.isSuccessResult()) {
+ throw new Error(novuRestResult.error!.responseText);
+ }
+ const { value } = novuRestResult;
+
+ return value;
+ }
+
async function getWorkflowStepControlValues(
workflow: WorkflowResponseDto,
step: StepDto & { _id: string; slug: Slug; stepId: string },
diff --git a/packages/shared/src/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts
index 55a20ac23b0..47828c16f1b 100644
--- a/packages/shared/src/clients/workflows-client.ts
+++ b/packages/shared/src/clients/workflows-client.ts
@@ -46,6 +46,7 @@ export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {})
): Promise> => {
return await baseClient.safeGet(`/v2/workflows/${workflowId}/steps/${stepId}`);
};
+
const deleteWorkflow = async (workflowId: string): Promise> => {
return await baseClient.safeDelete(`/v2/workflows/${workflowId}`);
};
From 63cb686b47fe24b682e9c7edd76c413d2e5fefd3 Mon Sep 17 00:00:00 2001
From: Gosha
Date: Wed, 6 Nov 2024 14:48:07 +0200
Subject: [PATCH 07/11]
NV-4600/refactor-new-workflowidtest-data-endpoint-to-pass-dynamic-data
---
.../shared/build-string-schema.ts | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 apps/api/src/app/workflows-v2/shared/build-string-schema.ts
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
new file mode 100644
index 00000000000..356ab64e65d
--- /dev/null
+++ b/apps/api/src/app/workflows-v2/shared/build-string-schema.ts
@@ -0,0 +1,20 @@
+import { JSONSchema } from 'json-schema-to-ts';
+
+/**
+ * Builds a JSON schema object where each variable becomes a string property.
+ */
+export function buildStringSchema(variables: Record): JSONSchema {
+ const properties: Record = {};
+
+ for (const [variableKey, variableValue] of Object.entries(variables)) {
+ properties[variableKey] = {
+ type: 'string',
+ default: variableValue,
+ };
+ }
+
+ return {
+ type: 'object',
+ properties,
+ };
+}
From 038207612103031ab82ce5298b2bc51c6c1be877 Mon Sep 17 00:00:00 2001
From: GalT <39020298+tatarco@users.noreply.github.com>
Date: Wed, 6 Nov 2024 13:51:12 +0100
Subject: [PATCH 08/11] bug(api): spellcheck
---
apps/api/src/app/workflows-v2/generate-preview.e2e.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 4e9d71b527d..985206f2d90 100644
--- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
+++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts
@@ -275,7 +275,7 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId: string): Generate
function buildDtoWithPayload(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto {
return {
- controlValues: getControlValues(stepId)[StepTypeEnum.EMAIL],
+ controlValues: getControlValues(stepId)[stepTypeEnum],
previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } },
};
}
From 18abe4cbbcdeb69a49a437c611c66a55b2c24264 Mon Sep 17 00:00:00 2001
From: Gosha
Date: Wed, 6 Nov 2024 17:21:08 +0200
Subject: [PATCH 09/11] feat(api): update after pr comments
---
.../shared/build-string-schema.ts | 2 +-
.../get-step-schema/get-step-data.usecase.ts | 24 +++----------------
.../usecases/test-data/test-data.usecase.ts | 4 ++--
.../src/dto/workflows/create-workflow-dto.ts | 1 -
4 files changed, 6 insertions(+), 25 deletions(-)
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
index 356ab64e65d..92d413018a0 100644
--- a/apps/api/src/app/workflows-v2/shared/build-string-schema.ts
+++ b/apps/api/src/app/workflows-v2/shared/build-string-schema.ts
@@ -3,7 +3,7 @@ import { JSONSchema } from 'json-schema-to-ts';
/**
* Builds a JSON schema object where each variable becomes a string property.
*/
-export function buildStringSchema(variables: Record): JSONSchema {
+export function buildJSONSchema(variables: Record): JSONSchema {
const properties: Record = {};
for (const [variableKey, variableValue] of Object.entries(variables)) {
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/get-step-schema/get-step-data.usecase.ts
index e4f731acab7..9d8b67bb501 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/get-step-schema/get-step-data.usecase.ts
@@ -7,6 +7,7 @@ import { mapStepTypeToResult } from '../../shared';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { InvalidStepException } from '../../exceptions/invalid-step.exception';
import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
+import { buildJSONSchema } from '../../shared/build-string-schema';
@Injectable()
export class GetStepDataUsecase {
@@ -39,12 +40,12 @@ export class GetStepDataUsecase {
};
}
- private buildPayloadSchema(controlValues: Record) {
+ private buildPayloadSchema(controlValues: Record) {
const payloadVariables = this.buildDefaultPayloadUseCase.execute({
controlValues,
}).previewPayload.payload;
- return buildStringSchema(payloadVariables || {});
+ return buildJSONSchema(payloadVariables || {});
}
private async fetchWorkflow(command: GetStepDataCommand) {
@@ -158,22 +159,3 @@ function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | unde
description: 'Previous Steps Results',
} as const satisfies JSONSchema;
}
-
-/**
- * Builds a JSON schema object where each variable becomes a string property.
- */
-function buildStringSchema(variables: Record): JSONSchema {
- const properties: Record = {};
-
- for (const [variableKey, variableValue] of Object.entries(variables)) {
- properties[variableKey] = {
- type: 'string',
- default: variableValue,
- };
- }
-
- return {
- type: 'object',
- properties,
- };
-}
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
index 77be4395345..b27bc27a002 100644
--- 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
@@ -7,7 +7,7 @@ import { WorkflowTestDataCommand } from './test-data.command';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command';
import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
-import { buildStringSchema } from '../../shared/build-string-schema';
+import { buildJSONSchema } from '../../shared/build-string-schema';
@Injectable()
export class WorkflowTestDataUseCase {
@@ -52,7 +52,7 @@ export class WorkflowTestDataUseCase {
payloadVariables = { ...payloadVariables, ...currPayloadVariables };
}
- return buildStringSchema(payloadVariables || {});
+ return buildJSONSchema(payloadVariables || {});
}
private async getValues(user: UserSessionData, _stepId: string, _workflowId: string) {
diff --git a/packages/shared/src/dto/workflows/create-workflow-dto.ts b/packages/shared/src/dto/workflows/create-workflow-dto.ts
index 460cb1a4f98..ad5a58b0b58 100644
--- a/packages/shared/src/dto/workflows/create-workflow-dto.ts
+++ b/packages/shared/src/dto/workflows/create-workflow-dto.ts
@@ -1,4 +1,3 @@
-import { IsDefined, IsNotEmpty, IsString } from 'class-validator';
import { PreferencesRequestDto, StepCreateDto, WorkflowCommonsFields } from './workflow-commons-fields';
import { WorkflowCreationSourceEnum } from '../../types';
From 5cff9b003661691096a484e093a13dac4ed52d95 Mon Sep 17 00:00:00 2001
From: Gosha
Date: Wed, 6 Nov 2024 17:34:32 +0200
Subject: [PATCH 10/11] feat(api): fix test name
---
apps/api/src/app/workflows-v2/workflow.controller.e2e.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 79826c12cab..4984acda1e8 100644
--- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
+++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
@@ -463,7 +463,7 @@ describe('Workflow Controller E2E API Testing', () => {
});
describe('Get Test Data Permutations', () => {
- it('should get step payload variables', async () => {
+ it('should get test data', async () => {
const steps = [
{
...buildEmailStep(),
From 73076d4552ed2d98ff2acb0022c7987f4f82a252 Mon Sep 17 00:00:00 2001
From: Gosha
Date: Thu, 7 Nov 2024 11:34:00 +0200
Subject: [PATCH 11/11] feat(api): fix test in ci
---
.../workflows-v2/workflow.controller.e2e.ts | 41 +++++++++++--------
1 file changed, 25 insertions(+), 16 deletions(-)
diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
index 4984acda1e8..7094dd0f212 100644
--- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
+++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
@@ -498,22 +498,31 @@ describe('Workflow Controller E2E API Testing', () => {
},
},
});
- expect(workflowTestData.to).to.deep.equal({
- type: 'object',
- properties: {
- subscriberId: {
- type: 'string',
- default: `${session.user._id}`,
- },
- email: {
- type: 'string',
- default: `${session.user.email}`,
- format: 'email',
- },
- },
- required: ['subscriberId', 'email'],
- additionalProperties: false,
- });
+
+ /*
+ * Validate the 'to' schema
+ * Note: Can't use deep comparison since emails differ between local and CI environments due to user sessions
+ */
+ const toSchema = workflowTestData.to;
+ if (
+ typeof toSchema === 'boolean' ||
+ typeof toSchema.properties?.subscriberId === 'boolean' ||
+ typeof toSchema.properties?.email === 'boolean'
+ ) {
+ expect((toSchema as any).type).to.be.a('boolean');
+ expect(((toSchema as any).properties?.subscriberId as any).type).to.be.a('boolean');
+ expect(((toSchema as any).properties?.email as any).type).to.be.a('boolean');
+ throw new Error('To schema is not a boolean');
+ }
+ expect(toSchema.type).to.equal('object');
+ expect(toSchema.properties?.subscriberId.type).to.equal('string');
+ expect(toSchema.properties?.subscriberId.default).to.equal(session.user._id);
+ expect(toSchema.properties?.email.type).to.equal('string');
+ expect(toSchema.properties?.email.format).to.equal('email');
+ expect(toSchema.properties?.email.default).to.be.a('string');
+ expect(toSchema.properties?.email.default).to.not.equal('');
+ expect(toSchema.required).to.deep.equal(['subscriberId', 'email']);
+ expect(toSchema.additionalProperties).to.be.false;
});
});