Skip to content

Commit

Permalink
feat(api): Completed maily hydration and expension logic with e2e tes…
Browse files Browse the repository at this point in the history
…ting
  • Loading branch information
tatarco committed Oct 27, 2024
1 parent dc51c99 commit d68c467
Show file tree
Hide file tree
Showing 17 changed files with 1,263 additions and 422 deletions.
2 changes: 2 additions & 0 deletions apps/api/src/app/environments-v1/novu-bridge.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PushOutputRendererUsecase,
SmsOutputRendererUsecase,
} from './usecases/output-renderers';
import { HydrateEmailSchemaUseCase } from './usecases/output-renderers/hydrate-email-schema.usecase';

@Module({
controllers: [NovuBridgeController],
Expand All @@ -34,6 +35,7 @@ import {
PushOutputRendererUsecase,
EmailOutputRendererUsecase,
ExpandEmailEditorSchemaUsecase,
HydrateEmailSchemaUseCase,
],
})
export class NovuBridgeModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import {
SmsOutputRendererUsecase,
} from '../output-renderers';

// eslint-disable-next-line @typescript-eslint/naming-convention
export interface MasterPayload {
subscriber: Record<string, unknown>;
payload: Record<string, unknown>;
steps: Record<string, unknown>; // step.stepId.unkown

Check warning on line 28 in apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts

View workflow job for this annotation

GitHub Actions / Spell check

Misspelled word (unkown) Suggestions: (unknown*)
}
@Injectable()
export class ConstructFrameworkWorkflow {
constructor(
Expand All @@ -46,9 +52,14 @@ export class ConstructFrameworkWorkflow {
private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity): Workflow {
return workflow(
newWorkflow.triggers[0].identifier,
async ({ step }) => {
async ({ step, payload, subscriber }) => {
const masterPayload: MasterPayload = { payload, subscriber, steps: {} };
for await (const staticStep of newWorkflow.steps) {
await this.constructStep(step, staticStep);
masterPayload.steps[staticStep.stepId || staticStep._templateId] = await this.constructStep(
step,
staticStep,
masterPayload
);
}
},
{
Expand All @@ -66,7 +77,11 @@ export class ConstructFrameworkWorkflow {
);
}

private constructStep(step: Step, staticStep: NotificationStepEntity): StepOutput<Record<string, unknown>> {
private constructStep(
step: Step,
staticStep: NotificationStepEntity,
masterPayload: MasterPayload
): StepOutput<Record<string, unknown>> {
const stepTemplate = staticStep.template;

if (!stepTemplate) {
Expand Down Expand Up @@ -100,7 +115,7 @@ export class ConstructFrameworkWorkflow {
return step.email(
stepId,
async (controlValues) => {
return this.emailOutputRendererUseCase.execute({ controlValues });
return this.emailOutputRendererUseCase.execute({ controlValues, masterPayload });
},
this.constructChannelStepOptions(staticStep)
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,49 @@
import { EmailRenderOutput, TipTapNode } from '@novu/shared';
import { render } from '@maily-to/render';
import { z } from 'zod';
import { Injectable } from '@nestjs/common';
import { render } from '@maily-to/render';
import { RenderCommand } from './render-command';
import { MasterPayload } from '../construct-framework-workflow';
import { ExpandEmailEditorSchemaUsecase } from './email-schema-expander.usecase';
import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase';

export class EmailOutputRendererCommand extends RenderCommand {
masterPayload: MasterPayload;
}

@Injectable()
export class EmailOutputRendererUsecase {
constructor(private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {}
constructor(
private expendEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase,
private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase // Inject the new use case
) {}

async execute(renderCommand: RenderCommand): Promise<EmailRenderOutput> {
const parse = EmailStepControlSchema.parse(renderCommand.controlValues);
const schema = parse.emailEditor as TipTapNode;
const expandedSchema = this.expendEmailEditorSchemaUseCase.execute({ schema });
const html = await render(expandedSchema);
async execute(renderCommand: EmailOutputRendererCommand): Promise<EmailRenderOutput> {
const { emailEditor, subject } = EmailStepControlSchema.parse(renderCommand.controlValues);
const emailSchemaHydrated = this.hydrate(emailEditor, renderCommand);
const expandedSchema = this.transformForAndShowLogic(emailSchemaHydrated);
const htmlRendered = await render(expandedSchema);

return { subject: parse.subject, body: html };
return { subject, body: htmlRendered };
}
}
const emailContentSchema = z
.object({
type: z.string(),
content: z.array(z.lazy(() => emailContentSchema)).optional(),
text: z.string().optional(),
attr: z.record(z.unknown()).optional(),
})
.strict();

const emailEditorSchema = z
.object({
type: z.string(),
content: z.array(emailContentSchema).optional(),
text: z.string().optional(),
attr: z.record(z.unknown()).optional(),
})
.strict();
private transformForAndShowLogic(body: TipTapNode) {
return this.expendEmailEditorSchemaUseCase.execute({ schema: body });
}

private hydrate(emailEditor: string, renderCommand: EmailOutputRendererCommand) {
const { hydratedEmailSchema } = this.hydrateEmailSchemaUseCase.execute({
emailEditor,
masterPayload: renderCommand.masterPayload,
});

return hydratedEmailSchema;
}
}

export const EmailStepControlSchema = z
.object({
emailEditor: emailEditorSchema,
emailEditor: z.string(),
subject: z.string(),
})
.strict();
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,146 @@
import { TipTapNode } from '@novu/shared';
import { ExpendEmailEditorSchemaCommand } from './expend-email-editor-schema-command';

// Rename the class to ExpendEmailEditorSchemaUseCase
export class ExpandEmailEditorSchemaUsecase {
execute(command: ExpendEmailEditorSchemaCommand): TipTapNode {
return this.expendSchema(command.schema);
}

private expendSchema(schema: TipTapNode): TipTapNode {
// todo: try to avoid !
const content = schema.content!.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[];
this.traverseAndAugment(command.schema, undefined);

return { ...schema, content };
return command.schema;
}

private processItemNode(node: TipTapNode, item: any): TipTapNode {
if (node.type === 'text' && typeof node.text === 'string') {
const regex = /{#item\.(\w+)#}/g;
node.text = node.text.replace(regex, (_, key: string) => {
const propertyName = key;

return item[propertyName] !== undefined ? item[propertyName] : _;
private traverseAndAugment(node: TipTapNode, parentNode?: TipTapNode) {
if (node.content) {
node.content.forEach((innerNode) => {
this.traverseAndAugment(innerNode, node);
});
}
if (this.hasShow(node)) {
this.hideShowIfNeeded(node, parentNode);
} else if (this.hasEach(node)) {
const newContent = this.expendedForEach(node);
node.content = newContent;
this.removeForNodeAndUpgradeContent(node, newContent, parentNode);
}
}

if (node.content) {
node.content = node.content.map((innerNode) => this.processItemNode(innerNode, item));
private removeForNodeAndUpgradeContent(node: TipTapNode, expandedContents: TipTapNode[], parentNode?: TipTapNode) {
if (parentNode && parentNode.content) {
this.insertArrayAt(parentNode.content, parentNode.content.indexOf(node), expandedContents);
parentNode.content.splice(parentNode.content.indexOf(node), 1);
}
}

private insertArrayAt(array: any[], index: number, newArray: any[]) {
if (index < 0 || index > array.length) {
throw new Error('Index out of bounds');
}
array.splice(index, 0, ...newArray);
}

return node;
private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { each: unknown } } {
return !!(node.attrs && 'each' in node.attrs);
}

private processNodeRecursive(node: TipTapNode): TipTapNode | null {
if (node.type === 'show') {
const whenValue = node.attr?.when;
if (whenValue !== 'true') {
return null;
}
private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { show: string } } {
return !!(node.attrs && 'show' in node.attrs);
}

private regularExpension(eachObject: any, templateContent: TipTapNode[]): TipTapNode[] {

Check warning on line 49 in apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (Expension)
const expandedContent: TipTapNode[] = [];
const jsonArrOfValues = eachObject as unknown as [{ [key: string]: string }];

for (const value of jsonArrOfValues) {
const hydratedContent = this.replacePlaceholders(templateContent, value);
expandedContent.push(...hydratedContent);
}

if (this.hasEachAttr(node)) {
return { type: 'section', content: this.handleFor(node) };
return expandedContent;
}

private isOrderedList(templateContent: TipTapNode[]) {
return templateContent.length === 1 && templateContent[0].type === 'orderedList';
}

private isBulletList(templateContent: TipTapNode[]) {
return templateContent.length === 1 && templateContent[0].type === 'bulletList';
}

private expendedForEach(node: TipTapNode & { attrs: { each: unknown } }): TipTapNode[] {
const eachObject = node.attrs.each;
const templateContent = node.content || [];

if (this.isOrderedList(templateContent) && templateContent[0].content) {
return [{ ...templateContent[0], content: this.regularExpension(eachObject, templateContent[0].content) }];

Check warning on line 74 in apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (Expension)
}
if (this.isBulletList(templateContent) && templateContent[0].content) {
return [{ ...templateContent[0], content: this.regularExpension(eachObject, templateContent[0].content) }];

Check warning on line 77 in apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (Expension)
}

return this.processNodeContent(node);
return this.regularExpension(eachObject, templateContent);

Check warning on line 80 in apps/api/src/app/environments-v1/usecases/output-renderers/email-schema-expander.usecase.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (Expension)
}

private processNodeContent(node: TipTapNode): TipTapNode | null {
if (node.content) {
node.content = node.content.map(this.processNodeRecursive.bind(this)).filter(Boolean) as TipTapNode[];
private removeNodeFromParent(node: TipTapNode, parentNode?: TipTapNode) {
if (parentNode && parentNode.content) {
parentNode.content.splice(parentNode.content.indexOf(node), 1);
}
}

return node;
private hideShowIfNeeded(node: TipTapNode & { attrs: { show: unknown } }, parentNode?: TipTapNode): void {
const { show } = node.attrs;
const shouldShow = typeof show === 'boolean' ? show : this.stringToBoolean(show);

if (!shouldShow) {
this.removeNodeFromParent(node, parentNode);
} else {
delete node.attrs.show;
}
}

private hasEachAttr(node: TipTapNode): node is TipTapNode & { attr: { each: any } } {
return node.attr !== undefined && node.attr.each !== undefined;
private stringToBoolean(value: unknown): boolean {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}

return false;
}

private handleFor(node: TipTapNode & { attr: { each: any } }): TipTapNode[] {
const items = node.attr.each;
const newContent: TipTapNode[] = [];
private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { id: string } } {
return newNode.type === 'payloadValue' && newNode.attrs?.id !== undefined;
}

const itemsParsed = JSON.parse(items.replace(/'/g, '"'));
for (const item of itemsParsed) {
const newNode = { ...node };
newNode.content = newNode.content?.map((innerNode) => this.processItemNode(innerNode, item)) || [];
if (newNode.content) {
newContent.push(...newNode.content);
private replacePlaceholders(nodes: TipTapNode[], payload: Record<string, any>): 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<string, any>, path: string): any {
if (path in obj) {
return obj[path];
}

return newContent;
const keys = path.split('.');

return keys.reduce((currentObj, key) => {
if (currentObj && typeof currentObj === 'object' && key in currentObj) {
return currentObj[key];
}

return undefined;
}, obj);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { BaseCommand } from '@novu/application-generic';
import { TipTapNode } from '@novu/shared';

// eslint-disable-next-line @typescript-eslint/naming-convention
export interface ExpendEmailEditorSchemaCommand extends BaseCommand {
export class ExpendEmailEditorSchemaCommand extends BaseCommand {
schema: TipTapNode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// New HydrateEmailSchemaUseCase class
import { MasterPayload } from '../construct-framework-workflow';

export class HydrateEmailSchemaCommand {
emailEditor: string;
masterPayload: MasterPayload;
}
Loading

0 comments on commit d68c467

Please sign in to comment.