Skip to content

Commit

Permalink
feat(api): adding instant preview endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
tatarco committed Oct 10, 2024
1 parent 5c537c8 commit 12fdc6b
Show file tree
Hide file tree
Showing 29 changed files with 1,290 additions and 81 deletions.
7 changes: 5 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"@sendgrid/mail": "^8.1.0",
"@sentry/browser": "^8.33.1",
"@sentry/hub": "^7.114.0",
"@sentry/node": "^8.33.1",
"@sentry/nestjs": "^8.33.1",
"@sentry/node": "^8.33.1",
"@sentry/profiling-node": "^8.33.1",
"@sentry/tracing": "^7.40.0",
"@types/newrelic": "^9.14.0",
Expand All @@ -67,6 +67,7 @@
"date-fns": "^2.29.2",
"dotenv": "^16.4.5",
"envalid": "^8.0.0",
"faker": "^6.6.6",
"handlebars": "^4.7.7",
"helmet": "^6.0.1",
"i18next": "^23.7.6",
Expand All @@ -93,7 +94,8 @@
"slugify": "^1.4.6",
"swagger-ui-express": "^4.4.0",
"twilio": "^4.14.1",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@faker-js/faker": "^6.0.0",
Expand All @@ -105,6 +107,7 @@
"@types/bull": "^3.15.8",
"@types/chai": "^4.2.11",
"@types/express": "4.17.17",
"@types/faker": "^6.6.9",
"@types/mocha": "^10.0.2",
"@types/node": "^20.15.0",
"@types/passport-github": "^1.1.5",
Expand Down
141 changes: 84 additions & 57 deletions apps/api/src/app/bridge/usecases/preview-step/preview-step.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { createHmac } from 'crypto';
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import { BadRequestException, Injectable } from '@nestjs/common';
import { PostActionEnum, HttpQueryKeysEnum } from '@novu/framework';

import { EnvironmentRepository } from '@novu/dal';
import { Event, PostActionEnum } from '@novu/framework';
import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal';
import { decryptApiKey } from '@novu/application-generic';

import { PreviewStepCommand } from './preview-step.command';
import { BridgeErrorCodeEnum } from '../../shared';

Expand All @@ -15,81 +16,107 @@ export class PreviewStep {

async execute(command: PreviewStepCommand) {
const environment = await this.environmentRepository.findOne({ _id: command.environmentId });
const bridgeUrl = this.assertBridgeUrl(command, environment);
try {
const response = await this.restCallBridgeWithPreviewCommand(command, environment, bridgeUrl);
this.assertHasPreviewOutput(response);

return response.data;
} catch (e) {
throw this.buildBridgeErrors(e, bridgeUrl);
}
}

private assertHasPreviewOutput(
response: AxiosResponse
): asserts response is AxiosResponse & { data: { outputs: any; metadata: any } } {
if (!response.data?.outputs || !response.data?.metadata) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE,
message: JSON.stringify(response.data),
});
}
}
private async restCallBridgeWithPreviewCommand(
command: PreviewStepCommand,
environment: EnvironmentEntity | null,
bridgeUrl: string
) {
const payload = this.mapPayload(command);
const novuSignatureHeader = this.buildNovuSignature(environment, payload);
const bridgeActionUrl = new URL(bridgeUrl);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.ACTION, PostActionEnum.PREVIEW);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.WORKFLOW_ID, command.workflowId);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.STEP_ID, command.stepId);

return await axios.create().post(url, payload, {
headers: {
'content-type': 'application/json',
'x-novu-signature': novuSignatureHeader,
'novu-signature': novuSignatureHeader,
},
});
}
private assertBridgeUrl(command: PreviewStepCommand, environment) {
const bridgeUrl = command.bridgeUrl || environment?.echo.url;
if (!bridgeUrl) {
throw new BadRequestException('Bridge URL not found');
}

const axiosInstance = axios.create();
try {
const payload = this.mapPayload(command);
const novuSignatureHeader = this.buildNovuSignature(environment, payload);
const bridgeActionUrl = new URL(bridgeUrl);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.ACTION, PostActionEnum.PREVIEW);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.WORKFLOW_ID, command.workflowId);
bridgeActionUrl.searchParams.set(HttpQueryKeysEnum.STEP_ID, command.stepId);

const response = await axiosInstance.post(bridgeActionUrl.toString(), payload, {
headers: {
'content-type': 'application/json',
'x-novu-signature': novuSignatureHeader,
'novu-signature': novuSignatureHeader,
},
return bridgeUrl;
}

private buildBridgeErrors(e: any, bridgeUrl: string): Error {
if (e?.response?.status === 404) {
return new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_ENDPOINT_NOT_FOUND,
message: `Bridge Endpoint Was not found or not accessible. Endpoint: ${bridgeUrl}`,
});
}

if (!response.data?.outputs || !response.data?.metadata) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE,
message: JSON.stringify(response.data),
});
}
if (e?.response?.status === 405) {
return new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_ENDPOINT_NOT_FOUND,
message: `Bridge Endpoint is not properly configured. : ${bridgeUrl}`,
});
}

return response.data;
} catch (e: any) {
if (e?.response?.status === 404) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_ENDPOINT_NOT_FOUND,
message: `Bridge Endpoint Was not found or not accessible. Endpoint: ${bridgeUrl}`,
});
}

if (e?.response?.status === 405) {
throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_ENDPOINT_NOT_FOUND,
message: `Bridge Endpoint is not properly configured. : ${bridgeUrl}`,
});
}

if (e.code === BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE) {
throw e;
}

// todo add status indication - check if e?.response?.status === 400 here
if (e?.response?.data) {
throw new BadRequestException(e.response.data);
}
if (e.code === BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE) {
return e;
}

throw new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE,
message: `Un-expected Bridge response: ${e.message}`,
});
// todo add status indication - check if e?.response?.status === 400 here
if (e?.response?.data) {
return new BadRequestException(e.response.data);
}

return new BadRequestException({
code: BridgeErrorCodeEnum.BRIDGE_UNEXPECTED_RESPONSE,
message: `Un-expected Bridge response: ${e.message}`,
});
}

private mapPayload(command: PreviewStepCommand) {
const payload = {
inputs: command.controls || command.inputs || {},
controls: command.controls || command.inputs || {},
private mapPayload(command: PreviewStepCommand): Event {
return {
payload: {},
workflowId: command.workflowId,
stepId: command.stepId,
inputs: command.inputs || {},
controls: command.controls || {},
data: command.data || {},
action: PostActionEnum.PREVIEW,
subscriber: {},
state: [
{
stepId: 'trigger',
outputs: command.data || {},
state: {
status: '',
error: undefined,
},
},
],
};

return payload;
}

private buildNovuSignature(
Expand Down
59 changes: 44 additions & 15 deletions apps/api/src/app/environments/environments.bridge.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
Controller,
Get,
NotFoundException,
Options,
Param,
Post,
Query,
Expand All @@ -15,21 +14,17 @@ import {
import { Request, Response } from 'express';
import { ApiTags } from '@nestjs/swagger';
import { decryptApiKey } from '@novu/application-generic';
import { EnvironmentRepository, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal';
import { Client, Event, workflow, Step, Workflow } from '@novu/framework';
import {
EnvironmentRepository,
NotificationStepEntity,
NotificationTemplateEntity,
NotificationTemplateRepository,
} from '@novu/dal';
import { Client, Event, Step, Workflow, workflow, WorkflowChannelEnum } from '@novu/framework';
import { PreviewResult, StepTypeEnum, TRANSIENT_PREVIEW_PREFIX } from '@novu/shared';
import { ApiCommonResponses } from '../shared/framework/response.decorator';
import { NovuNestjsHandler } from './novu-nestjs-handler';

// Unfortunately we need this mapper because the `in_app` step type uses `step.inApp()` in Framework.
const stepFnFromStepType: Record<Exclude<StepTypeEnum, StepTypeEnum.CUSTOM | StepTypeEnum.TRIGGER>, keyof Step> = {
[StepTypeEnum.IN_APP]: 'inApp',
[StepTypeEnum.EMAIL]: 'email',
[StepTypeEnum.SMS]: 'sms',
[StepTypeEnum.CHAT]: 'chat',
[StepTypeEnum.PUSH]: 'push',
[StepTypeEnum.DIGEST]: 'digest',
[StepTypeEnum.DELAY]: 'delay',
};
import { OutputRendererFactory } from './render/output-render-factory';

@ApiCommonResponses()
@Controller('/environments')
Expand Down Expand Up @@ -69,6 +64,9 @@ export class EnvironmentsBridgeController {
@Body() event: Event
) {
const foundWorkflow = await this.getWorkflow(environmentId, workflowId);
if (shouldMockStepsForPreview(event)) {
foundWorkflow.steps = [this.buildPreviewStep(event)];
}

const programmaticallyCreatedWorkflow = this.createWorkflow(foundWorkflow, event.controls);

Expand Down Expand Up @@ -107,9 +105,11 @@ export class EnvironmentsBridgeController {
newWorkflow.name,
async ({ step }) => {
for await (const staticStep of newWorkflow.steps) {
await step[stepFnFromStepType[staticStep.template!.type]](staticStep.stepId, () => controls, {
const stepFnFromStepTypeElement = stepFnFromStepType[staticStep.template!.type];
await step[stepFnFromStepTypeElement](staticStep.stepId, () => controls, {
// TODO: fix the step typings, `controls` lives on template property, not step
controlSchema: (staticStep.template as unknown as typeof staticStep).controls?.schema,
outputSchema: renderOutputSchema(staticStep.template!.type, controls),
/*
* TODO: add conditions
* Used to construct conditions defined with https://react-querybuilder.js.org/ or similar
Expand All @@ -129,4 +129,33 @@ export class EnvironmentsBridgeController {
}
);
}

private buildPreviewStep(event: Event): NotificationStepEntity {
const idWithoutPrefix = event.stepId.replace(TRANSIENT_PREVIEW_PREFIX, '');

return {
_templateId: idWithoutPrefix,
template: {
type: idWithoutPrefix as unknown as StepTypeEnum,
content: 'mock content',
},
};
}
}
function renderOutputSchema(type: StepTypeEnum, controls: Record<string, unknown>): PreviewResult {
return OutputRendererFactory.createRenderer(type).render(controls);
}
// Unfortunately we need this mapper because the `in_app` step type uses `step.inApp()` in Framework.
const stepFnFromStepType: Record<Exclude<StepTypeEnum, StepTypeEnum.CUSTOM | StepTypeEnum.TRIGGER>, keyof Step> = {
[StepTypeEnum.IN_APP]: WorkflowChannelEnum.IN_APP,
[StepTypeEnum.EMAIL]: WorkflowChannelEnum.EMAIL,
[StepTypeEnum.SMS]: WorkflowChannelEnum.SMS,
[StepTypeEnum.CHAT]: WorkflowChannelEnum.CHAT,
[StepTypeEnum.PUSH]: WorkflowChannelEnum.PUSH,
[StepTypeEnum.DIGEST]: 'digest',
[StepTypeEnum.DELAY]: 'delay',
};

function shouldMockStepsForPreview(event: Event) {
return event.stepId.startsWith(TRANSIENT_PREVIEW_PREFIX);
}
12 changes: 12 additions & 0 deletions apps/api/src/app/environments/render/email-preview-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { EMAIL_EDITOR_JSON_KEY, EmailPreviewResult } from '@novu/shared';
import { Renderer } from './output-render-factory';
import { expendSchema, TiptapNode } from './email-schema-extender';

export class EmailPreviewRenderer implements Renderer<EmailPreviewResult> {
render(controlValues: Record<string, unknown>): EmailPreviewResult {
const subject = (controlValues.subject as string) || 'Default Email Subject';
const body = expendSchema(controlValues[EMAIL_EDITOR_JSON_KEY] as TiptapNode);

return { subject, body: JSON.stringify(body) };
}
}
75 changes: 75 additions & 0 deletions apps/api/src/app/environments/render/email-schema-extender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* eslint-disable */
export type TiptapNode = {
type: string;
content?: TiptapNode[];
text?: string;
attr?: Record<string, any>;
};

export function expendSchema(schema: TiptapNode): TiptapNode {
const content = schema.content!.map(processNodeRecursive).filter((x) => Boolean(x)) as TiptapNode[];

return { ...schema, content };
}

function processItemNode(node: TiptapNode, item: any): TiptapNode {
if (node.type === 'text' && typeof node.text === 'string') {
const regex = /{{(novu\.item\.(\w+))}}/g;
node.text = node.text.replace(regex, (_, key: string) => {
const propertyName = key.split('.')[2];

return item[propertyName] !== undefined ? item[propertyName] : _;
});
}

if (node.content) {
node.content = node.content.map((innerNode) => processItemNode(innerNode, item));
}

return node;
}

const processNodeRecursive = (node: TiptapNode): TiptapNode | null => {
if (node.type === 'show') {
const whenValue = node.attr?.when;
if (whenValue !== 'true') {
return null;
}
}

if (hasEachAttr(node)) {
return handleFor(node);
}

return processNodeContent(node);
};

const processNodeContent = (node: TiptapNode): TiptapNode | null => {
if (node.content) {
node.content = node.content.map(processNodeRecursive).filter(Boolean) as TiptapNode[];
}
return node;
};

function hasEachAttr(node: TiptapNode): node is TiptapNode & { attr: { each: any } } {
return node.attr !== undefined && node.attr.each !== undefined;
}

function handleFor(node: TiptapNode & { attr: { each: any } }) {
const items = node.attr.each;
const newContent: TiptapNode[] = [];

for (const item of items) {
const newNode = { ...node };
newNode.content =
newNode.content?.map((innerNode) => {
return processItemNode(innerNode, item);
}) || [];

if (newNode.content) {
newContent.push(...newNode.content);
}
}

return { type: 'container', content: newContent };
}
Loading

0 comments on commit 12fdc6b

Please sign in to comment.