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/.github/workflows/reusable-widget-e2e.yml b/.github/workflows/reusable-widget-e2e.yml index f75dbd299db..b635bac7def 100644 --- a/.github/workflows/reusable-widget-e2e.yml +++ b/.github/workflows/reusable-widget-e2e.yml @@ -99,13 +99,13 @@ jobs: parallel: false config-file: cypress.config.ts - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots path: apps/widget/cypress/screenshots # Test run video was always captured, so this action uses "always()" condition - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: cypress-videos 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/LICENSE-ENTERPRISE b/LICENSE-ENTERPRISE new file mode 100644 index 00000000000..1da8557fd7c --- /dev/null +++ b/LICENSE-ENTERPRISE @@ -0,0 +1,32 @@ +Portions of this software are licensed as follows: + +* All content that resides under https://github.com/novuhq/novu/tree/next/enterprise/packages and +https://github.com/novuhq/novu/tree/next/apps/web/src/ee directory of this repository (Commercial License) is licensed under the license defined in "https://github.com/novuhq/novu/blob/next/apps/web/src/ee/LICENSE". +* All third party components incorporated into the Novu Software are licensed under the original license provided by the owner of the applicable component. +* Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below. + +Novu Proprietary Software License + +IMPORTANT – READ CAREFULLY: This License Agreement ("Agreement") is a legal agreement between you (either an individual or a single entity) and Novu Corporation ("Novu") for the software product identified below, which includes computer software and associated media, printed materials, and "online" or electronic documentation (collectively, the "Software"). + +By installing, copying, or otherwise using these files, you agree to be bound by the terms of this Agreement. +If you do not agree to the terms of this Agreement, do not install or use the Software. + +Grant of License: Subject to the terms of this Agreement, Novu hereby grants you a non-exclusive, non-transferable license to use the Software solely for your internal operations. +You may not rent, lease, lend, sell, redistribute, sublicense or provide commercial hosting services with the Software. + +- Use Restrictions: Use of the Software is conditional upon your compliance with the terms set forth below: +- Approval Required: You may not use the Software without obtaining prior written approval from Novu. To request approval, you must contact Novu at [contact information]. +- No Modification: You may not modify, adapt, or translate the Software. You may not reverse engineer, decompile, disassemble, or otherwise attempt to discover the source code of the Software, except to the extent that such activity is expressly permitted by applicable law notwithstanding this limitation. + +Intellectual Property Rights: The Software is the property of Novu and is protected by copyright laws and international copyright treaties, as well as other intellectual property laws and treaties. The Software is licensed, not sold. + +Termination: This Agreement is effective until terminated. Your rights under this Agreement will terminate automatically without notice from Novu if you fail to comply with any of the terms and conditions of this Agreement. Upon termination, you must cease all use of the Software and destroy all copies, full or partial, of the Software. + +No Warranties: Novu expressly disclaims any warranty for the Software. The Software is provided 'As Is' without any express or implied warranty of any kind, including but not limited to any warranties of merchantability, noninfringement, or fitness for a particular purpose. Novu does not warrant or assume responsibility for the accuracy or completeness of any information, text, graphics, links, or other items contained within the Software. + +Limitation of Liability: In no event shall Novu be liable for any damages whatsoever (including, without limitation, damages for loss of profits, business interruption, loss of information, or any other pecuniary loss) arising out of the use of or inability to use this Software, even if Novu has been advised of the possibility of such damages. + +By installing, copying, or otherwise using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions. + +You also agree that this Agreement is the complete and exclusive statement of agreement between the parties and supersedes all proposals or prior agreements, oral or written, and any other communications between the parties relating to the subject matter of this Agreement. diff --git a/LICENSE-MIT b/LICENSE-MIT index 8f2c13585e5..29a1b830e2b 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Noti-fire apps ltd. +Copyright (c) 2019 Noti-fire Apps Ltd. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,6 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -This license applies to the entire repo except for subfolders that have their own license file. -In such cases, the license file in the subfolder takes precedence. diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development index 771c3ede632..96ed6084d36 100644 --- a/apps/api/src/.env.development +++ b/apps/api/src/.env.development @@ -88,3 +88,4 @@ HUBSPOT_PRIVATE_APP_ACCESS_TOKEN= CLERK_ISSUER_URL= CLERK_WEBHOOK_SECRET= +PLAIN_SUPPORT_KEY='PLAIN_SUPPORT_KEY' diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production index 984679452bc..4b3b7986807 100644 --- a/apps/api/src/.env.production +++ b/apps/api/src/.env.production @@ -75,3 +75,4 @@ HUBSPOT_PRIVATE_APP_ACCESS_TOKEN= CLERK_ISSUER_URL= CLERK_WEBHOOK_SECRET= +PLAIN_SUPPORT_KEY='PLAIN_SUPPORT_KEY' diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index 6156beb5ce7..677cf42664b 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -115,3 +115,4 @@ CLERK_PEM_PUBLIC_KEY= TUNNEL_BASE_ADDRESS=example.com API_ROOT_URL=http://localhost:1337 +PLAIN_SUPPORT_KEY='PLAIN_SUPPORT_KEY' diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index 21affbfbe90..680633953b8 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -83,3 +83,4 @@ CLERK_ISSUER_URL= CLERK_LONG_LIVED_TOKEN= TUNNEL_BASE_ADDRESS= +PLAIN_SUPPORT_KEY='PLAIN_SUPPORT_KEY' diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 297ec39b2e3..2dee00d4775 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -55,6 +55,9 @@ const enterpriseImports = (): Array; 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/support/dto/create-thread.dto.ts b/apps/api/src/app/support/dto/create-thread.dto.ts new file mode 100644 index 00000000000..cc61f066ae6 --- /dev/null +++ b/apps/api/src/app/support/dto/create-thread.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class CreateSupportThreadDto { + @ApiProperty() + @IsString() + text: string; +} diff --git a/apps/api/src/app/support/support.controller.ts b/apps/api/src/app/support/support.controller.ts new file mode 100644 index 00000000000..77a0aa64938 --- /dev/null +++ b/apps/api/src/app/support/support.controller.ts @@ -0,0 +1,140 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { UserAuthGuard, UserSession } from '@novu/application-generic'; +import { UserRepository } from '@novu/dal'; +import { UserSessionData } from '@novu/shared'; +import { CreateSupportThreadDto } from './dto/create-thread.dto'; +import { CreateSupportThreadUsecase } from './usecases/create-thread.usecase'; +import { CreateSupportThreadCommand } from './usecases/create-thread.command'; + +@Controller('/support') +export class SupportController { + constructor( + private readonly userRepository: UserRepository, + private createSupportThreadUsecase: CreateSupportThreadUsecase + ) {} + + @Post('plain/cards') + async getPlainCards() { + return { + data: {}, + + cards: [ + { + key: 'plain-customer-details', + components: [ + { + componentSpacer: { + spacerSize: 'S', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: 'Registered at', + textColor: 'MUTED', + }, + }, + ], + rowAsideContent: [ + { + componentText: { + text: '7/18/2024, 1:00 PM', + }, + }, + ], + }, + }, + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: 'Last signed in', + textColor: 'MUTED', + }, + }, + ], + rowAsideContent: [ + { + componentText: { + text: '10/20/2024, 12:57 PM', + }, + }, + ], + }, + }, + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: 'Last device used', + textColor: 'MUTED', + }, + }, + ], + rowAsideContent: [ + { + componentText: { + text: 'iPhone 13 🍎', + }, + }, + ], + }, + }, + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: 'Marketing preferences', + textColor: 'MUTED', + }, + }, + ], + rowAsideContent: [ + { + componentText: { + text: 'Opted out 🙅', + }, + }, + ], + }, + }, + ], + }, + ], + }; + } + + @UseGuards(UserAuthGuard) + @Post('create-thread') + async createThread(@Body() body: CreateSupportThreadDto, @UserSession() user: UserSessionData) { + return this.createSupportThreadUsecase.execute( + CreateSupportThreadCommand.create({ + text: body.text, + email: user.email as string, + firstName: user.firstName as string, + lastName: user.lastName as string, + userId: user._id as string, + }) + ); + } +} diff --git a/apps/api/src/app/support/support.module.ts b/apps/api/src/app/support/support.module.ts new file mode 100644 index 00000000000..ea3ae839616 --- /dev/null +++ b/apps/api/src/app/support/support.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SupportService } from '@novu/application-generic'; +import { SupportController } from './support.controller'; +import { SharedModule } from '../shared/shared.module'; +import { CreateSupportThreadUsecase } from './usecases/create-thread.usecase'; + +@Module({ + imports: [SharedModule], + controllers: [SupportController], + providers: [CreateSupportThreadUsecase, SupportService], +}) +export class SupportModule {} diff --git a/apps/api/src/app/support/usecases/create-thread.command.ts b/apps/api/src/app/support/usecases/create-thread.command.ts new file mode 100644 index 00000000000..e3d332699be --- /dev/null +++ b/apps/api/src/app/support/usecases/create-thread.command.ts @@ -0,0 +1,24 @@ +import { BaseCommand } from '@novu/application-generic'; +import { IsDefined, IsOptional, IsString } from 'class-validator'; + +export class CreateSupportThreadCommand extends BaseCommand { + @IsDefined() + @IsString() + text: string; + + @IsDefined() + @IsString() + email: string; + + @IsDefined() + @IsString() + firstName: string; + + @IsOptional() + @IsString() + lastName?: string; + + @IsDefined() + @IsString() + userId: string; +} diff --git a/apps/api/src/app/support/usecases/create-thread.usecase.ts b/apps/api/src/app/support/usecases/create-thread.usecase.ts new file mode 100644 index 00000000000..4762cbce7f4 --- /dev/null +++ b/apps/api/src/app/support/usecases/create-thread.usecase.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { SupportService } from '@novu/application-generic'; +import { CreateSupportThreadCommand } from './create-thread.command'; +import { capitalize } from '../../shared/services/helper/helper.service'; + +@Injectable() +export class CreateSupportThreadUsecase { + constructor(private supportService: SupportService) {} + + async execute(command: CreateSupportThreadCommand) { + const firstName = capitalize(command.firstName ?? ''); + const lastName = capitalize(command.lastName ?? ''); + const plainCustomer = await this.supportService.upsertCustomer({ + emailAddress: command.email, + fullName: `${firstName} ${lastName}`, + novuUserId: command.userId, + }); + + const thread = await this.supportService.createThread({ + plainCustomerId: plainCustomer.data?.customer?.id, + threadText: command.text, + }); + + return { + success: true, + message: 'Thread created successfully', + threadId: thread.data?.id, + }; + } +} 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..985206f2d90 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], + 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/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 6c36ca6ffc5..e4f731acab7 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 @@ -6,11 +6,13 @@ import { GetStepDataCommand } from './get-step-data.command'; 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'; @Injectable() export class GetStepDataUsecase { constructor( private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, + private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase, private controlValuesRepository: ControlValuesRepository ) {} @@ -21,20 +23,30 @@ export class GetStepDataUsecase { if (!currentStep.name || !currentStep._templateId || !currentStep.stepId) { throw new InvalidStepException(currentStep); } + const controlValues = await this.getValues(command, currentStep, workflow._id); + const payloadSchema = this.buildPayloadSchema(controlValues); return { controls: { dataSchema: currentStep.template?.controls?.schema, uiSchema: currentStep.template?.controls?.uiSchema, - values: await this.getValues(command, currentStep, workflow._id), + values: controlValues, }, - variables: buildVariablesSchema(previousSteps), + variables: buildVariablesSchema(previousSteps, payloadSchema), name: currentStep.name, _id: currentStep._templateId, stepId: currentStep.stepId, }; } + private buildPayloadSchema(controlValues: Record) { + const payloadVariables = this.buildDefaultPayloadUseCase.execute({ + controlValues, + }).previewPayload.payload; + + return buildStringSchema(payloadVariables || {}); + } + private async fetchWorkflow(command: GetStepDataCommand) { const workflow = await this.getWorkflowByIdsUseCase.execute({ identifierOrInternalId: command.identifierOrInternalId, @@ -108,12 +120,16 @@ const buildSubscriberSchema = () => additionalProperties: false, }) as const satisfies JSONSchema; -function buildVariablesSchema(previousSteps?: NotificationStepEntity[]): JSONSchema { +function buildVariablesSchema( + previousSteps: NotificationStepEntity[] | undefined, + payloadSchema: JSONSchema +): JSONSchema { return { type: 'object', properties: { subscriber: buildSubscriberSchema(), steps: buildPreviousStepsSchema(previousSteps), + payload: payloadSchema, }, additionalProperties: false, } as const satisfies JSONSchema; @@ -142,3 +158,22 @@ 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/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..62816b8ab91 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); } }); }); @@ -436,6 +422,44 @@ describe('Workflow Controller E2E API Testing', () => { const stepRetrievedByStepIdentifier = await getStepData(internalWorkflowId, stepIdentifier); expect(stepRetrievedByStepIdentifier._id).to.equal(stepId); }); + + it('should get step payload variables', async () => { + const steps = [ + { + ...buildEmailStep(), + controlValues: { + body: 'Welcome to our newsletter {{bodyText}}{{bodyText2}}{{payload.prefixBodyText}}', + subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}', + }, + }, + { ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{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 stepData = await getStepData(workflowCreated._id, workflowCreated.steps[0]._id); + const { variables } = stepData; + + if (typeof variables === 'boolean') throw new Error('Variables is not an object'); + const { properties } = variables; + expect(properties).to.be.ok; + if (!properties) throw new Error('Payload schema is not valid'); + + expect(properties.payload).to.deep.equal({ + type: 'object', + properties: { + prefixSubjectText: { + type: 'string', + default: '{{payload.prefixSubjectText}}', + }, + prefixBodyText: { + type: 'string', + default: '{{payload.prefixBodyText}}', + }, + }, + }); + }); }); async function updateWorkflowRest(id: string, workflow: UpdateWorkflowDto): Promise { @@ -529,6 +553,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/config/env.validators.ts b/apps/api/src/config/env.validators.ts index 8145ecbc97e..e50279dcc6e 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -52,6 +52,7 @@ export const envValidators = { NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER: str({ default: undefined }), HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID: str({ default: undefined }), HUBSPOT_PRIVATE_APP_ACCESS_TOKEN: str({ default: undefined }), + PLAIN_SUPPORT_KEY: str(), // Feature Flags ...Object.keys(FeatureFlagsKeysEnum).reduce( (acc, key) => { 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/dashboard/src/components/ui/command.tsx b/apps/dashboard/src/components/primitives/command.tsx similarity index 98% rename from apps/dashboard/src/components/ui/command.tsx rename to apps/dashboard/src/components/primitives/command.tsx index df15d293e93..4efab01afd7 100644 --- a/apps/dashboard/src/components/ui/command.tsx +++ b/apps/dashboard/src/components/primitives/command.tsx @@ -3,7 +3,7 @@ import { type DialogProps } from '@radix-ui/react-dialog'; import { Command as CommandPrimitive } from 'cmdk'; import { cn } from '@/utils/ui'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Dialog, DialogContent } from '@/components/primitives/dialog'; import { inputVariants } from '@/components/primitives/variants'; import { InputField } from '@/components/primitives/input'; diff --git a/apps/dashboard/src/components/primitives/dialog.tsx b/apps/dashboard/src/components/primitives/dialog.tsx index 686fcd90c6b..1824088c590 100644 --- a/apps/dashboard/src/components/primitives/dialog.tsx +++ b/apps/dashboard/src/components/primitives/dialog.tsx @@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef< {children} - + Close diff --git a/apps/dashboard/src/components/primitives/hover-to-copy.tsx b/apps/dashboard/src/components/primitives/hover-to-copy.tsx new file mode 100644 index 00000000000..62044e5d33b --- /dev/null +++ b/apps/dashboard/src/components/primitives/hover-to-copy.tsx @@ -0,0 +1,45 @@ +import { cn } from '@/utils/ui'; +import { HTMLAttributes, useRef, useState } from 'react'; +import { RiFileCopyLine } from 'react-icons/ri'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'; + +type HoverToCopyProps = HTMLAttributes & { + valueToCopy: string; +}; + +export const HoverToCopy = (props: HoverToCopyProps) => { + const { valueToCopy, className, children, ...rest } = props; + const [isCopied, setIsCopied] = useState(false); + const triggerRef = useRef(null); + + const copyToClipboard = async (e: React.MouseEvent) => { + e.preventDefault(); + try { + await navigator.clipboard.writeText(valueToCopy); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 1500); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + return ( + + + + {children} + + + + + { + e.preventDefault(); + }} + > + {isCopied ? 'Copied!' : 'Click to copy'} + + + + ); +}; diff --git a/apps/dashboard/src/components/primitives/tag-input.tsx b/apps/dashboard/src/components/primitives/tag-input.tsx index 8a1373b3fbd..9550ab7445e 100644 --- a/apps/dashboard/src/components/primitives/tag-input.tsx +++ b/apps/dashboard/src/components/primitives/tag-input.tsx @@ -3,7 +3,7 @@ import { Badge } from '@/components/primitives/badge'; import { Popover, PopoverAnchor, PopoverContent } from '@/components/primitives/popover'; import { inputVariants } from '@/components/primitives/variants'; -import { CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/primitives/command'; import { cn } from '@/utils/ui'; import { Command } from 'cmdk'; import { forwardRef, useEffect, useState } from 'react'; diff --git a/apps/dashboard/src/components/side-navigation/subscribers-stay-tuned-modal.tsx b/apps/dashboard/src/components/side-navigation/subscribers-stay-tuned-modal.tsx index c60234ab67c..58d82d99f90 100644 --- a/apps/dashboard/src/components/side-navigation/subscribers-stay-tuned-modal.tsx +++ b/apps/dashboard/src/components/side-navigation/subscribers-stay-tuned-modal.tsx @@ -1,6 +1,6 @@ +import { Button } from '@/components/primitives/button'; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTrigger } from '@/components/primitives/dialog'; import { ReactNode } from 'react'; -import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTrigger } from '../primitives/dialog'; -import { Button } from '../primitives/button'; import { RiBookMarkedLine } from 'react-icons/ri'; export const SubscribersStayTunedModal = ({ children }: { children: ReactNode }) => { diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx deleted file mode 100644 index 5b2c249f81f..00000000000 --- a/apps/dashboard/src/components/ui/dialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import * as React from 'react'; -import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { Cross2Icon } from '@radix-ui/react-icons'; - -import { cn } from '@/utils/ui'; - -const Dialog = DialogPrimitive.Root; - -const DialogTrigger = DialogPrimitive.Trigger; - -const DialogPortal = DialogPrimitive.Portal; - -const DialogClose = DialogPrimitive.Close; - -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); -DialogContent.displayName = DialogPrimitive.Content.displayName; - -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( - -); -DialogHeader.displayName = 'DialogHeader'; - -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( - -); -DialogFooter.displayName = 'DialogFooter'; - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogTitle.displayName = DialogPrimitive.Title.displayName; - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogDescription.displayName = DialogPrimitive.Description.displayName; - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -}; diff --git a/apps/dashboard/src/components/workflow-row.tsx b/apps/dashboard/src/components/workflow-row.tsx index 3507ba4175e..93f3e3ef988 100644 --- a/apps/dashboard/src/components/workflow-row.tsx +++ b/apps/dashboard/src/components/workflow-row.tsx @@ -30,6 +30,7 @@ import { } from '@/components/primitives/tooltip'; import { RiMore2Fill, RiPlayCircleLine } from 'react-icons/ri'; import { useSyncWorkflow } from '@/hooks/use-sync-workflow'; +import { HoverToCopy } from '@/components/primitives/hover-to-copy'; type WorkflowRowProps = { workflow: WorkflowListResponseDto; @@ -68,7 +69,9 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { - + + + diff --git a/apps/web/src/api/support.ts b/apps/web/src/api/support.ts new file mode 100644 index 00000000000..b4f4607fd0e --- /dev/null +++ b/apps/web/src/api/support.ts @@ -0,0 +1,7 @@ +import { api } from './api.client'; + +export async function createThread({ threadText }) { + return await api.post('/v1/support/create-thread', { + text: threadText, + }); +} diff --git a/apps/web/src/components/layout/components/SupportModal.tsx b/apps/web/src/components/layout/components/SupportModal.tsx new file mode 100644 index 00000000000..3270dff1ea3 --- /dev/null +++ b/apps/web/src/components/layout/components/SupportModal.tsx @@ -0,0 +1,78 @@ +import { errorMessage, Modal, successMessage } from '@novu/design-system'; +import { css } from '@novu/novui/css'; +import { Button, Title, Textarea } from '@novu/novui'; +import { HStack, Box } from '@novu/novui/jsx'; +import { FC } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { createThread } from '../../../api/support'; + +export type SupportModalProps = { + isOpen: boolean; + toggleOpen: () => void; +}; + +export const SupportModal: FC = ({ isOpen, toggleOpen }) => { + const { + control, + handleSubmit, + formState: { isValid }, + reset, + } = useForm({ + defaultValues: { + threadText: '', + }, + }); + + const onSubmit = async (data) => { + try { + await createThread({ threadText: data.threadText }); + successMessage('Thanks for contacting us! We will get back to you soon.'); + reset(); + toggleOpen(); + } catch (error) { + errorMessage('Something went wrong. Please reach out to us at support@novu.co'); + } + }; + + return ( + <> + Contact Us} onClose={toggleOpen}> + + ( + + )} + /> + + + + + You can also email us as atsupport@novu.co + + + + Submit + + + + + > + ); +}; diff --git a/apps/web/src/components/layout/components/v2/HeaderNav.tsx b/apps/web/src/components/layout/components/v2/HeaderNav.tsx index 39ef8f7e4e9..00b0626c064 100644 --- a/apps/web/src/components/layout/components/v2/HeaderNav.tsx +++ b/apps/web/src/components/layout/components/v2/HeaderNav.tsx @@ -1,10 +1,11 @@ +import { useState } from 'react'; import { ActionIcon, Header } from '@mantine/core'; -import { IconHelpOutline } from '@novu/novui/icons'; -import { Tooltip } from '@novu/design-system'; +import { IconHelpOutline, IconOutlineChat, IconOutlineLibraryBooks, IconOutlineGroup } from '@novu/novui/icons'; +import { Tooltip, Dropdown } from '@novu/design-system'; import { css } from '@novu/novui/css'; import { HStack } from '@novu/novui/jsx'; import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { IS_EE_AUTH_ENABLED, IS_SELF_HOSTED } from '../../../../config'; +import { IS_EE_AUTH_ENABLED, IS_NOVU_PROD_STAGING } from '../../../../config'; import { useBootIntercom, useFeatureFlag } from '../../../../hooks'; import useThemeChange from '../../../../hooks/useThemeChange'; import { discordInviteUrl } from '../../../../pages/quick-start/consts'; @@ -15,17 +16,21 @@ import { HeaderMenuItems } from './HeaderMenuItems'; import { UserProfileButton } from '../../../../ee/clerk'; import { BridgeMenuItems } from './BridgeMenuItems'; import { WorkflowHeaderBackButton } from './WorkflowHeaderBackButton'; +import { SupportModal } from '../SupportModal'; export function HeaderNav() { const { currentUser } = useAuth(); - - const isSelfHosted = IS_SELF_HOSTED; + const [isSupportModalOpened, setIsSupportModalOpened] = useState(false); const isV2Enabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_ENABLED); useBootIntercom(); const { Icon, themeLabel, toggleColorScheme } = useThemeChange(); + const toggleSupportModalShow = () => { + setIsSupportModalOpened((previous) => !previous); + }; + return ( + {/* Ugly fallback to satisfy the restrictive typings of the NotificationCenterWidget */} - - {isSelfHosted ? ( + + {IS_NOVU_PROD_STAGING ? ( + + + + } + > + + + + Join us on Discord + + + + + + + Documentation + + + + + { + toggleSupportModalShow(); + }} + > + + Contact Us + + + + ) : ( - ) : ( - - - )} + {IS_EE_AUTH_ENABLED ? : } + ); } diff --git a/apps/web/src/config/index.ts b/apps/web/src/config/index.ts index fa2a3d8262c..3eb73a93319 100644 --- a/apps/web/src/config/index.ts +++ b/apps/web/src/config/index.ts @@ -52,6 +52,9 @@ export const WIDGET_EMBED_PATH = export const IS_SELF_HOSTED = window._env_.REACT_APP_IS_SELF_HOSTED === 'true' || process.env.REACT_APP_IS_SELF_HOSTED === 'true'; +// To test feature in prod and staging. Excluding self host and local +export const IS_NOVU_PROD_STAGING = !IS_SELF_HOSTED && !API_ROOT.includes('localhost'); + export const REACT_APP_VERSION = process.env.NOVU_VERSION; export const INTERCOM_APP_ID = window._env_.REACT_APP_INTERCOM_APP_ID || process.env.REACT_APP_INTERCOM_APP_ID || ''; diff --git a/libs/application-generic/package.json b/libs/application-generic/package.json index 024ae79359a..2dbd578c815 100644 --- a/libs/application-generic/package.json +++ b/libs/application-generic/package.json @@ -69,6 +69,7 @@ "@pyroscope/nodejs": "^0.2.9", "@segment/analytics-node": "^1.1.4", "@sentry/node": "^8.33.1", + "@team-plain/typescript-sdk": "5.4.3", "axios": "^1.6.8", "bullmq": "^3.10.2", "class-transformer": "0.5.1", diff --git a/libs/application-generic/src/.env.test b/libs/application-generic/src/.env.test index c0084fcd988..41cd1bf0fc2 100644 --- a/libs/application-generic/src/.env.test +++ b/libs/application-generic/src/.env.test @@ -85,3 +85,4 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 NOVU_SMS_INTEGRATION_ACCOUNT_SID=test NOVU_SMS_INTEGRATION_TOKEN=test NOVU_SMS_INTEGRATION_SENDER=1234567890 +PLAIN_SUPPORT_KEY='PLAIN_SUPPORT_KEY' diff --git a/libs/application-generic/src/services/index.ts b/libs/application-generic/src/services/index.ts index b33cbd8296b..9db30b12348 100644 --- a/libs/application-generic/src/services/index.ts +++ b/libs/application-generic/src/services/index.ts @@ -6,6 +6,7 @@ export * from './queues'; export * from './workers'; export { INovuWorker, ReadinessService } from './readiness'; export { AnalyticsService } from './analytics.service'; +export { SupportService } from './support.service'; export { VerifyPayloadService } from './verify-payload.service'; export { EventsDistributedLockService } from './events-distributed-lock.service'; export * from './calculate-delay'; diff --git a/libs/application-generic/src/services/support.service.ts b/libs/application-generic/src/services/support.service.ts new file mode 100644 index 00000000000..5a52aff3e58 --- /dev/null +++ b/libs/application-generic/src/services/support.service.ts @@ -0,0 +1,80 @@ +import { Logger } from '@nestjs/common'; +import { PlainClient, UpsertResult } from '@team-plain/typescript-sdk'; + +const LOG_CONTEXT = 'SupportService'; + +export class SupportService { + private plainClient: PlainClient; + private readonly plainKey: string; + constructor() { + this.plainKey = process.env.PLAIN_SUPPORT_KEY; + if (this.plainKey) { + this.plainClient = new PlainClient({ apiKey: this.plainKey }); + Logger.log(`Initialized PlainClient`, LOG_CONTEXT); + } else { + Logger.log('Skipping PlainClient initialization', LOG_CONTEXT); + } + } + + async upsertCustomer({ emailAddress, fullName, novuUserId }) { + const res = await this.plainClient?.upsertCustomer({ + identifier: { + emailAddress, + }, + onCreate: { + email: { + email: emailAddress, + isVerified: true, + }, + externalId: novuUserId, + fullName, + }, + onUpdate: { + externalId: { value: novuUserId }, + email: { + email: emailAddress, + isVerified: true, + }, + fullName: { + value: fullName, + }, + }, + }); + if (res.error) { + Logger.error( + { emailAddress, fullName, error: res.error }, + res.error.message, + LOG_CONTEXT, + ); + throw new Error(res.error.message); + } else { + return res; + } + } + + async createThread({ plainCustomerId, threadText }) { + const res = await this.plainClient?.createThread({ + customerIdentifier: { + customerId: plainCustomerId, + }, + components: [ + { + componentText: { + text: threadText, + }, + }, + ], + }); + + if (res.error) { + Logger.error( + { plainCustomerId, threadText, error: res.error }, + res.error.message, + LOG_CONTEXT, + ); + throw new Error(res.error.message); + } else { + return res; + } + } +} diff --git a/libs/novui/src/components/textarea/Textarea.tsx b/libs/novui/src/components/textarea/Textarea.tsx index 66931bc75a3..fc2e168ff1e 100644 --- a/libs/novui/src/components/textarea/Textarea.tsx +++ b/libs/novui/src/components/textarea/Textarea.tsx @@ -11,7 +11,7 @@ import { DEFAULT_TEXT_INPUT_TYPE, TextInputType } from '../input'; export interface TextareaProps extends JsxStyleProps, CoreProps, - Pick, + Pick, Partial { label?: LocalizedMessage; description?: LocalizedMessage; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index d5200de380d..4987a3daebe 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -17,11 +17,23 @@ "publishConfig": { "access": "public" }, + "exports": { + ".": { + "import": { + "types": "./dist/client/index.d.mts", + "default": "./dist/client/index.mjs" + }, + "require": { + "types": "./dist/client/index.d.ts", + "default": "./dist/client/index.js" + } + } + }, "scripts": { "build:watch": "tsup --watch", - "build": "tsup && pnpm run check-exports", + "build": "tsup && pnpm run check:exports", "lint": "eslint src", - "check-exports": "attw --pack .", + "check:exports": "attw --pack .", "release:preview": "pnpx pkg-pr-new publish" }, "devDependencies": { diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts index 1124f4174ba..fa61f1f13df 100644 --- a/packages/react-native/tsup.config.ts +++ b/packages/react-native/tsup.config.ts @@ -9,5 +9,7 @@ export default defineConfig([ sourcemap: true, clean: true, dts: true, + treeshake: false, + bundle: false, }, ]); 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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0f2e869fbd..ba64c71b183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2598,6 +2598,9 @@ importers: '@sentry/node': specifier: ^8.33.1 version: 8.33.1 + '@team-plain/typescript-sdk': + specifier: 5.4.3 + version: 5.4.3 axios: specifier: ^1.6.8 version: 1.6.8 @@ -16177,6 +16180,9 @@ packages: '@taskforcesh/bullmq-pro@5.1.14': resolution: {integrity: sha512-J/83Q1GTFWbUWn1bpsiX+CcQXktp7ADg/d1oID+wQ8ZwSB2W5l/1FV4yR1BEi3sO+UFEo+rK3qfXQuDml7aBYA==, tarball: https://npm.taskforce.sh/@taskforcesh/bullmq-pro/-/@taskforcesh/bullmq-pro-5.1.14.tgz} + '@team-plain/typescript-sdk@5.4.3': + resolution: {integrity: sha512-660VeutTdaspdzsgpLHKhYsBxX0BOH/O4r0FTi1HD67QyHnKeaMRbxBAen7Yi+vpcpBc3gJGq4HtgHWPZ5WEoA==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -29629,7 +29635,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode.react@3.1.0: @@ -44348,7 +44353,6 @@ snapshots: '@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)': dependencies: graphql: 16.9.0 - optional: true '@grpc/grpc-js@1.11.1': dependencies: @@ -56070,6 +56074,15 @@ snapshots: - supports-color optional: true + '@team-plain/typescript-sdk@5.4.3': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) + graphql: 16.9.0 + lodash.get: 4.4.2 + zod: 3.22.4 + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.24.7
{isCopied ? 'Copied!' : 'Click to copy'}