From 12fdc6bd41f262a7fe3497dfef57fb7841b733d1 Mon Sep 17 00:00:00 2001 From: GalT <39020298+tatarco@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:03:28 +0200 Subject: [PATCH] feat(api): adding instant preview endpoint --- apps/api/package.json | 7 +- .../preview-step/preview-step.usecase.ts | 141 +++++++++------ .../environments.bridge.controller.ts | 59 ++++-- .../render/email-preview-renderer.ts | 12 ++ .../render/email-schema-extender.ts | 75 ++++++++ .../render/hydration-component.ts | 111 ++++++++++++ .../render/output-render-factory.ts | 108 +++++++++++ apps/api/src/app/pipes/zod-validation-pipe.ts | 18 ++ .../variable-validator-component.ts | 153 ++++++++++++++++ .../step-schemas/e2e/generate-preview.e2e.ts | 168 ++++++++++++++++++ .../step-schemas/step-schemas.controller.ts | 38 +++- .../generate-preview-command.ts | 7 + .../generate-preview-use-case.ts | 63 +++++++ .../workflows-v2/workflow.controller.e2e.ts | 2 +- apps/api/src/exception-filter.ts | 6 +- apps/dashboard/package.json | 1 + packages/shared/package.json | 7 +- packages/shared/src/clients/index.ts | 3 + .../shared/src/clients/novu-base-client.ts | 141 +++++++++++++++ .../shared/src/clients/step-schemas-client.ts | 25 +++ .../shared/src/clients/workflows-client.ts | 57 ++++++ packages/shared/src/dto/index.ts | 1 + packages/shared/src/dto/json-scehma-zod.ts | 26 +++ .../control-preview-issue.type.ts | 6 + .../dto/step-schemas/controlPreviewIssue.ts | 8 + .../generate-preview-request.dto.ts | 42 +++++ .../generate-preview-response.dto.ts | 81 +++++++++ packages/shared/src/dto/step-schemas/index.ts | 4 + packages/shared/src/index.ts | 1 + 29 files changed, 1290 insertions(+), 81 deletions(-) create mode 100644 apps/api/src/app/environments/render/email-preview-renderer.ts create mode 100644 apps/api/src/app/environments/render/email-schema-extender.ts create mode 100644 apps/api/src/app/environments/render/hydration-component.ts create mode 100644 apps/api/src/app/environments/render/output-render-factory.ts create mode 100644 apps/api/src/app/pipes/zod-validation-pipe.ts create mode 100644 apps/api/src/app/step-schemas/components/variable-validator-component.ts create mode 100644 apps/api/src/app/step-schemas/e2e/generate-preview.e2e.ts create mode 100644 apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-command.ts create mode 100644 apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-use-case.ts create mode 100644 packages/shared/src/clients/index.ts create mode 100644 packages/shared/src/clients/novu-base-client.ts create mode 100644 packages/shared/src/clients/step-schemas-client.ts create mode 100644 packages/shared/src/clients/workflows-client.ts create mode 100644 packages/shared/src/dto/json-scehma-zod.ts create mode 100644 packages/shared/src/dto/step-schemas/control-preview-issue.type.ts create mode 100644 packages/shared/src/dto/step-schemas/controlPreviewIssue.ts create mode 100644 packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts create mode 100644 packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts create mode 100644 packages/shared/src/dto/step-schemas/index.ts diff --git a/apps/api/package.json b/apps/api/package.json index d29805e8705..6dc678afba3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", @@ -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", @@ -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", @@ -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", 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 f5358cc0acb..bf404a10875 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,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'; @@ -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( diff --git a/apps/api/src/app/environments/environments.bridge.controller.ts b/apps/api/src/app/environments/environments.bridge.controller.ts index 8454ccfec87..1c94174e20c 100644 --- a/apps/api/src/app/environments/environments.bridge.controller.ts +++ b/apps/api/src/app/environments/environments.bridge.controller.ts @@ -4,7 +4,6 @@ import { Controller, Get, NotFoundException, - Options, Param, Post, Query, @@ -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, 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') @@ -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); @@ -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 @@ -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): 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, 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); } diff --git a/apps/api/src/app/environments/render/email-preview-renderer.ts b/apps/api/src/app/environments/render/email-preview-renderer.ts new file mode 100644 index 00000000000..34436c39b6a --- /dev/null +++ b/apps/api/src/app/environments/render/email-preview-renderer.ts @@ -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 { + render(controlValues: Record): 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) }; + } +} diff --git a/apps/api/src/app/environments/render/email-schema-extender.ts b/apps/api/src/app/environments/render/email-schema-extender.ts new file mode 100644 index 00000000000..c01fb395e29 --- /dev/null +++ b/apps/api/src/app/environments/render/email-schema-extender.ts @@ -0,0 +1,75 @@ +/* eslint-disable */ +export type TiptapNode = { + type: string; + content?: TiptapNode[]; + text?: string; + attr?: Record; +}; + +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 }; +} diff --git a/apps/api/src/app/environments/render/hydration-component.ts b/apps/api/src/app/environments/render/hydration-component.ts new file mode 100644 index 00000000000..c934669304e --- /dev/null +++ b/apps/api/src/app/environments/render/hydration-component.ts @@ -0,0 +1,111 @@ +/* eslint-disable id-length */ +import { HydrationStrategyEnum, JsonSchemaDto } from '@novu/shared'; +import { faker } from '@faker-js/faker'; + +export type Payload = Record; + +function getDefaultFromSchema(key, schema: JsonSchemaDto) { + const defaultValue = generateDefaults(schema)[key]; + if (defaultValue) { + return defaultValue; + } + + return undefined; +} +function generateDefaults(schema: JsonSchemaDto, prefix: string = ''): Record { + const result: Record = {}; + + // If the schema has a default value, use it + if (schema.default !== undefined) { + result[prefix] = schema.default; + } else { + // Generate a default value based on the type + switch (schema.type) { + case 'string': + result[prefix] = faker.lorem.word(); + break; + case 'number': + result[prefix] = faker.datatype.number(); + break; + case 'boolean': + result[prefix] = faker.datatype.boolean(); + break; + case 'array': + result[prefix] = []; + break; + case 'object': + result[prefix] = {}; + break; + default: + result[prefix] = faker.lorem.word(); + } + } + + // If there are properties, recursively generate defaults for them + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + const newPrefix = prefix ? `${prefix}.${key}` : key; // Create a new key using dot notation + const nestedDefaults = generateDefaults(value, newPrefix); + Object.assign(result, nestedDefaults); // Merge nested results into the result + } + } + + return result; +} + +function getValueAsString(controlValues: Record, key: string) { + return typeof controlValues[key] === 'object' ? JSON.stringify(controlValues[key]) : String(controlValues[key]); +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +interface HydrateTextParams { + controlValues: Record; + controlValueKey: string; + payload?: Payload; + variablesSchema: JsonSchemaDto; + hydrationStrategies: HydrationStrategyEnum[]; +} + +export function hydrateText(p: HydrateTextParams): string { + let hydratedText: string = getValueAsString(p.controlValues, p.controlValueKey); + if (p.hydrationStrategies.includes(HydrationStrategyEnum.HYDRATE_VARIABLES_WITH_PAYLOAD_VALUES_IF_EXIST)) { + hydratedText = hydratePayload(hydratedText, 'payload', (key) => getValueFromPayload(key, p.payload || {})); + } + if (p.hydrationStrategies.includes(HydrationStrategyEnum.HYDRATE_PAYLOAD_VARIABLES_WITH_RANDOM_VALUES)) { + hydratedText = hydratePayload(hydratedText, 'payload', randomWithFaker); + } + if (p.hydrationStrategies.includes(HydrationStrategyEnum.HYDRATE_SYSTEM_VARIABLES_WITH_DEFAULTS)) { + hydratedText = hydratePayload(hydratedText, 'subscriber', (key) => getDefaultFromSchema(key, p.variablesSchema)); + hydratedText = hydratePayload(hydratedText, 'actor', (key) => getDefaultFromSchema(key, p.variablesSchema)); + hydratedText = hydratePayload(hydratedText, 'steps', (key) => getDefaultFromSchema(key, p.variablesSchema)); + } + + return hydratedText; +} +function hydratePayload(text: string, placeholderInnerField: string, valueGetter: (key) => string | object) { + const regexPattern = `{{(novu.${placeholderInnerField}\\.(\\w+))}}`; + const regex = new RegExp(regexPattern, 'g'); + + return text.replace(regex, (originalText, key: string) => { + const value = valueGetter(key); + + if (value === undefined) { + return originalText; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + }); +} + +function getValueFromPayload(key: string, payload: Payload): any { + const keys = key.split('.').slice(2); + + return keys.reduce((acc, key2) => (acc && acc[key2] !== undefined ? acc[key2] : undefined), payload); +} +function randomWithFaker(key: string): string { + return faker.lorem.word(); +} diff --git a/apps/api/src/app/environments/render/output-render-factory.ts b/apps/api/src/app/environments/render/output-render-factory.ts new file mode 100644 index 00000000000..0abc0025f1e --- /dev/null +++ b/apps/api/src/app/environments/render/output-render-factory.ts @@ -0,0 +1,108 @@ +// Base interface for all renderers +import { + ChatPreviewResult, + InAppPreviewResult, + PreviewResult, + PushPreviewResult, + RedirectTargetEnum, + SmsPreviewResult, + StepTypeEnum, +} from '@novu/shared'; +import { EmailPreviewRenderer } from './email-preview-renderer'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface Renderer { + render(controlValues: Record): T; +} + +export class OutputRendererFactory { + static createRenderer(stepType: StepTypeEnum): Renderer { + switch (stepType) { + case StepTypeEnum.CHAT: + return new ChatPreviewRenderer(); + case StepTypeEnum.SMS: + return new SmsPreviewRenderer(); + case StepTypeEnum.PUSH: + return new PushPreviewRenderer(); + case StepTypeEnum.EMAIL: + return new EmailPreviewRenderer(); + case StepTypeEnum.IN_APP: + return new InAppPreviewRenderer(); + default: + throw new Error(`Unknown step type: ${stepType}`); + } + } +} + +// Concrete Renderer for Chat Preview +class ChatPreviewRenderer implements Renderer { + render(controlValues: Record): ChatPreviewResult { + const message = (controlValues.message as string) || 'Default chat message'; + + return { body: message }; + } +} + +// Concrete Renderer for SMS Preview +class SmsPreviewRenderer implements Renderer { + render(controlValues: Record): SmsPreviewResult { + const smsMessage = (controlValues.smsMessage as string) || 'Default SMS message'; + + return { body: smsMessage }; + } +} + +// Concrete Renderer for Push Notification Preview +class PushPreviewRenderer implements Renderer { + render(controlValues: Record): PushPreviewResult { + const subject = (controlValues.subject as string) || 'Default Push Notification Subject'; + const body = (controlValues.body as string) || 'Default Push Notification Body'; + + return { subject, body }; + } +} + +// Concrete Renderer for In-App Message Preview +class InAppPreviewRenderer implements Renderer { + render(controlValues: Record): InAppPreviewResult { + const subject = (controlValues.subject as string) || 'Default In-App Message Subject'; + const body = (controlValues.body as string) || 'Default In-App Message Body'; + const avatar = controlValues.avatar as string; // Optional + const primaryActionLabel = (controlValues.primaryActionLabel as string) || 'Primary Action'; + const primaryActionUrl = (controlValues.primaryActionUrl as string) || 'http://default.url'; + const primaryActionTarget = (controlValues.primaryActionTarget as RedirectTargetEnum) || RedirectTargetEnum.SELF; + + const secondaryActionLabel = controlValues.secondaryActionLabel as string; // Optional + const secondaryActionUrl = controlValues.secondaryActionUrl as string; // Optional + const secondaryActionTarget = controlValues.secondaryActionTarget as RedirectTargetEnum; // Optional + + const redirectUrl = (controlValues.redirectUrl as string) || 'http://default.redirect.url'; + const redirectTarget = (controlValues.redirectTarget as RedirectTargetEnum) || RedirectTargetEnum.SELF; + + return { + subject, + body, + avatar, + primaryAction: { + label: primaryActionLabel, + redirect: { + url: primaryActionUrl, + target: primaryActionTarget, + }, + }, + secondaryAction: secondaryActionLabel + ? { + label: secondaryActionLabel, + redirect: { + url: secondaryActionUrl, + target: secondaryActionTarget, + }, + } + : undefined, + redirect: { + url: redirectUrl, + target: redirectTarget, + }, + }; + } +} diff --git a/apps/api/src/app/pipes/zod-validation-pipe.ts b/apps/api/src/app/pipes/zod-validation-pipe.ts new file mode 100644 index 00000000000..4d777876f64 --- /dev/null +++ b/apps/api/src/app/pipes/zod-validation-pipe.ts @@ -0,0 +1,18 @@ +import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; +import { ZodError, ZodSchema } from 'zod'; + +@Injectable() +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown, metadata: ArgumentMetadata) { + if (metadata.type === 'body') { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new ZodError(result.error.errors); + } + + return result.data; + } + } +} diff --git a/apps/api/src/app/step-schemas/components/variable-validator-component.ts b/apps/api/src/app/step-schemas/components/variable-validator-component.ts new file mode 100644 index 00000000000..5bfc76197ba --- /dev/null +++ b/apps/api/src/app/step-schemas/components/variable-validator-component.ts @@ -0,0 +1,153 @@ +/* eslint-disable */ +import { ControlPreviewIssue, ControlPreviewIssueType, JsonSchemaDto, ValidationStrategyEnum } from '@novu/shared'; + +export class VariableValidatorComponent { + public searchAndValidatePlaceholderExistence( + controlsValues: Record | undefined, + payload: Record | undefined, + validationStrategy: ValidationStrategyEnum[], + controlSchema: JsonSchemaDto + ): Record { + let issues: Record = {}; + if (validationStrategy.includes(ValidationStrategyEnum.VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION)) { + const placeholders = this.collectPlaceholders(controlsValues); + issues = { ...this.validatePlaceholders(placeholders, payload), ...issues }; + } + if (validationStrategy.includes(ValidationStrategyEnum.VALIDATE_MISSING_CONTROL_VALUES)) { + issues = { ...this.validateMissingControlValues(controlSchema, controlsValues), ...issues }; + } + + return issues; + } + + private validateMissingControlValues(controlSchema, controlsValues?: Record) { + let issues: Record = {}; + + const requiredFields = findRequiredFields(controlSchema); + const keysWithValues = controlsValues ? Object.keys(controlsValues) : []; + + requiredFields.forEach((field) => { + if (!keysWithValues.includes(field)) { + issues[field] = issues[field] || []; + issues[field].push({ + issueType: ControlPreviewIssueType.MISSING_VALUE, + message: `The control value for '${field}' is missing.`, + }); + } + }); + return issues; + } + + /** + * Validates the placeholders against the original object and returns an object of issues. + * + * @param {Record} placeholders - The object mapping placeholders to their original keys. + * @param {any} originalObj - The original object to validate against. + * @returns {Record} An object mapping original values to an array of issues. + */ + private validatePlaceholders( + placeholders: Record, + originalObj: any + ): Record { + const issues: Record = {}; + + for (const placeholder in placeholders) { + const originalKey = placeholders[placeholder]; + const originalValue = this.getValueByPath(originalObj, originalKey); + + if (originalValue === undefined) { + issues[originalKey] = issues[originalKey] || []; + issues[originalKey].push({ + issueType: ControlPreviewIssueType.MISSING_VARIABLE_IN_PAYLOAD, + variableName: placeholder, + message: `The variable '${placeholder}' is missing in the payload.`, + }); + } else if (typeof originalValue !== 'string') { + issues[originalKey] = issues[originalKey] || []; + issues[originalKey].push({ + issueType: ControlPreviewIssueType.VARIABLE_TYPE_MISMATCH, + variableName: placeholder, + message: `The variable '${placeholder}' is expected to be a string, but got ${typeof originalValue}.`, + }); + } + } + + return issues; + } + + /** + * Retrieves the value from an object by a dot-separated path. + * + * @param {any} obj - The object to retrieve the value from. + * @param {string} path - The dot-separated path to the value. + * @returns {any} The value at the specified path, or undefined if not found. + */ + private getValueByPath(obj: any, path: string): any { + return path.split('.').reduce((acc, part) => acc && acc[part], obj); + } + /** + * Collects placeholders from the provided object and returns an object + * where each key is a placeholder and its value is the original key + * from which the placeholder was found. + * + * @param {any} obj - The input object containing strings with placeholders. + * @returns {Record} An object mapping placeholders to their original keys. + */ + private collectPlaceholders(obj: any): Record { + const placeholders: Record = {}; + + function recursiveSearch(value: any, originalKey: string | null = null) { + if (typeof value === 'string') { + const regex = /{{(.*?)}}/g; + let match; + + while ((match = regex.exec(value)) !== null) { + const placeholder = match[1].trim(); + placeholders[placeholder] = originalKey || ''; + } + } else if (Array.isArray(value)) { + for (const item of value) { + recursiveSearch(item, originalKey); + } + } else if (typeof value === 'object' && value !== null) { + for (const key in value) { + if (value.hasOwnProperty(key)) { + recursiveSearch(value[key], key); + } + } + } + } + + recursiveSearch(obj); + + return placeholders; + } +} +function findRequiredFields(schema: JsonSchemaDto, parentPath: string = ''): string[] { + const requiredFields: string[] = []; + + // Check if the schema has a 'required' key + if (schema.required) { + // Construct the path for each required field + schema.required.forEach((field) => { + requiredFields.push(parentPath ? `${parentPath}.${field}` : field); + }); + } + + // If the schema has properties, check each property + if (schema.properties) { + for (const prop in schema.properties) { + const propSchema = schema.properties[prop]; + // Construct the path for the current property + const currentPath = parentPath ? `${parentPath}.${prop}` : prop; + + // Recursively find required fields in nested objects + if (propSchema.type === 'object') { + const nestedRequired = findRequiredFields(propSchema, currentPath); + requiredFields.push(...nestedRequired); + } + } + } + + return requiredFields; +} diff --git a/apps/api/src/app/step-schemas/e2e/generate-preview.e2e.ts b/apps/api/src/app/step-schemas/e2e/generate-preview.e2e.ts new file mode 100644 index 00000000000..e0f04b22e2b --- /dev/null +++ b/apps/api/src/app/step-schemas/e2e/generate-preview.e2e.ts @@ -0,0 +1,168 @@ +import { UserSession } from '@novu/testing'; +import { + ChannelTypeEnum, + createStepSchemaClient, + createWorkflowClient, + GeneratePreviewRequestDto, + GeneratePreviewResponseDto, + StepTypeEnum, +} from '@novu/shared'; +import { expect } from 'chai'; +import { randomUUID } from 'node:crypto'; +import { buildCreateWorkflowDto } from '../../workflows-v2/workflow.controller.e2e'; + +describe('Get Control Schema - /step-schemas/:stepType (GET)', () => { + let session: UserSession; + let stepSchemaClient: ReturnType; + let workflowsClient: ReturnType; + + beforeEach(async () => { + console.log('Outer beforeEach'); + session = new UserSession(); + await session.initialize(); + console.log(`session.initiated`); + stepSchemaClient = createStepSchemaClient(session.serverUrl, getHeaders()); + workflowsClient = createWorkflowClient(session.serverUrl, getHeaders()); + }); + + describe('Generate Preview', () => { + describe('Hydration testing', () => { + it('When using multiple strategies all should run', async () => { + // Implement test logic here + }); + it('HYDRATE_VARIABLES_WITH_PAYLOAD_VALUES_IF_EXIST should only hydrate based on strategy', async () => { + // Implement test logic here + }); + it('HYDRATE_SYSTEM_VARIABLES_WITH_DEFAULTS should only hydrate based on strategy', async () => { + // Implement test logic here + }); + it('HYDRATE_PAYLOAD_VARIABLES_WITH_RANDOM_VALUES should only hydrate based on strategy', async () => { + // Implement test logic here + }); + }); + + describe('Validation testing', () => { + // Implement validation tests + }); + + describe('Happy Path', () => { + describe('SMS', () => { + const stepType = ChannelTypeEnum.SMS; + let workflowId: string; + let requestDto: GeneratePreviewRequestDto; + let previewResponseDto: GeneratePreviewResponseDto; + + before(async () => { + workflowId = await createWorkflowAndReturnId(stepType); + requestDto = buildHappyDto(stepType, workflowId); + previewResponseDto = await generatePreview(stepType, requestDto); + }); + + it('should match the body in the preview response', () => { + expect(previewResponseDto.result).to.exist; + expect(previewResponseDto.result!.type).to.equal(stepType); + expect(previewResponseDto.result!.preview.body).to.equal(requestDto.controlValues!.body); + }); + }); + + describe('Email', () => { + // Implement email tests + }); + describe('Push', () => { + // Implement push tests + }); + describe('Chat', () => { + // Implement chat tests + }); + describe('InApp', () => { + // Implement InApp tests + }); + }); + }); + + function getHeaders(): HeadersInit { + return { + Authorization: `Bearer ${JSON.stringify(session.fetchJWT())}`, // Fixed space + 'Novu-Environment-Id': session.environment._id, + }; + } + + async function generatePreview( + stepType: ChannelTypeEnum.SMS, + dto: GeneratePreviewRequestDto + ): Promise { + const novuRestResult = await stepSchemaClient.generatePreview(stepType, dto); + if (!novuRestResult.isSuccessResult()) { + throw new Error('Failed to generate preview'); + } + + return novuRestResult.value; + } + + async function createWorkflowAndReturnId(type: ChannelTypeEnum) { + const createWorkflowDto = buildCreateWorkflowDto(type + randomUUID()); + const workflowResult = await workflowsClient.createWorkflow(createWorkflowDto); + if (!workflowResult.isSuccessResult()) { + throw new Error('Failed to create workflow'); + } + + return workflowResult.value._id; + } +}); + +function buildHappyDto(stepTypeEnum: ChannelTypeEnum.SMS, workflowId: string): GeneratePreviewRequestDto { + return { + workflowId, + validationStrategy: [], + hydrationStrategy: [], + controlValues: dtos[stepTypeEnum], + controlSchema: { + type: 'object', + properties: { + body: { + type: 'string', + }, + }, + required: ['body'], + additionalProperties: false, + }, + variablesSchema: {}, + }; +} + +const dtos: Record> = { + [StepTypeEnum.SMS]: { + body: 'Hello, World!', + }, + [StepTypeEnum.EMAIL]: { + subject: 'Hello, World!', + body: 'Hello, World!', + }, + [StepTypeEnum.PUSH]: { + subject: 'Hello, World!', + body: 'Hello, World!', + }, + [StepTypeEnum.CHAT]: { + body: 'Hello, World!', + }, + [StepTypeEnum.IN_APP]: { + subject: 'Hello, World!', + body: 'Hello, World!', + avatar: 'https://www.example.com/avatar.png', + primaryAction: { + label: 'Primary Action', + url: 'https://www.example.com/primary-action', + }, + secondaryAction: { + label: 'Secondary Action', + url: 'https://www.example.com/secondary-action', + }, + data: { + key: 'value', + }, + redirect: { + target: '_blank', + url: 'https://www.example.com/redirect', + }, + }, +}; diff --git a/apps/api/src/app/step-schemas/step-schemas.controller.ts b/apps/api/src/app/step-schemas/step-schemas.controller.ts index 43c234d8c36..82872ef86be 100644 --- a/apps/api/src/app/step-schemas/step-schemas.controller.ts +++ b/apps/api/src/app/step-schemas/step-schemas.controller.ts @@ -1,6 +1,21 @@ -import { ClassSerializerInterceptor, Controller, Get, Param, UseInterceptors } from '@nestjs/common'; +import { + Body, + ClassSerializerInterceptor, + Controller, + Get, + Param, + Post, + UseInterceptors, + UsePipes, +} from '@nestjs/common'; -import { UserSessionData } from '@novu/shared'; +import { + ChannelTypeEnum, + GeneratePreviewRequestDto, + GeneratePreviewRequestDtoSchema, + GeneratePreviewResponseDto, + UserSessionData, +} from '@novu/shared'; import { ExternalApiAccessible, UserSession } from '@novu/application-generic'; import { StepType } from '@novu/framework'; @@ -9,12 +24,18 @@ import { GetStepSchemaCommand } from './usecases/get-step-schema/get-step-schema import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; import { GetStepSchema } from './usecases/get-step-schema/get-step-schema.usecase'; import { SchemaTypeDto } from './dtos/schema-type.dto'; +import { GeneratePreviewUseCase } from './usecases/generate-preview/generate-preview-use-case'; +import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview-command'; +import { ZodValidationPipe } from '../pipes/zod-validation-pipe'; @Controller('/step-schemas') @UserAuthentication() @UseInterceptors(ClassSerializerInterceptor) export class StepSchemasController { - constructor(private getStepDefaultSchemaUsecase: GetStepSchema) {} + constructor( + private getStepDefaultSchemaUsecase: GetStepSchema, + private generatePreviewUseCase: GeneratePreviewUseCase + ) {} @Get('/:stepType') @ApiOperation({ @@ -42,4 +63,15 @@ export class StepSchemasController { return { schema }; } + @Post('/:stepType/preview') + @UsePipes(new ZodValidationPipe(GeneratePreviewRequestDtoSchema)) + async generatePreview( + @UserSession() user: UserSessionData, + @Param('stepType') stepType: ChannelTypeEnum, + @Body() generatePreviewRequestDto: GeneratePreviewRequestDto + ): Promise { + return await this.generatePreviewUseCase.execute( + GeneratePreviewCommand.create({ user, stepType, generatePreviewRequestDto }) + ); + } } diff --git a/apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-command.ts b/apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-command.ts new file mode 100644 index 00000000000..6abc77200b7 --- /dev/null +++ b/apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-command.ts @@ -0,0 +1,7 @@ +import { ChannelTypeEnum, GeneratePreviewRequestDto } from '@novu/shared'; +import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; + +export class GeneratePreviewCommand extends EnvironmentWithUserObjectCommand { + stepType: ChannelTypeEnum; + generatePreviewRequestDto: GeneratePreviewRequestDto; +} diff --git a/apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-use-case.ts b/apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-use-case.ts new file mode 100644 index 00000000000..777c8ff49bd --- /dev/null +++ b/apps/api/src/app/step-schemas/usecases/generate-preview/generate-preview-use-case.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { ControlPreviewIssue, GeneratePreviewResponseDto, TRANSIENT_PREVIEW_PREFIX } from '@novu/shared'; +import { VariableValidatorComponent } from '../../components/variable-validator-component'; +import { GeneratePreviewCommand } from './generate-preview-command'; +import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; +import { hydrateText } from '../../../environments/render/hydration-component'; + +@Injectable() +export class GeneratePreviewUseCase { + private validator: VariableValidatorComponent; + private previewStep: PreviewStep; + + async execute(command: GeneratePreviewCommand): Promise { + const issues: Record = this.validateControlValues(command); + const { augmentedControlValues, augmentedPayload } = hydrateControlValues(command); + const newVar = await this.previewStep.execute( + PreviewStepCommand.create({ + controls: augmentedControlValues, + data: augmentedPayload, + environmentId: command.user.environmentId, + inputs: {}, + organizationId: command.user.organizationId, + stepId: command.generatePreviewRequestDto.stepId || TRANSIENT_PREVIEW_PREFIX + command.stepType, + userId: command.user._id, + workflowId: command.generatePreviewRequestDto.workflowId, + }) + ); + + return { + issues, + result: { + preview: newVar, + type: command.stepType, + }, + }; + } + + private validateControlValues(command: GeneratePreviewCommand) { + return this.validator.searchAndValidatePlaceholderExistence( + command.generatePreviewRequestDto.controlValues, + command.generatePreviewRequestDto.payloadValues, + command.generatePreviewRequestDto.validationStrategy, + command.generatePreviewRequestDto.controlSchema + ); + } +} +function hydrateControlValues(command: GeneratePreviewCommand): Record { + const augmentedControlValues: Record = {}; + const dto = command.generatePreviewRequestDto; + for (const key in dto.controlValues) { + if (dto.controlValues.hasOwnProperty(key)) { + augmentedControlValues[key] = hydrateText({ + controlValues: dto.controlValues, + controlValueKey: key, + payload: dto.payloadValues, + variablesSchema: dto.variablesSchema, + hydrationStrategies: dto.hydrationStrategy, + }); + } + } + + return augmentedControlValues; +} 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 1afe59a9eea..5c5e321d613 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -239,7 +239,7 @@ function buildInAppStep(): StepDto { }; } -function buildCreateWorkflowDto(nameSuffix: string): CreateWorkflowDto { +export function buildCreateWorkflowDto(nameSuffix: string): CreateWorkflowDto { return { __source: WorkflowCreationSourceEnum.EDITOR, name: TEST_WORKFLOW_NAME + nameSuffix, diff --git a/apps/api/src/exception-filter.ts b/apps/api/src/exception-filter.ts index 657db906696..47b7d4c6b87 100644 --- a/apps/api/src/exception-filter.ts +++ b/apps/api/src/exception-filter.ts @@ -41,7 +41,11 @@ export class AllExceptionsFilter implements ExceptionFilter { } ) { const uuid = this.getUuid(exception); - this.logger.error({ exception, errorId: uuid }, `unexpected exception thrown`, 'Exception'); + this.logger.error( + { exception, errorId: uuid, message: exception instanceof Error ? exception.message : '' }, + `unexpected exception thrown${exception instanceof Error ? ` with message:${exception.message}` : ''}`, + 'Exception' + ); return { ...responseBody, errorId: uuid }; } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 5620b76684e..d8f633ff22a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -36,6 +36,7 @@ "@segment/analytics-next": "^1.73.0", "@tanstack/react-query": "^4.20.4", "class-variance-authority": "^0.7.0", + "faker": "^6.6.6", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.439.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index 688d941f46b..ab33d01f541 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,7 +40,8 @@ "dependencies": { "@nestjs/swagger": "7.4.0", "class-transformer": "0.5.1", - "class-validator": "0.14.1" + "class-validator": "0.14.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/bluebird": "^3.5.24", @@ -51,6 +52,8 @@ "vitest": "^2.0.5" }, "nx": { - "tags": ["package:public"] + "tags": [ + "package:public" + ] } } diff --git a/packages/shared/src/clients/index.ts b/packages/shared/src/clients/index.ts new file mode 100644 index 00000000000..4f1b9652c22 --- /dev/null +++ b/packages/shared/src/clients/index.ts @@ -0,0 +1,3 @@ +export * from './step-schemas-client'; +export * from './novu-base-client'; +export * from './workflows-client'; diff --git a/packages/shared/src/clients/novu-base-client.ts b/packages/shared/src/clients/novu-base-client.ts new file mode 100644 index 00000000000..9205591a67d --- /dev/null +++ b/packages/shared/src/clients/novu-base-client.ts @@ -0,0 +1,141 @@ +// Define the error classes +export class HttpError extends Error { + status: number; + + constructor(response: Response) { + super(response.statusText); + this.name = 'HttpError'; + this.status = response.status; + } +} + +export class NovuBadRequestError extends HttpError {} +export class NovuUnauthorizedError extends HttpError {} +export class NovuForbiddenError extends HttpError {} +export class NovuNotFoundError extends HttpError {} +export class NovuInternalServerError extends HttpError {} +export class NovuNotImplementedError extends HttpError {} +export class NovuBadGatewayError extends HttpError {} +export class NovuServiceUnavailableError extends HttpError {} +export class Novu extends HttpError {} +export class NovuRedirectError extends HttpError { + redirectUrl: string; + + constructor(response: Response) { + super(response); + this.redirectUrl = response.headers.get('Location') || ''; + } +} + +const errorMap: Record HttpError> = { + 400: NovuBadRequestError, + 401: NovuUnauthorizedError, + 403: NovuForbiddenError, + 404: NovuNotFoundError, + 500: NovuInternalServerError, + 501: NovuNotImplementedError, + 502: NovuBadGatewayError, + 503: NovuServiceUnavailableError, + 504: Novu, +}; + +type FetchFunction = () => Promise; + +export class NovuRestResult { + public isSuccess: boolean; + public value?: T; + public error?: E; + + private constructor(isSuccess: boolean, value?: T, error?: E) { + this.isSuccess = isSuccess; + this.value = value; + this.error = error; + } + + static ok(value: T): NovuRestResult { + return new NovuRestResult(true, value); + } + + static fail(error: E): NovuRestResult { + return new NovuRestResult(false, undefined as never, error); + } + public isSuccessResult(): this is { value: T; error: never } { + return this.isSuccess; + } +} + +// Functional version of NovuBaseClient +export const createNovuBaseClient = (baseUrl: string, headers: HeadersInit = {}) => { + const defaultHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + + const buildUrl = (endpoint: string): string => `${baseUrl}${endpoint}`; + + const safeFetch = async (url: string, fetchFunc: FetchFunction): Promise> => { + const response = await fetchFunc(); + + if (response.ok) { + const jsonData = await response.json(); + + return NovuRestResult.ok(jsonData.data); + } + + if (response.status >= 300 && response.status < 400) { + const redirectError = new NovuRedirectError(response); + + return NovuRestResult.fail(redirectError); + } + + const ErrorClass = errorMap[response.status] || HttpError; + const errorResult = new ErrorClass(response); + + return NovuRestResult.fail(errorResult); + }; + + const safeGet = async (endpoint: string): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'GET', + headers: defaultHeaders, + }) + ); + }; + + const safePut = async (endpoint: string, data: object): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'PUT', + headers: defaultHeaders, + body: JSON.stringify(data), + }) + ); + }; + + const safePost = async (endpoint: string, data: object): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'POST', + headers: defaultHeaders, + body: JSON.stringify(data), + }) + ); + }; + + const safeDelete = async (endpoint: string): Promise> => { + return await safeFetch(endpoint, () => + fetch(buildUrl(endpoint), { + method: 'DELETE', + headers: defaultHeaders, + }) + ); + }; + + return { + safeGet, + safePut, + safePost, + safeDelete, + }; +}; diff --git a/packages/shared/src/clients/step-schemas-client.ts b/packages/shared/src/clients/step-schemas-client.ts new file mode 100644 index 00000000000..968ea44e232 --- /dev/null +++ b/packages/shared/src/clients/step-schemas-client.ts @@ -0,0 +1,25 @@ +import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client'; +import { ChannelTypeEnum } from '../types'; +import { GeneratePreviewRequestDto, GeneratePreviewResponseDto } from '../dto'; + +export const EMAIL_EDITOR_JSON_KEY = 'EMAIL_EDITOR_JSON_KEY'; + +// Define the StepSchemaClient as a function that utilizes the base client +export const createStepSchemaClient = (baseUrl: string, headers: HeadersInit = {}) => { + const baseClient = createNovuBaseClient(baseUrl, headers); + + const generatePreview = async ( + stepType: ChannelTypeEnum, + generatePreviewDto: GeneratePreviewRequestDto + ): Promise> => { + return await baseClient.safePost( + `v1/step-schemas/${stepType}/preview`, + generatePreviewDto + ); + }; + + // Return the methods as an object + return { + generatePreview, + }; +}; diff --git a/packages/shared/src/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts new file mode 100644 index 00000000000..06431a36c47 --- /dev/null +++ b/packages/shared/src/clients/workflows-client.ts @@ -0,0 +1,57 @@ +import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client'; +import { CreateWorkflowDto, ListWorkflowResponse, UpdateWorkflowDto, WorkflowResponseDto } from '../dto'; +import { GetListQueryParams } from '../dto/workflows/get-list-query-params'; + +// Define the WorkflowClient as a function that utilizes the base client +export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) => { + const baseClient = createNovuBaseClient(baseUrl, headers); + + const createWorkflow = async ( + createWorkflowDto: CreateWorkflowDto + ): Promise> => { + return await baseClient.safePost('v2/workflows', createWorkflowDto); + }; + + const updateWorkflow = async ( + workflowId: string, + updateWorkflowDto: UpdateWorkflowDto + ): Promise> => { + return await baseClient.safePut(`v2/workflows/${workflowId}`, updateWorkflowDto); + }; + + const getWorkflow = async (workflowId: string): Promise> => { + return await baseClient.safeGet(`v2/workflows/${workflowId}`); + }; + + const deleteWorkflow = async (workflowId: string): Promise> => { + return await baseClient.safeDelete(`v2/workflows/${workflowId}`); + }; + + const searchWorkflows = async ( + queryParams: GetListQueryParams + ): Promise> => { + const query = new URLSearchParams(); + query.append('offset', queryParams.offset?.toString() || '0'); + query.append('limit', queryParams.limit?.toString() || '50'); + if (queryParams.orderDirection) { + query.append('orderDirection', queryParams.orderDirection); + } + if (queryParams.orderByField) { + query.append('orderByField', queryParams.orderByField); + } + if (queryParams.query) { + query.append('query', queryParams.query); + } + + return await baseClient.safeGet(`v2/workflows?${query.toString()}`); + }; + + // Return the methods as an object + return { + createWorkflow, + updateWorkflow, + getWorkflow, + deleteWorkflow, + searchWorkflows, + }; +}; diff --git a/packages/shared/src/dto/index.ts b/packages/shared/src/dto/index.ts index 178cb0ce34e..742ae9555dc 100644 --- a/packages/shared/src/dto/index.ts +++ b/packages/shared/src/dto/index.ts @@ -14,3 +14,4 @@ export * from './widget'; export * from './session'; export * from './controls'; export * from './subscription'; +export * from './step-schemas'; diff --git a/packages/shared/src/dto/json-scehma-zod.ts b/packages/shared/src/dto/json-scehma-zod.ts new file mode 100644 index 00000000000..15cc115b8e2 --- /dev/null +++ b/packages/shared/src/dto/json-scehma-zod.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const jsonSchemaType = z.union([ + z.literal('string'), + z.literal('number'), + z.literal('object'), + z.literal('array'), + z.literal('boolean'), + z.literal('null'), +]); + +// Define a recursive Zod schema for JSON Schema properties +const jsonSchemaProperties = z.object({ + type: jsonSchemaType, + properties: z.record(z.lazy(() => jsonschemaZodValidator)).optional(), // Allow nested schemas + items: z.lazy(() => jsonschemaZodValidator).optional(), // For arrays, this can be a schema or a reference + required: z.array(z.string()).optional(), + enum: z.array(z.string()).optional(), + additionalProperties: z.union([z.boolean(), z.lazy(() => jsonschemaZodValidator)]).optional(), // Allow nested additional properties +}); + +// Main JSON Schema validation +export const jsonschemaZodValidator = z.object({ + $schema: z.string().optional(), // Optional $schema property + ...jsonSchemaProperties.shape, // Spread the properties schema +}); diff --git a/packages/shared/src/dto/step-schemas/control-preview-issue.type.ts b/packages/shared/src/dto/step-schemas/control-preview-issue.type.ts new file mode 100644 index 00000000000..1cc4e9bcfa8 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/control-preview-issue.type.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum ControlPreviewIssueType { + 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/controlPreviewIssue.ts b/packages/shared/src/dto/step-schemas/controlPreviewIssue.ts new file mode 100644 index 00000000000..d5c65d6111d --- /dev/null +++ b/packages/shared/src/dto/step-schemas/controlPreviewIssue.ts @@ -0,0 +1,8 @@ +import { ControlPreviewIssueType } from './control-preview-issue.type'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface ControlPreviewIssue { + issueType: ControlPreviewIssueType; + variableName?: string; + message: string; +} diff --git a/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts b/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts new file mode 100644 index 00000000000..49697ecfa33 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/generate-preview-request.dto.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { jsonschemaZodValidator } from '../json-scehma-zod'; + +export const TRANSIENT_PREVIEW_PREFIX = 'transient-preview-'; + +/** + * @enum {string} + * @description Enum representing different validation strategies for processing requests. + * If an empty list is submitted, no validation will occur. + * If multiple strategies are submitted, they will be executed in the order they are listed. + */ +export enum ValidationStrategyEnum { + VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION = 'VALIDATE_MISSING_PAYLOAD_VALUES_FOR_HYDRATION', + VALIDATE_MISSING_CONTROL_VALUES = 'VALIDATE_MISSING_CONTROL_VALUES', +} + +/** + * @enum {string} + * @description Enum representing different hydration strategies for variables. + * If an empty list is submitted, no hydration will occur. + * If multiple strategies are submitted, they will be executed in the order they are listed. + * If a strategy is not listed, it will not be executed. + */ +export enum HydrationStrategyEnum { + HYDRATE_VARIABLES_WITH_PAYLOAD_VALUES_IF_EXIST = 'HYDRATE_VARIABLES_WITH_PAYLOAD_VALUES_IF_EXIST', + HYDRATE_SYSTEM_VARIABLES_WITH_DEFAULTS = 'HYDRATE_SYSTEM_VARIABLES_WITH_DEFAULTS', + HYDRATE_PAYLOAD_VARIABLES_WITH_RANDOM_VALUES = 'HYDRATE_PAYLOAD_VARIABLES_WITH_RANDOM_VALUES', +} + +export const GeneratePreviewRequestDtoSchema = z.object({ + controlValues: z.record(z.string().or(z.number()).or(z.boolean()).or(z.unknown())).optional(), + payloadValues: z.record(z.string().or(z.number()).or(z.boolean()).or(z.unknown())).optional(), + controlSchema: jsonschemaZodValidator, + variablesSchema: jsonschemaZodValidator, + workflowId: z.string(), + stepId: z.string().optional(), + hydrationStrategy: z.array(z.nativeEnum(HydrationStrategyEnum)), + validationStrategy: z.array(z.nativeEnum(ValidationStrategyEnum)), +}); + +export type GeneratePreviewRequestDto = z.infer; +export type JsonSchemaDto = z.infer; 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 new file mode 100644 index 00000000000..392d1ffd269 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/generate-preview-response.dto.ts @@ -0,0 +1,81 @@ +import { ControlPreviewIssue } from './controlPreviewIssue'; +import { ChannelTypeEnum } from '../../types'; + +export type GeneratePreviewResponseDto = { + issues: { [controlId: string]: ControlPreviewIssue[] }; + result?: {} & ( + | { + type: ChannelTypeEnum.EMAIL; + preview: EmailPreviewResult; + } + | { + type: ChannelTypeEnum.IN_APP; + preview: InAppPreviewResult; + } + | { + type: ChannelTypeEnum.SMS; + preview: SmsPreviewResult; + } + | { + type: ChannelTypeEnum.PUSH; + preview: PushPreviewResult; + } + | { + type: ChannelTypeEnum.CHAT; + preview: ChatPreviewResult; + } + ); +}; + +export enum RedirectTargetEnum { + SELF = '_self', + BLANK = '_blank', + PARENT = '_parent', + TOP = '_top', + UNFENCED_TOP = '_unfencedTop', +} + +export class PreviewResult {} + +export class ChatPreviewResult extends PreviewResult { + body: string; +} + +export class SmsPreviewResult extends PreviewResult { + body: string; +} + +export class PushPreviewResult extends PreviewResult { + subject: string; + body: string; +} + +export class EmailPreviewResult extends PreviewResult { + subject: string; + body: string; +} + +export class InAppPreviewResult extends PreviewResult { + subject: string; + body: string; + avatar?: string; + primaryAction: { + label: string; + redirect: { + url: string; + target?: RedirectTargetEnum; + }; + }; + secondaryAction?: { + label: string; + redirect: { + url: string; + target?: RedirectTargetEnum; + }; + }; + data?: { [key: string]: unknown }; + redirect: { + url: string; + target?: RedirectTargetEnum; + }; +} diff --git a/packages/shared/src/dto/step-schemas/index.ts b/packages/shared/src/dto/step-schemas/index.ts new file mode 100644 index 00000000000..896f0edb827 --- /dev/null +++ b/packages/shared/src/dto/step-schemas/index.ts @@ -0,0 +1,4 @@ +export * from './generate-preview-request.dto'; +export * from './generate-preview-response.dto'; +export * from './controlPreviewIssue'; +export * from './control-preview-issue.type'; 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';