From e5d1ee551f96c98f0b7a36ec033657a05523f948 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 21 Jan 2025 15:07:50 +0530 Subject: [PATCH 01/25] feat(api): Nv 5101 email sent from new dashbord has message clipped block in (#7545) --- .../email-output-renderer.usecase.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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 index 2ea73395be7..7f65b73684f 100644 --- 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 @@ -33,7 +33,8 @@ export class EmailOutputRendererUsecase { fullPayloadForRender: renderCommand.fullPayloadForRender, }); const parsedTipTap = await this.parseTipTapNodeByLiquid(expandedMailyContent, renderCommand); - const renderedHtml = await mailyRender(parsedTipTap); + const strippedTipTap = this.removeTrailingEmptyLines(parsedTipTap); + const renderedHtml = await mailyRender(strippedTipTap); /** * Force type mapping in case undefined control. @@ -43,6 +44,30 @@ export class EmailOutputRendererUsecase { return { subject: subject as string, body: renderedHtml }; } + private removeTrailingEmptyLines(node: TipTapNode): TipTapNode { + if (!node.content || node.content.length === 0) return node; + + // Iterate from the end of the content and find the first non-empty node + let lastIndex = node.content.length; + // eslint-disable-next-line no-plusplus + for (let i = node.content.length - 1; i >= 0; i--) { + const childNode = node.content[i]; + + const isEmptyParagraph = + childNode.type === 'paragraph' && !childNode.text && (!childNode.content || childNode.content.length === 0); + + if (!isEmptyParagraph) { + lastIndex = i + 1; // Include this node in the result + break; + } + } + + // Slice the content to remove trailing empty nodes + const filteredContent = node.content.slice(0, lastIndex); + + return { ...node, content: filteredContent }; + } + private async parseTipTapNodeByLiquid( tiptapNode: TipTapNode, renderCommand: EmailOutputRendererCommand From 52f3f922eead429beed1dbcf36867c1b17f23375 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 21 Jan 2025 12:15:21 +0200 Subject: [PATCH 02/25] fix(dashboard): created at error on activity feed --- .../activity/components/status-preview-card.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/components/activity/components/status-preview-card.tsx b/apps/dashboard/src/components/activity/components/status-preview-card.tsx index 5edf5dabbb0..e80721e0ba7 100644 --- a/apps/dashboard/src/components/activity/components/status-preview-card.tsx +++ b/apps/dashboard/src/components/activity/components/status-preview-card.tsx @@ -1,9 +1,9 @@ -import { format } from 'date-fns'; -import { cn } from '@/utils/ui'; import { STEP_TYPE_TO_ICON } from '@/components/icons/utils'; -import { JOB_STATUS_CONFIG } from '../constants'; -import { IActivityJob, JobStatusEnum, StepTypeEnum } from '@novu/shared'; import { STEP_TYPE_LABELS } from '@/utils/constants'; +import { cn } from '@/utils/ui'; +import { IActivityJob, JobStatusEnum, StepTypeEnum } from '@novu/shared'; +import { format } from 'date-fns'; +import { JOB_STATUS_CONFIG } from '../constants'; function getStepIcon(type?: StepTypeEnum) { const Icon = STEP_TYPE_TO_ICON[type as keyof typeof STEP_TYPE_TO_ICON]; @@ -45,9 +45,11 @@ export function StatusPreviewCard({ jobs }: StatusPreviewCardProps) { )} -
- {format(new Date(job.createdAt), 'HH:mm')} -
+ {job.createdAt && ( +
+ {format(new Date(job.createdAt), 'HH:mm')} +
+ )} ); })} From 426ad151b9a044bc8897343709821d5ca57f22bf Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Tue, 21 Jan 2025 12:12:36 +0100 Subject: [PATCH 03/25] refactor(api-service): simplify email render flow (#7544) --- .cspell.json | 3 +- apps/api/src/app/bridge/bridge.module.ts | 2 - .../app/environments-v1/novu-bridge.module.ts | 6 +- .../email-output-renderer.spec.ts | 150 ++++++++-- .../email-output-renderer.usecase.ts | 259 +++++++++++++++--- .../expand-email-editor-schema-command.ts | 7 - .../expand-email-editor-schema.usecase.ts | 213 -------------- .../hydrate-email-schema.command.ts | 7 - .../hydrate-email-schema.usecase.ts | 142 ---------- .../usecases/output-renderers/index.ts | 3 - .../maily-to-liquid/maily.types.ts | 31 +++ .../wrap-maily-in-liquid.command.ts | 45 +++ .../wrap-maily-in-liquid.usecase.ts | 208 ++++++++++++++ .../workflows-v2/e2e/generate-preview.e2e.ts | 144 ---------- .../app/workflows-v2/generate-preview.e2e.ts | 164 ++--------- .../src/app/workflows-v2/maily-test-data.ts | 103 ++++--- .../build-payload-schema.usecase.ts | 40 +-- .../generate-preview.usecase.ts | 9 +- .../app/workflows-v2/util/build-variables.ts | 18 +- .../src/app/workflows-v2/util/tip-tap.util.ts | 62 ----- apps/api/src/app/workflows-v2/util/utils.ts | 50 +--- .../src/app/workflows-v2/workflow.module.ts | 2 - libs/application-generic/src/index.ts | 1 - .../src/utils/process-node-attrs.ts | 160 ----------- .../src/dto/workflows/control-schemas.ts | 12 - packages/shared/src/dto/workflows/index.ts | 1 - 26 files changed, 752 insertions(+), 1090 deletions(-) delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts delete mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/maily.types.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command.ts create mode 100644 apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase.ts delete mode 100644 apps/api/src/app/workflows-v2/util/tip-tap.util.ts delete mode 100644 libs/application-generic/src/utils/process-node-attrs.ts delete mode 100644 packages/shared/src/dto/workflows/control-schemas.ts diff --git a/.cspell.json b/.cspell.json index 25aedeab6db..411a1f461cb 100644 --- a/.cspell.json +++ b/.cspell.json @@ -724,7 +724,8 @@ "navigatable", "facated", "dotenvcreate", - "querybuilder" + "querybuilder", + "liquified" ], "flagWords": [], "patterns": [ diff --git a/apps/api/src/app/bridge/bridge.module.ts b/apps/api/src/app/bridge/bridge.module.ts index 6a4c9940120..eee79d5997f 100644 --- a/apps/api/src/app/bridge/bridge.module.ts +++ b/apps/api/src/app/bridge/bridge.module.ts @@ -20,7 +20,6 @@ import { SharedModule } from '../shared/shared.module'; import { BridgeController } from './bridge.controller'; import { USECASES } from './usecases'; import { BuildVariableSchemaUsecase } from '../workflows-v2/usecases/build-variable-schema'; -import { HydrateEmailSchemaUseCase } from '../environments-v1/usecases/output-renderers/hydrate-email-schema.usecase'; import { BuildPayloadSchema } from '../workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase'; import { BuildStepIssuesUsecase } from '../workflows-v2/usecases/build-step-issues/build-step-issues.usecase'; @@ -42,7 +41,6 @@ const PROVIDERS = [ UpsertControlValuesUseCase, BuildVariableSchemaUsecase, TierRestrictionsValidateUsecase, - HydrateEmailSchemaUseCase, CommunityOrganizationRepository, BuildPayloadSchema, BuildStepIssuesUsecase, 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 9339893706e..dbd97b3804b 100644 --- a/apps/api/src/app/environments-v1/novu-bridge.module.ts +++ b/apps/api/src/app/environments-v1/novu-bridge.module.ts @@ -8,8 +8,6 @@ import { ConstructFrameworkWorkflow } from './usecases/construct-framework-workf import { NovuBridgeController } from './novu-bridge.controller'; import { ChatOutputRendererUsecase, - ExpandEmailEditorSchemaUsecase, - HydrateEmailSchemaUseCase, InAppOutputRendererUsecase, PushOutputRendererUsecase, EmailOutputRendererUsecase, @@ -17,6 +15,7 @@ import { } from './usecases/output-renderers'; import { DelayOutputRendererUsecase } from './usecases/output-renderers/delay-output-renderer.usecase'; import { DigestOutputRendererUsecase } from './usecases/output-renderers/digest-output-renderer.usecase'; +import { WrapMailyInLiquidUseCase } from './usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase'; @Module({ controllers: [NovuBridgeController], @@ -36,8 +35,7 @@ import { DigestOutputRendererUsecase } from './usecases/output-renderers/digest- ChatOutputRendererUsecase, PushOutputRendererUsecase, EmailOutputRendererUsecase, - ExpandEmailEditorSchemaUsecase, - HydrateEmailSchemaUseCase, + WrapMailyInLiquidUseCase, DelayOutputRendererUsecase, DigestOutputRendererUsecase, ], diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts index 805431ce692..a087fb6336f 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts @@ -1,24 +1,19 @@ import { Test } from '@nestjs/testing'; import { expect } from 'chai'; -import { TipTapNode } from '@novu/shared'; +import { JSONContent as MailyJSONContent } from '@maily-to/render'; import { EmailOutputRendererUsecase } from './email-output-renderer.usecase'; -import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; -import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase'; import { FullPayloadForRender } from './render-command'; +import { WrapMailyInLiquidUseCase } from './maily-to-liquid/wrap-maily-in-liquid.usecase'; describe('EmailOutputRendererUsecase', () => { let emailOutputRendererUsecase: EmailOutputRendererUsecase; - let expandEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase; - let hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ - providers: [EmailOutputRendererUsecase, ExpandEmailEditorSchemaUsecase, HydrateEmailSchemaUseCase], + providers: [EmailOutputRendererUsecase, WrapMailyInLiquidUseCase], }).compile(); emailOutputRendererUsecase = moduleRef.get(EmailOutputRendererUsecase); - expandEmailEditorSchemaUseCase = moduleRef.get(ExpandEmailEditorSchemaUsecase); - hydrateEmailSchemaUseCase = moduleRef.get(HydrateEmailSchemaUseCase); }); const mockFullPayload: FullPayloadForRender = { @@ -61,7 +56,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should process simple text with liquid variables', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -94,7 +89,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should handle nested object variables with liquid syntax', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -132,7 +127,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should handle liquid variables with default values', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -167,7 +162,7 @@ describe('EmailOutputRendererUsecase', () => { describe('variable node transformation to text', () => { it('should handle maily variables', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -237,7 +232,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should handle maily variables with fallback values', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -340,7 +335,7 @@ describe('EmailOutputRendererUsecase', () => { describe('conditional block transformation (showIfKey)', () => { it('should render content when showIfKey condition is true', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -397,7 +392,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should not render content when showIfKey condition is false', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -459,7 +454,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should handle nested conditional blocks correctly', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -543,12 +538,127 @@ describe('EmailOutputRendererUsecase', () => { }); describe('for block transformation and expansion', () => { - // Tests for for loop block transformation will be added here + it('should handle for loop block transformation with array of objects', async () => { + const mockTipTapNode: MailyJSONContent = { + type: 'doc', + content: [ + { + type: 'for', + attrs: { + each: 'payload.comments', + isUpdatingKey: false, + showIfKey: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'text', + text: 'This is an author: ', + }, + { + type: 'variable', + attrs: { + id: 'payload.comments.author', + label: null, + fallback: null, + required: false, + }, + }, + { + type: 'variable', + attrs: { + // variable not belonging to the loop + id: 'payload.postTitle', + label: null, + fallback: null, + required: false, + }, + }, + ], + }, + ], + }, + ], + }; + + const renderCommand = { + controlValues: { + subject: 'For Loop Test', + body: JSON.stringify(mockTipTapNode), + }, + fullPayloadForRender: { + ...mockFullPayload, + payload: { + postTitle: 'Post Title', + comments: [{ author: 'John' }, { author: 'Jane' }], + }, + }, + }; + const result = await emailOutputRendererUsecase.execute(renderCommand); + expect(result.body).to.include('This is an author: JohnPost Title'); + expect(result.body).to.include('This is an author: JanePost Title'); + }); + + it('should handle for loop block transformation with array of primitives', async () => { + const mockTipTapNode: MailyJSONContent = { + type: 'doc', + content: [ + { + type: 'for', + attrs: { + each: 'payload.names', + isUpdatingKey: false, + showIfKey: null, + }, + content: [ + { + type: 'paragraph', + attrs: { + textAlign: 'left', + }, + content: [ + { + type: 'variable', + attrs: { + id: 'payload.names', + label: null, + fallback: null, + required: false, + }, + }, + ], + }, + ], + }, + ], + }; + + const renderCommand = { + controlValues: { + subject: 'For Loop Test', + body: JSON.stringify(mockTipTapNode), + }, + fullPayloadForRender: { + ...mockFullPayload, + payload: { + names: ['John', 'Jane'], + }, + }, + }; + const result = await emailOutputRendererUsecase.execute(renderCommand); + expect(result.body).to.include('John'); + expect(result.body).to.include('Jane'); + }); }); describe('node attrs and marks attrs hydration', () => { it('should handle links with href attributes', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -601,7 +711,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should handle image nodes with variable attributes', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { @@ -637,7 +747,7 @@ describe('EmailOutputRendererUsecase', () => { }); it('should handle marks attrs href', async () => { - const mockTipTapNode: TipTapNode = { + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ { 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 index 7f65b73684f..82e61ba24c9 100644 --- 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 @@ -1,16 +1,18 @@ -import { render as mailyRender } from '@maily-to/render'; +/* eslint-disable no-param-reassign */ +import { render as mailyRender, JSONContent as MailyJSONContent } from '@maily-to/render'; import { Injectable } from '@nestjs/common'; import { Liquid } from 'liquidjs'; -import { EmailRenderOutput, TipTapNode } from '@novu/shared'; +import { EmailRenderOutput } from '@novu/shared'; import { InstrumentUsecase } from '@novu/application-generic'; import { FullPayloadForRender, RenderCommand } from './render-command'; -import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; +import { WrapMailyInLiquidUseCase } from './maily-to-liquid/wrap-maily-in-liquid.usecase'; +import { MAILY_ITERABLE_MARK, MailyAttrsEnum, MailyContentTypeEnum } from './maily-to-liquid/maily.types'; export class EmailOutputRendererCommand extends RenderCommand {} @Injectable() export class EmailOutputRendererUsecase { - constructor(private expandEmailEditorSchemaUseCase: ExpandEmailEditorSchemaUsecase) {} + constructor(private wrapMailyInLiquidUsecase: WrapMailyInLiquidUseCase) {} @InstrumentUsecase() async execute(renderCommand: EmailOutputRendererCommand): Promise { @@ -28,13 +30,11 @@ export class EmailOutputRendererUsecase { }; } - const expandedMailyContent = await this.expandEmailEditorSchemaUseCase.execute({ - emailEditorJson: body, - fullPayloadForRender: renderCommand.fullPayloadForRender, - }); - const parsedTipTap = await this.parseTipTapNodeByLiquid(expandedMailyContent, renderCommand); - const strippedTipTap = this.removeTrailingEmptyLines(parsedTipTap); - const renderedHtml = await mailyRender(strippedTipTap); + const liquifiedMaily = this.wrapMailyInLiquidUsecase.execute({ emailEditor: body }); + const transformedMaily = await this.transformMailyContent(liquifiedMaily, renderCommand.fullPayloadForRender); + const parsedMaily = await this.parseMailyContentByLiquid(transformedMaily, renderCommand.fullPayloadForRender); + const strippedMaily = this.removeTrailingEmptyLines(parsedMaily); + const renderedHtml = await mailyRender(strippedMaily); /** * Force type mapping in case undefined control. @@ -44,7 +44,7 @@ export class EmailOutputRendererUsecase { return { subject: subject as string, body: renderedHtml }; } - private removeTrailingEmptyLines(node: TipTapNode): TipTapNode { + private removeTrailingEmptyLines(node: MailyJSONContent): MailyJSONContent { if (!node.content || node.content.length === 0) return node; // Iterate from the end of the content and find the first non-empty node @@ -68,36 +68,223 @@ export class EmailOutputRendererUsecase { return { ...node, content: filteredContent }; } - private async parseTipTapNodeByLiquid( - tiptapNode: TipTapNode, - renderCommand: EmailOutputRendererCommand - ): Promise { - const parsedString = await parseLiquid(JSON.stringify(tiptapNode), renderCommand.fullPayloadForRender); + private async parseMailyContentByLiquid( + mailyContent: MailyJSONContent, + variables: FullPayloadForRender + ): Promise { + const parsedString = await this.parseLiquid(JSON.stringify(mailyContent), variables); return JSON.parse(parsedString); } -} -export const parseLiquid = async (value: string, variables: FullPayloadForRender): Promise => { - const client = new Liquid({ - outputEscape: (output) => { - return stringifyDataStructureWithSingleQuotes(output); - }, - }); + private async transformMailyContent( + node: MailyJSONContent, + variables: FullPayloadForRender, + parent?: MailyJSONContent + ) { + const queue: Array<{ node: MailyJSONContent; parent?: MailyJSONContent }> = [{ node, parent }]; + + while (queue.length > 0) { + const current = queue.shift()!; + + if (this.hasShow(current.node)) { + await this.handleShowNode(current.node, variables, current.parent); + } + + if (this.isForNode(current.node)) { + await this.handleEachNode(current.node, variables, current.parent); + } + + if (this.isVariableNode(current.node)) { + this.processVariableNodeTypes(current.node); + } + + if (current.node.content) { + for (const childNode of current.node.content) { + queue.push({ node: childNode, parent: current.node }); + } + } + } + + return node; + } + + private async handleShowNode( + node: MailyJSONContent & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } }, + variables: FullPayloadForRender, + parent?: MailyJSONContent + ): Promise { + const shouldShow = await this.evaluateShowCondition(variables, node); + if (!shouldShow && parent?.content) { + parent.content = parent.content.filter((pNode) => pNode !== node); + + return; + } + + // @ts-ignore + delete node.attrs[MailyAttrsEnum.SHOW_IF_KEY]; + } + + private async handleEachNode( + node: MailyJSONContent & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } }, + variables: FullPayloadForRender, + parent?: MailyJSONContent + ): Promise { + const newContent = await this.multiplyForEachNode(node, variables); + + if (parent?.content) { + const nodeIndex = parent.content.indexOf(node); + parent.content = [...parent.content.slice(0, nodeIndex), ...newContent, ...parent.content.slice(nodeIndex + 1)]; + } else { + node.content = newContent; + } + } + + private async evaluateShowCondition( + variables: FullPayloadForRender, + node: MailyJSONContent & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } + ): Promise { + const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs; + const parsedShowIfValue = await this.parseLiquid(showIfKey, variables); - const template = client.parse(value); + return this.stringToBoolean(parsedShowIfValue); + } + + private processVariableNodeTypes(node: MailyJSONContent) { + node.type = 'text'; // set 'variable' to 'text' to for Liquid to recognize it + node.text = node.attrs?.id || ''; + } + + private isForNode( + node: MailyJSONContent + ): node is MailyJSONContent & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } } { + return !!( + node.type === MailyContentTypeEnum.FOR && + node.attrs && + node.attrs[MailyAttrsEnum.EACH_KEY] !== undefined && + typeof node.attrs[MailyAttrsEnum.EACH_KEY] === 'string' + ); + } - return await client.render(template, variables); -}; + private hasShow( + node: MailyJSONContent + ): node is MailyJSONContent & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } { + return node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== undefined && node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== null; + } -const stringifyDataStructureWithSingleQuotes = (value: unknown, spaces: number = 0): string => { - if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { - const valueStringified = JSON.stringify(value, null, spaces); - const valueSingleQuotes = valueStringified.replace(/"/g, "'"); - const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); + /** + * For 'each' node, multiply the content by the number of items in the iterable array + * and add indexes to the placeholders. + * + * @example + * node: + * { + * type: 'each', + * attrs: { each: '{{ payload.comments }}' }, + * content: [ + * { type: 'variable', text: '{{ payload.comments[0].author }}' } + * ] + * } + * + * variables: + * { payload: { comments: [{ author: 'John Doe' }, { author: 'Jane Doe' }] } } + * + * result: + * [ + * { type: 'text', text: '{{ payload.comments[0].author }}' }, + * { type: 'text', text: '{{ payload.comments[1].author }}' } + * ] + * + */ + private async multiplyForEachNode( + node: MailyJSONContent & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } }, + variables: FullPayloadForRender + ): Promise { + const iterablePath = node.attrs[MailyAttrsEnum.EACH_KEY]; + const forEachNodes = node.content || []; + const iterableArray = await this.getIterableArray(iterablePath, variables); - return valueEscapedNewLines; - } else { - return String(value); + return iterableArray.flatMap((_, index) => this.processForEachNodes(forEachNodes, iterablePath, index)); } -}; + + private async getIterableArray(iterablePath: string, variables: FullPayloadForRender): Promise { + const iterableArrayString = await this.parseLiquid(iterablePath, variables); + + try { + const parsedArray = JSON.parse(iterableArrayString.replace(/'/g, '"')); + + if (!Array.isArray(parsedArray)) { + throw new Error(`Iterable "${iterablePath}" is not an array`); + } + + return parsedArray; + } catch (error) { + throw new Error(`Failed to parse iterable value for "${iterablePath}": ${error.message}`); + } + } + + private processForEachNodes(nodes: MailyJSONContent[], iterablePath: string, index: number): MailyJSONContent[] { + return nodes.map((node) => { + const processedNode = { ...node }; + + if (this.isVariableNode(processedNode)) { + this.processVariableNodeTypes(processedNode); + + if (processedNode.text) { + processedNode.text = processedNode.text.replace(MAILY_ITERABLE_MARK, index.toString()); + } + + return processedNode; + } + + if (processedNode.content?.length) { + processedNode.content = this.processForEachNodes(processedNode.content, iterablePath, index); + } + + return processedNode; + }); + } + + private stringToBoolean(value: unknown): boolean { + if (typeof value === 'string') { + return value.toLowerCase() === 'true'; + } + + return false; + } + + private isVariableNode( + node: MailyJSONContent + ): node is MailyJSONContent & { attrs: { [MailyAttrsEnum.ID]: string } } { + return !!( + node.type === MailyContentTypeEnum.VARIABLE && + node.attrs && + node.attrs[MailyAttrsEnum.ID] !== undefined && + typeof node.attrs[MailyAttrsEnum.ID] === 'string' + ); + } + + private parseLiquid(value: string, variables: FullPayloadForRender): Promise { + const client = new Liquid({ + outputEscape: (output) => { + return this.stringifyDataStructureWithSingleQuotes(output); + }, + }); + + const template = client.parse(value); + + return client.render(template, variables); + } + + private stringifyDataStructureWithSingleQuotes(value: unknown, spaces: number = 0): string { + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + const valueStringified = JSON.stringify(value, null, spaces); + const valueSingleQuotes = valueStringified.replace(/"/g, "'"); + const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); + + return valueEscapedNewLines; + } else { + return String(value); + } + } +} 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 deleted file mode 100644 index 9a4dbe23a6c..00000000000 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema-command.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseCommand } from '@novu/application-generic'; -import { FullPayloadForRender } from './render-command'; - -export class ExpandEmailEditorSchemaCommand extends BaseCommand { - emailEditorJson: 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 deleted file mode 100644 index ed887eed248..00000000000 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/expand-email-editor-schema.usecase.ts +++ /dev/null @@ -1,213 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { TipTapNode } from '@novu/shared'; -import { Injectable } from '@nestjs/common'; -import { MAILY_ITERABLE_MARK, MailyAttrsEnum } from '@novu/application-generic'; -import { ExpandEmailEditorSchemaCommand } from './expand-email-editor-schema-command'; -import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase'; -import { parseLiquid } from './email-output-renderer.usecase'; -import { FullPayloadForRender } from './render-command'; - -@Injectable() -export class ExpandEmailEditorSchemaUsecase { - constructor(private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase) {} - - async execute(command: ExpandEmailEditorSchemaCommand): Promise { - const hydratedEmailSchema = this.hydrateEmailSchemaUseCase.execute({ - emailEditor: command.emailEditorJson, - }); - - const processed = await this.processSpecialNodeTypes(command.fullPayloadForRender, hydratedEmailSchema); - - // needs to be done after the special node types are processed - this.processVariableNodeTypes(processed, command.fullPayloadForRender); - - return processed; - } - - private async processSpecialNodeTypes(variables: FullPayloadForRender, rootNode: TipTapNode): Promise { - const processedNode = structuredClone(rootNode); - await this.traverseAndProcessNodes(processedNode, variables); - - return processedNode; - } - - private async traverseAndProcessNodes( - node: TipTapNode, - variables: FullPayloadForRender, - parent?: TipTapNode - ): Promise { - const queue: Array<{ node: TipTapNode; parent?: TipTapNode }> = [{ node, parent }]; - - while (queue.length > 0) { - const current = queue.shift()!; - await this.processNode(current.node, variables, current.parent); - - if (current.node.content) { - for (const childNode of current.node.content) { - queue.push({ node: childNode, parent: current.node }); - } - } - } - } - - private async processNode(node: TipTapNode, variables: FullPayloadForRender, parent?: TipTapNode): Promise { - if (this.hasShow(node)) { - await this.handleShowNode(node, variables, parent); - } - - if (this.hasEach(node)) { - await this.handleEachNode(node, variables, parent); - } - } - - private async handleShowNode( - node: TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } }, - variables: FullPayloadForRender, - parent?: TipTapNode - ): Promise { - const shouldShow = await this.evaluateShowCondition(variables, node); - if (!shouldShow && parent?.content) { - parent.content = parent.content.filter((pNode) => pNode !== node); - - return; - } - - // @ts-ignore - delete node.attrs[MailyAttrsEnum.SHOW_IF_KEY]; - } - - private async handleEachNode( - node: TipTapNode & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } }, - variables: FullPayloadForRender, - parent?: TipTapNode - ): Promise { - const newContent = await this.multiplyForEachNode(node, variables); - if (parent?.content) { - const nodeIndex = parent.content.indexOf(node); - parent.content = [...parent.content.slice(0, nodeIndex), ...newContent, ...parent.content.slice(nodeIndex + 1)]; - } else { - node.content = newContent; - } - } - - private async evaluateShowCondition( - variables: FullPayloadForRender, - node: TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } - ): Promise { - const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs; - const parsedShowIfValue = await parseLiquid(showIfKey, variables); - - return this.stringToBoolean(parsedShowIfValue); - } - - private processVariableNodeTypes(node: TipTapNode, variables: FullPayloadForRender) { - if (this.isAVariableNode(node)) { - node.type = 'text'; // set 'variable' to 'text' to for Liquid to recognize it - } - - node.content?.forEach((innerNode) => this.processVariableNodeTypes(innerNode, variables)); - } - - private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } } { - return node.attrs?.[MailyAttrsEnum.EACH_KEY] !== undefined && node.attrs?.[MailyAttrsEnum.EACH_KEY] !== null; - } - - private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } { - return node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== undefined && node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== null; - } - - private isOrderedList(templateContent: TipTapNode[]) { - return templateContent.length === 1 && templateContent[0].type === 'orderedList'; - } - - private isBulletList(templateContent: TipTapNode[]) { - return templateContent.length === 1 && templateContent[0].type === 'bulletList'; - } - - /** - * For 'each' node, multiply the content by the number of items in the iterable array - * and add indexes to the placeholders. - * - * @example - * node: - * { - * type: 'each', - * attrs: { each: '{{ payload.comments }}' }, - * content: [ - * { type: 'variable', text: '{{ payload.comments[0].author }}' } - * ] - * } - * - * variables: - * { payload: { comments: [{ author: 'John Doe' }, { author: 'Jane Doe' }] } } - * - * result: - * [ - * { type: 'text', text: '{{ payload.comments[0].author }}' }, - * { type: 'text', text: '{{ payload.comments[1].author }}' } - * ] - * - */ - private async multiplyForEachNode( - node: TipTapNode & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } }, - variables: FullPayloadForRender - ): Promise { - const iterablePath = node.attrs[MailyAttrsEnum.EACH_KEY]; - const nodeContent = node.content || []; - const expandedContent: TipTapNode[] = []; - - const iterableArrayString = await parseLiquid(iterablePath, variables); - - let iterableArray: unknown; - try { - iterableArray = JSON.parse(iterableArrayString.replace(/'/g, '"')); - } catch (error) { - throw new Error(`Failed to parse iterable value for "${iterablePath}": ${error.message}`); - } - - if (!Array.isArray(iterableArray)) { - throw new Error(`Iterable "${iterablePath}" is not an array`); - } - - for (const [index] of iterableArray.entries()) { - const contentToExpand = - (this.isOrderedList(nodeContent) || this.isBulletList(nodeContent)) && nodeContent[0].content - ? nodeContent[0].content - : nodeContent; - - const hydratedContent = this.addIndexesToPlaceholders(contentToExpand, iterablePath, index); - expandedContent.push(...hydratedContent); - } - - return expandedContent; - } - - private addIndexesToPlaceholders(nodes: TipTapNode[], iterablePath: string, index: number): TipTapNode[] { - return nodes.map((node) => { - const newNode: TipTapNode = { ...node }; - - if (this.isAVariableNode(newNode)) { - const nodePlaceholder = newNode.text as string; - - newNode.text = nodePlaceholder.replace(MAILY_ITERABLE_MARK, index.toString()); - newNode.type = 'text'; // set 'variable' to 'text' to for Liquid to recognize it - } else if (newNode.content) { - newNode.content = this.addIndexesToPlaceholders(newNode.content, iterablePath, index); - } - - return newNode; - }); - } - - private stringToBoolean(value: unknown): boolean { - if (typeof value === 'string') { - return value.toLowerCase() === 'true'; - } - - return false; - } - - private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { [MailyAttrsEnum.ID]: string } } { - return newNode.type === 'variable'; - } -} 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 deleted file mode 100644 index 35b5ca9b6b7..00000000000 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.command.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseCommand } from '@novu/application-generic'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class HydrateEmailSchemaCommand extends BaseCommand { - @IsString() - emailEditor: string; -} 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 deleted file mode 100644 index 3e3eda861d0..00000000000 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { Injectable } from '@nestjs/common'; -import { z } from 'zod'; - -import { TipTapNode } from '@novu/shared'; -import { MailyAttrsEnum, processNodeAttrs, processNodeMarks } from '@novu/application-generic'; - -import { HydrateEmailSchemaCommand } from './hydrate-email-schema.command'; - -/** - * Transforms the content in place by processing nodes and replacing variables with Liquid.js syntax. - * Handles both array iterations and simple variables. - * - * @example - * Input: - * { - * type: "for", - * attrs: { each: "payload.comments" }, - * content: [{ - * type: "variable", - * attrs: { id: "payload.comments.name" } - * }] - * }, - * { - * type: "variable", - * attrs: { id: "payload.test" } - * } - * - * Output: - * { - * type: "paragraph", - * attrs: { each: "{{ payload.comments }}" }, - * content: [{ - * type: "variable", - * text: "{{ payload.comments[0].name }}" - * }] - * }, - * { - * type: "variable", - * text: "{{ payload.test }}" - * } - */ -@Injectable() -export class HydrateEmailSchemaUseCase { - execute(command: HydrateEmailSchemaCommand): TipTapNode { - // TODO: Aligned Zod inferred type and TipTapNode to remove the need of a type assertion - const emailBody: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)) as TipTapNode; - if (emailBody) { - this.prepareForLiquidParsing([emailBody]); - } - - return emailBody; - } - - private variableLogic( - node: TipTapNode & { - attrs: { id: string }; - }, - content: TipTapNode[], - index: number - ) { - content[index] = { - type: 'variable', - text: node.attrs.id, - attrs: { - ...node.attrs, - }, - }; - } - - private async forNodeLogic( - node: TipTapNode & { - attrs: { each: string }; - }, - content: TipTapNode[], - index: number - ) { - content[index] = { - type: 'paragraph', - content: node.content, - attrs: { - ...node.attrs, - }, - }; - } - - private prepareForLiquidParsing(content: TipTapNode[], forLoopVariable?: string) { - content.forEach((node, index) => { - processNodeAttrs(node, forLoopVariable); - processNodeMarks(node); - - if (this.isForNode(node)) { - this.forNodeLogic(node, content, index); - - if (node.content) { - this.prepareForLiquidParsing(node.content, node.attrs.each); - } - } else if (this.isVariableNode(node)) { - this.variableLogic(node, content, index); - } else if (node.content) { - this.prepareForLiquidParsing(node.content, forLoopVariable); - } - }); - } - - private isForNode(node: TipTapNode): node is TipTapNode & { attrs: { each: string } } { - return !!( - node.type === 'for' && - node.attrs && - node.attrs[MailyAttrsEnum.EACH_KEY] !== undefined && - typeof node.attrs.each === 'string' - ); - } - - private isVariableNode(node: TipTapNode): node is TipTapNode & { attrs: { id: string } } { - return !!( - node.type === 'variable' && - node.attrs && - node.attrs[MailyAttrsEnum.ID] !== undefined && - 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(), - marks: z - .array( - z - .object({ - type: z.string(), - attrs: z.record(z.any()).optional(), - }) - .passthrough() - ) - .optional(), - attrs: z.record(z.unknown()).optional(), - }) - .passthrough(); 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 64cdbeeed83..fac72149694 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 @@ -4,6 +4,3 @@ export * from './push-output-renderer.usecase'; export * from './sms-output-renderer.usecase'; export * from './in-app-output-renderer.usecase'; export * from './email-output-renderer.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/maily-to-liquid/maily.types.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/maily.types.ts new file mode 100644 index 00000000000..582be895f24 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/maily.types.ts @@ -0,0 +1,31 @@ +export enum MailyContentTypeEnum { + VARIABLE = 'variable', + FOR = 'for', + BUTTON = 'button', + IMAGE = 'image', + LINK = 'link', +} + +export enum MailyAttrsEnum { + ID = 'id', + SHOW_IF_KEY = 'showIfKey', + EACH_KEY = 'each', + FALLBACK = 'fallback', + IS_SRC_VARIABLE = 'isSrcVariable', + IS_EXTERNAL_LINK_VARIABLE = 'isExternalLinkVariable', + IS_TEXT_VARIABLE = 'isTextVariable', + IS_URL_VARIABLE = 'isUrlVariable', + TEXT = 'text', + URL = 'url', + SRC = 'src', + EXTERNAL_LINK = 'externalLink', + HREF = 'href', +} + +export const MAILY_ITERABLE_MARK = '0'; + +export const MAILY_FIRST_CITIZEN_VARIABLE_KEY = [ + MailyAttrsEnum.ID, + MailyAttrsEnum.SHOW_IF_KEY, + MailyAttrsEnum.EACH_KEY, +]; diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command.ts new file mode 100644 index 00000000000..07190fa5693 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command.ts @@ -0,0 +1,45 @@ +import { BaseCommand } from '@novu/application-generic'; +import { ValidateBy, ValidationOptions } from 'class-validator'; +import { JSONContent as MailyJSONContent } from '@maily-to/render'; +import { z } from 'zod'; + +export const MailyJSONContentSchema = z.custom(); + +export function isStringifiedMailyJSONContent(value: unknown): value is string { + if (typeof value !== 'string') return false; + + try { + const parsed = JSON.parse(value); + + return isObjectMailyJSONContent(parsed); + } catch { + return false; + } +} + +export function isObjectMailyJSONContent(value: unknown): value is MailyJSONContent { + if (!value || typeof value !== 'object') return false; + + const doc = value as MailyJSONContent; + if (doc.type !== 'doc' || !Array.isArray(doc.content)) return false; + + return true; +} + +function IsStringifiedMailyJSONContent(validationOptions?: ValidationOptions) { + return ValidateBy( + { + name: 'isStringifiedMailyJSONContent', + validator: { + validate: (value): boolean => isStringifiedMailyJSONContent(value), + defaultMessage: () => 'Input must be a valid stringified Maily JSON content', + }, + }, + validationOptions + ); +} + +export class WrapMailyInLiquidCommand extends BaseCommand { + @IsStringifiedMailyJSONContent() + emailEditor: string; +} diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase.ts new file mode 100644 index 00000000000..61d9edde972 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase.ts @@ -0,0 +1,208 @@ +/* eslint-disable no-param-reassign */ +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; +import { JSONContent as MailyJSONContent } from '@maily-to/render'; +import { WrapMailyInLiquidCommand } from './wrap-maily-in-liquid.command'; +import { + MailyContentTypeEnum, + MailyAttrsEnum, + MAILY_ITERABLE_MARK, + MAILY_FIRST_CITIZEN_VARIABLE_KEY, +} from './maily.types'; + +/** + * Enriches Maily JSON content with Liquid syntax for variables. + * + * @example + * Input: + * { + * type: "for", + * attrs: { each: "payload.comments" }, + * content: [{ + * type: "variable", + * attrs: { id: "payload.comments.name" } + * }] + * }, + * { + * type: "variable", + * attrs: { id: "payload.test" } + * } + * + * Output: + * { + * type: "paragraph", + * attrs: { each: "{{ payload.comments }}" }, + * content: [{ + * type: "variable", + * text: "{{ payload.comments[0].name }}" + * }] + * }, + * { + * type: "variable", + * text: "{{ payload.test }}" + * } + */ +@Injectable() +export class WrapMailyInLiquidUseCase { + execute(command: WrapMailyInLiquidCommand): MailyJSONContent { + const mailyJSONContent: MailyJSONContent = JSON.parse(command.emailEditor); + + return this.wrapVariablesInLiquid(mailyJSONContent); + } + + private wrapVariablesInLiquid(node: MailyJSONContent, parentForLoopKey?: string): MailyJSONContent { + const newNode = { ...node } as MailyJSONContent & { attrs: Record }; + + // if this is a for loop node, track its variable + if (this.isForNode(node)) { + parentForLoopKey = node.attrs[MailyAttrsEnum.EACH_KEY]; + } + + if (node.content) { + newNode.content = node.content.map((child) => this.wrapVariablesInLiquid(child, parentForLoopKey)); + } + + if (this.hasAttrs(node)) { + newNode.attrs = this.processVariableNodeAttributes(node, parentForLoopKey); + } + + if (this.hasMarks(node)) { + newNode.marks = this.processNodeMarks(node); + } + + return newNode; + } + + private processVariableNodeAttributes( + node: MailyJSONContent & { attrs: Record }, + parentForLoopKey?: string + ) { + const { attrs, type } = node; + const config = variableAttributeConfig(type as MailyContentTypeEnum); + const processedAttrs = { ...attrs }; + + config.forEach(({ attr, flag }) => { + const attrValue = attrs[attr]; + const flagValue = attrs[flag]; + + if (!flagValue || !attrValue) { + return; + } + + let processedValue = attrValue; + + // add array index for attributes that belong to the for loop + if (parentForLoopKey && processedValue.startsWith(`${parentForLoopKey}`) && type !== MailyContentTypeEnum.FOR) { + processedValue = processedValue.replace(`${parentForLoopKey}`, `${parentForLoopKey}[${MAILY_ITERABLE_MARK}]`); + } + + processedAttrs[attr] = this.wrapInLiquidOutput(processedValue, attrs.fallback); + + if (!MAILY_FIRST_CITIZEN_VARIABLE_KEY.includes(flag)) { + processedAttrs[flag] = false; + } + }); + + return processedAttrs; + } + + private processNodeMarks(node: MailyJSONContent & { marks: Record[] }) { + return node.marks.map((mark) => { + if (!mark.attrs) { + return mark; + } + + const { attrs } = mark; + const processedMark = { + ...mark, + attrs: { ...attrs }, + }; + + const config = variableAttributeConfig(mark.type as MailyContentTypeEnum); + + config.forEach(({ attr, flag }) => { + const attrValue = attrs[attr]; + const flagValue = attrs[flag]; + const { fallback } = attrs; + + if (!flagValue || !attrValue || typeof attrValue !== 'string') { + return; + } + + processedMark.attrs[attr] = this.wrapInLiquidOutput(attrValue, fallback); + + if (!MAILY_FIRST_CITIZEN_VARIABLE_KEY.includes(flag)) { + processedMark.attrs[flag] = false; + } + }); + + return processedMark; + }); + } + + private wrapInLiquidOutput(variableName: string, fallback?: string): string { + const fallbackSuffix = fallback ? ` | default: '${fallback}'` : ''; + + return `{{ ${variableName}${fallbackSuffix} }}`; + } + + private hasAttrs(node: MailyJSONContent): node is MailyJSONContent & { attrs: Record } { + return !!node.attrs; + } + + private hasMarks(node: MailyJSONContent): node is MailyJSONContent & { marks: Record[] } { + return !!node.marks; + } + + private isForNode( + node: MailyJSONContent + ): node is MailyJSONContent & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } } { + return !!( + node.type === MailyContentTypeEnum.FOR && + node.attrs && + node.attrs[MailyAttrsEnum.EACH_KEY] !== undefined && + typeof node.attrs[MailyAttrsEnum.EACH_KEY] === 'string' + ); + } +} + +const variableAttributeConfig = (type: MailyContentTypeEnum) => { + const commonConfig = [ + /* + * Maily Variable Map + * * maily_id equals to maily_variable + * * https://github.com/arikchakma/maily.to/blob/ebcf233eb1d4b16fb568fb702bf0756678db38d0/packages/render/src/maily.tsx#L787 + */ + { attr: MailyAttrsEnum.ID, flag: MailyAttrsEnum.ID }, + /* + * showIfKey is always a maily_variable + */ + { attr: MailyAttrsEnum.SHOW_IF_KEY, flag: MailyAttrsEnum.SHOW_IF_KEY }, + { attr: MailyAttrsEnum.EACH_KEY, flag: MailyAttrsEnum.EACH_KEY }, + ]; + + if (type === MailyContentTypeEnum.BUTTON) { + return [ + { attr: MailyAttrsEnum.TEXT, flag: MailyAttrsEnum.IS_TEXT_VARIABLE }, + { attr: MailyAttrsEnum.URL, flag: MailyAttrsEnum.IS_URL_VARIABLE }, + ...commonConfig, + ]; + } + + if (type === MailyContentTypeEnum.IMAGE) { + return [ + { attr: MailyAttrsEnum.SRC, flag: MailyAttrsEnum.IS_SRC_VARIABLE }, + { + attr: MailyAttrsEnum.EXTERNAL_LINK, + flag: MailyAttrsEnum.IS_EXTERNAL_LINK_VARIABLE, + }, + ...commonConfig, + ]; + } + + if (type === MailyContentTypeEnum.LINK) { + return [{ attr: MailyAttrsEnum.HREF, flag: MailyAttrsEnum.IS_URL_VARIABLE }, ...commonConfig]; + } + + return commonConfig; +}; diff --git a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts index fd12586c389..37e793772cc 100644 --- a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts @@ -520,150 +520,6 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview #novu-v }); }); - it('should transform tip tap node to liquid variables', async () => { - const workflow = await createWorkflow(); - - const stepId = workflow.steps[1]._id; // Using the email step (second step) - const bodyControlValue = { - type: 'doc', - content: [ - { - type: 'heading', - attrs: { textAlign: 'left', level: 1 }, - content: [ - { type: 'text', text: 'New Maily Email Editor ' }, - { type: 'variable', attrs: { id: 'payload.foo', label: null, fallback: null, showIfKey: null } }, - { type: 'text', text: ' ' }, - ], - }, - { - type: 'paragraph', - attrs: { textAlign: 'left' }, - content: [ - { type: 'text', text: 'free text last name is: ' }, - { - type: 'variable', - attrs: { id: 'subscriber.lastName', label: null, fallback: null, showIfKey: `payload.show` }, - }, - { type: 'text', text: ' ' }, - { type: 'hardBreak' }, - { type: 'text', text: 'extra data : ' }, - { type: 'variable', attrs: { id: 'payload.extraData', label: null, fallback: null, showIfKey: null } }, - { type: 'text', text: ' ' }, - ], - }, - ], - }; - const controlValues = { - subject: 'Hello {{subscriber.firstName}} World!', - body: JSON.stringify(bodyControlValue), - }; - - const { status, body } = await session.testAgent.post(`/v2/workflows/${workflow._id}/step/${stepId}/preview`).send({ - controlValues, - previewPayload: {}, - }); - - expect(status).to.equal(201); - expect(body.data.result.type).to.equal('email'); - expect(body.data.result.preview.subject).to.equal('Hello {{subscriber.firstName}} World!'); - expect(body.data.result.preview.body).to.not.include('{{subscriber.lastName}}'); - expect(body.data.result.preview.body).to.include('{{payload.foo}}'); - expect(body.data.result.preview.body).to.include('{{payload.extraData}}'); - expect(body.data.previewPayloadExample).to.deep.equal({ - subscriber: { - firstName: '{{subscriber.firstName}}', - lastName: '{{subscriber.lastName}}', - }, - payload: { - foo: '{{payload.foo}}', - show: '{{payload.show}}', - extraData: '{{payload.extraData}}', - }, - }); - }); - - it('should render tip tap node with api client variables example', async () => { - const workflow = await createWorkflow(); - - const stepId = workflow.steps[1]._id; // Using the email step (second step) - const bodyControlValue = { - type: 'doc', - content: [ - { - type: 'heading', - attrs: { textAlign: 'left', level: 1 }, - content: [ - { type: 'text', text: 'New Maily Email Editor ' }, - { type: 'variable', attrs: { id: 'payload.foo', label: null, fallback: null, showIfKey: null } }, - { type: 'text', text: ' ' }, - ], - }, - { - type: 'paragraph', - attrs: { textAlign: 'left' }, - content: [ - { type: 'text', text: 'free text last name is: ' }, - { - type: 'variable', - attrs: { id: 'subscriber.lastName', label: null, fallback: null, showIfKey: `payload.show` }, - }, - { type: 'text', text: ' ' }, - { type: 'hardBreak' }, - { type: 'text', text: 'extra data : ' }, - { - type: 'variable', - attrs: { - id: 'payload.extraData', - label: null, - fallback: 'fallback extra data is awesome', - showIfKey: null, - }, - }, - { type: 'text', text: ' ' }, - ], - }, - ], - }; - const controlValues = { - subject: 'Hello {{subscriber.firstName}} World!', - body: JSON.stringify(bodyControlValue), - }; - - const { status, body } = await session.testAgent.post(`/v2/workflows/${workflow._id}/step/${stepId}/preview`).send({ - controlValues, - previewPayload: { - subscriber: { - firstName: 'John', - // lastName: 'Doe', - }, - payload: { - foo: 'foo from client', - show: true, - extraData: '', - }, - }, - }); - - expect(status).to.equal(201); - expect(body.data.result.type).to.equal('email'); - expect(body.data.result.preview.subject).to.equal('Hello John World!'); - expect(body.data.result.preview.body).to.include('{{subscriber.lastName}}'); - expect(body.data.result.preview.body).to.include('foo from client'); - expect(body.data.result.preview.body).to.include('fallback extra data is awesome'); - expect(body.data.previewPayloadExample).to.deep.equal({ - subscriber: { - firstName: 'John', - lastName: '{{subscriber.lastName}}', - }, - payload: { - foo: 'foo from client', - show: true, - extraData: '', - }, - }); - }); - async function createWorkflow(overrides: Partial = {}): Promise { const createWorkflowDto: CreateWorkflowDto = { __source: WorkflowCreationSourceEnum.EDITOR, 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 fc70ab3a40e..7d564eae5a6 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -8,6 +8,7 @@ import { createWorkflowClient, CreateWorkflowDto, CronExpressionEnum, + EmailRenderOutput, GeneratePreviewRequestDto, GeneratePreviewResponseDto, HttpError, @@ -18,7 +19,7 @@ import { } from '@novu/shared'; import { EmailControlType, InAppControlType } from '@novu/application-generic'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; -import { forSnippet, fullCodeSnippet } from './maily-test-data'; +import { fullCodeSnippet, previewPayloadExample } from './maily-test-data'; const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; @@ -170,138 +171,29 @@ describe('Generate Preview #novu-v2', () => { expect(previewResponseDto.result!.preview).to.deep.equal(getTestControlValues()[StepTypeEnum.CHAT]); }); - it.skip('email: should match the body in the preview response', async () => { + it('email: should match the body in the preview response', async () => { const previewResponseDto = await createWorkflowAndPreview(StepTypeEnum.EMAIL, 'Email'); + const preview = previewResponseDto.result.preview as EmailRenderOutput; - expect(previewResponseDto.result!.preview).to.exist; - expect(previewResponseDto.previewPayloadExample).to.exist; - expect(previewResponseDto.previewPayloadExample.subscriber, 'Expecting to find subscriber in the payload').to - .exist; + expect(previewResponseDto.result.type).to.equal(StepTypeEnum.EMAIL); - assertEmail(previewResponseDto); + expect(preview).to.exist; + expect(preview.body).to.exist; + expect(preview.subject).to.exist; + expect(preview.body).to.contain(previewPayloadExample().payload.body); + expect(preview.subject).to.contain(`Hello, World! ${SUBJECT_TEST_PAYLOAD}`); + expect(previewResponseDto.previewPayloadExample).to.exist; + expect(previewResponseDto.previewPayloadExample).to.deep.equal(previewPayloadExample()); }); async function createWorkflowAndPreview(type: StepTypeEnum, description: string) { - const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(workflowsClient, type); + const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(workflowsClient, type); const requestDto = buildDtoNoPayload(type); return await generatePreview(workflowsClient, workflowId, stepDatabaseId, requestDto, description); } }); - describe('email specific features', () => { - describe('show', () => { - it('show -> should hide element based on payload', async () => { - const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(workflowsClient, StepTypeEnum.EMAIL); - const previewResponseDto = await generatePreview( - workflowsClient, - workflowId, - stepDatabaseId, - { - controlValues: getTestControlValues()[StepTypeEnum.EMAIL], - previewPayload: { payload: { params: { isPayedUser: 'false' } } }, - }, - 'email' - ); - expect(previewResponseDto.result!.preview).to.exist; - if (previewResponseDto.result!.type !== ChannelTypeEnum.EMAIL) { - throw new Error('Expected email'); - } - 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 } = await createWorkflowAndReturnId(workflowsClient, StepTypeEnum.EMAIL); - const previewResponseDto = await generatePreview( - workflowsClient, - workflowId, - stepDatabaseId, - { - controlValues: getTestControlValues()[StepTypeEnum.EMAIL], - previewPayload: { payload: { params: { isPayedUser: 'true' } } }, - }, - 'email' - ); - expect(previewResponseDto.result!.preview).to.exist; - if (previewResponseDto.result!.type !== ChannelTypeEnum.EMAIL) { - throw new Error('Expected email'); - } - 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( - workflowsClient, - StepTypeEnum.EMAIL - ); - const previewResponseDto = await generatePreview( - workflowsClient, - workflowId, - stepDatabaseId, - { - controlValues: getTestControlValues(stepId)[StepTypeEnum.EMAIL], - previewPayload: { payload: { params: { isPayedUser: true } } }, - }, - 'email' - ); - if (previewResponseDto.result!.type !== ChannelTypeEnum.EMAIL) { - throw new Error('Expected email'); - } - 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( - workflowsClient, - StepTypeEnum.EMAIL - ); - const previewResponseDto = await generatePreview( - workflowsClient, - workflowId, - stepDatabaseId, - { - controlValues: getTestControlValues(stepId)[StepTypeEnum.EMAIL], - previewPayload: { payload: { params: { isPayedUser: 'true' } } }, - }, - 'email' - ); - expect(previewResponseDto.result!.preview).to.exist; - if (previewResponseDto.result!.type !== ChannelTypeEnum.EMAIL) { - throw new Error('Expected email'); - } - 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(workflowsClient, StepTypeEnum.EMAIL); - const name1 = 'ball is round'; - const name2 = 'square is square'; - const previewResponseDto = await generatePreview( - workflowsClient, - workflowId, - stepDatabaseId, - { - controlValues: buildSimpleForEmail() as unknown as Record, - previewPayload: { payload: { food: { items: [{ name: name1 }, { name: name2 }] } } }, - }, - 'email' - ); - expect(previewResponseDto.result!.preview).to.exist; - if (previewResponseDto.result!.type !== ChannelTypeEnum.EMAIL) { - throw new Error('Expected email'); - } - const preview = previewResponseDto.result!.preview.body; - expect(preview).to.not.contain('should be the fallback value'); - 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('payload sanitation', () => { it('Should produce a correct payload when pipe is used etc {{payload.variable | upper}}', async () => { const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(workflowsClient, StepTypeEnum.SMS); @@ -507,18 +399,13 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId?: string): Generat }; } -function buildEmailControlValuesPayload(stepId?: string): EmailControlType { +function buildEmailControlValuesPayload(): EmailControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - body: JSON.stringify(fullCodeSnippet(stepId)), - }; -} -function buildSimpleForEmail(): EmailControlType { - return { - subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - body: JSON.stringify(forSnippet), + body: JSON.stringify(fullCodeSnippet()), }; } + function buildInAppControlValues() { return { subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, @@ -598,7 +485,7 @@ function buildDigestControlValuesPayload() { export const getTestControlValues = (stepId?: string) => ({ [StepTypeEnum.SMS]: buildSmsControlValuesPayload(stepId), - [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(stepId) as unknown as Record, + [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(), [StepTypeEnum.PUSH]: buildPushControlValuesPayload(), [StepTypeEnum.CHAT]: buildChatControlValuesPayload(), [StepTypeEnum.IN_APP]: buildInAppControlValues(), @@ -619,23 +506,6 @@ async function assertHttpError( return new Error(`${description}: Failed to generate preview, bug in response error mapping `); } -function assertEmail(dto: GeneratePreviewResponseDto) { - if (dto.result!.type === ChannelTypeEnum.EMAIL) { - const preview = dto.result!.preview.body; - expect(preview).to.exist; - 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'); - } -} - export async function createWorkflowAndReturnId( workflowsClient: ReturnType, type: StepTypeEnum diff --git a/apps/api/src/app/workflows-v2/maily-test-data.ts b/apps/api/src/app/workflows-v2/maily-test-data.ts index ec88cc877f5..febcf8ed9bf 100644 --- a/apps/api/src/app/workflows-v2/maily-test-data.ts +++ b/apps/api/src/app/workflows-v2/maily-test-data.ts @@ -1,42 +1,4 @@ -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: 'variable', - attrs: { - id: 'payload.food.items.name', - label: null, - }, - }, - { - type: 'text', - text: ' ', - }, - ], - }, - ], - }, - ], -}; - -export function fullCodeSnippet(stepId?: string) { +export function fullCodeSnippet() { return { type: 'doc', content: [ @@ -502,3 +464,66 @@ export function fullCodeSnippet(stepId?: string) { ], }; } + +export function previewPayloadExample() { + return { + payload: { + subject: { + test: { + payload: '{{payload.subject.test.payload}}', + }, + }, + params: { + isPayedUser: '{{payload.params.isPayedUser}}', + }, + hidden: { + section: '{{payload.hidden.section}}', + }, + body: '{{payload.body}}', + origins: [ + { + country: '{{payload.origins.country}}', + id: '{{payload.origins.id}}', + time: '{{payload.origins.time}}', + }, + { + country: '{{payload.origins.country}}', + id: '{{payload.origins.id}}', + time: '{{payload.origins.time}}', + }, + { + country: '{{payload.origins.country}}', + id: '{{payload.origins.id}}', + time: '{{payload.origins.time}}', + }, + ], + students: [ + { + id: '{{payload.students.id}}', + name: '{{payload.students.name}}', + }, + { + id: '{{payload.students.id}}', + name: '{{payload.students.name}}', + }, + { + id: '{{payload.students.id}}', + name: '{{payload.students.name}}', + }, + ], + food: { + items: [ + { + name: '{{payload.food.items.name}}', + }, + { + name: '{{payload.food.items.name}}', + }, + { + name: '{{payload.food.items.name}}', + }, + ], + }, + }, + }; +} diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts index ca33080eee5..5172bbd0c75 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts @@ -2,18 +2,13 @@ import { Injectable } from '@nestjs/common'; import { ControlValuesRepository } from '@novu/dal'; import { ControlValuesLevelEnum, JSONSchemaDto } from '@novu/shared'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; -import { flattenObjectValues, keysToObject } from '../../util/utils'; -import { extractLiquidTemplateVariables } from '../../util/template-parser/liquid-parser'; +import { keysToObject } from '../../util/utils'; import { BuildPayloadSchemaCommand } from './build-payload-schema.command'; -import { isStringTipTapNode } from '../../util/tip-tap.util'; -import { HydrateEmailSchemaUseCase } from '../../../environments-v1/usecases/output-renderers/hydrate-email-schema.usecase'; +import { buildVariables } from '../../util/build-variables'; @Injectable() export class BuildPayloadSchema { - constructor( - private readonly controlValuesRepository: ControlValuesRepository, - private readonly hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase - ) {} + constructor(private readonly controlValuesRepository: ControlValuesRepository) {} @InstrumentUsecase() async execute(command: BuildPayloadSchemaCommand): Promise { @@ -44,38 +39,27 @@ export class BuildPayloadSchema { ).map((item) => item.controls); } - return controlValues.flat(); + // get just the actual control "values", not entire objects + return controlValues.flat().flatMap((obj) => Object.values(obj)); } + /** + * @example + * controlValues = [ "John {{name}}", "Address {{address}} {{address}}", "nothing", 123, true ] + * returns = [ "name", "address" ] + */ @Instrument() - private async extractAllVariables(controlValues: Record[]): Promise { + private async extractAllVariables(controlValues: unknown[]): Promise { const allVariables: string[] = []; for (const controlValue of controlValues) { - const processedControlValue = await this.extractVariables(controlValue); - const controlValuesString = flattenObjectValues(processedControlValue).join(' '); - const templateVariables = extractLiquidTemplateVariables(controlValuesString); + const templateVariables = buildVariables(undefined, controlValue); allVariables.push(...templateVariables.validVariables.map((variable) => variable.name)); } return [...new Set(allVariables)]; } - @Instrument() - private async extractVariables(controlValue: Record): Promise> { - const processedValue: Record = {}; - - for (const [key, value] of Object.entries(controlValue)) { - if (isStringTipTapNode(value)) { - processedValue[key] = this.hydrateEmailSchemaUseCase.execute({ emailEditor: value }); - } else { - processedValue[key] = value; - } - } - - return processedValue; - } - private async buildVariablesSchema(variables: string[]) { const { payload } = keysToObject(variables); 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 8f63622b640..d9a3e8792cc 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 @@ -12,7 +12,6 @@ import { PreviewPayload, StepResponseDto, WorkflowOriginEnum, - TipTapNode, StepTypeEnum, } from '@novu/shared'; import { @@ -25,7 +24,7 @@ import { dashboardSanitizeControlValues, } from '@novu/application-generic'; import { channelStepSchemas, actionStepSchemas } from '@novu/framework/internal'; - +import { JSONContent as MailyJSONContent } from '@maily-to/render'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; import { FrameworkPreviousStepsOutputState } from '../../../bridge/usecases/preview-step/preview-step.command'; import { BuildStepDataUsecase } from '../build-step-data'; @@ -33,9 +32,9 @@ import { GeneratePreviewCommand } from './generate-preview.command'; import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; import { Variable } from '../../util/template-parser/liquid-parser'; -import { isObjectTipTapNode } from '../../util/tip-tap.util'; import { buildVariables } from '../../util/build-variables'; import { keysToObject, mergeCommonObjectKeys, multiplyArrayItems } from '../../util/utils'; +import { isObjectMailyJSONContent } from '../../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command'; const LOG_CONTEXT = 'GeneratePreviewUsecase'; @@ -94,7 +93,7 @@ export class GeneratePreviewUsecase { variablesExample: _.merge(previewTemplateData.variablesExample, multipliedVariablesExampleResult), controlValues: { ...previewTemplateData.controlValues, - [controlKey]: isObjectTipTapNode(processedControlValues) + [controlKey]: isObjectMailyJSONContent(processedControlValues) ? JSON.stringify(processedControlValues) : processedControlValues, }, @@ -403,7 +402,7 @@ const EMPTY_STRING = ''; const WHITESPACE = ' '; const DEFAULT_URL_TARGET = '_blank'; const DEFAULT_URL_PATH = 'https://www.redirect-example.com'; -const DEFAULT_TIP_TAP_EMPTY_PREVIEW: TipTapNode = { +const DEFAULT_TIP_TAP_EMPTY_PREVIEW: MailyJSONContent = { type: 'doc', content: [ { diff --git a/apps/api/src/app/workflows-v2/util/build-variables.ts b/apps/api/src/app/workflows-v2/util/build-variables.ts index d17206e30c0..eef2220f87f 100644 --- a/apps/api/src/app/workflows-v2/util/build-variables.ts +++ b/apps/api/src/app/workflows-v2/util/build-variables.ts @@ -1,10 +1,11 @@ import _ from 'lodash'; -import { MAILY_ITERABLE_MARK, PinoLogger } from '@novu/application-generic'; +import { PinoLogger } from '@novu/application-generic'; import { Variable, extractLiquidTemplateVariables, TemplateVariables } from './template-parser/liquid-parser'; -import { isStringTipTapNode } from './tip-tap.util'; -import { HydrateEmailSchemaUseCase } from '../../environments-v1/usecases/output-renderers/hydrate-email-schema.usecase'; +import { WrapMailyInLiquidUseCase } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase'; +import { MAILY_ITERABLE_MARK } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/maily.types'; +import { isStringifiedMailyJSONContent } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command'; export function buildVariables( variableSchema: Record | undefined, @@ -13,9 +14,11 @@ export function buildVariables( ): TemplateVariables { let variableControlValue = controlValue; - if (isStringTipTapNode(variableControlValue)) { + if (isStringifiedMailyJSONContent(variableControlValue)) { try { - variableControlValue = new HydrateEmailSchemaUseCase().execute({ emailEditor: variableControlValue }); + variableControlValue = new WrapMailyInLiquidUseCase().execute({ + emailEditor: variableControlValue, + }); } catch (error) { logger?.error( { @@ -30,6 +33,11 @@ export function buildVariables( const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue)); + // don't compare against schema if it's not provided + if (!variableSchema) { + return { validVariables, invalidVariables }; + } + const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( variableSchema || {}, validVariables diff --git a/apps/api/src/app/workflows-v2/util/tip-tap.util.ts b/apps/api/src/app/workflows-v2/util/tip-tap.util.ts deleted file mode 100644 index e577e01268a..00000000000 --- a/apps/api/src/app/workflows-v2/util/tip-tap.util.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { TipTapNode } from '@novu/shared'; - -/** - * - * @param value minimal tiptap object from the client is - * { - * "type": "doc", - * "content": [ - * { - * "type": "paragraph", - * "attrs": { - * "textAlign": "left" - * }, - * "content": [ - * { - * "type": "text", - * "text": " " - * } - * ] - * } - *] - *} - */ -export function isStringTipTapNode(value: unknown): value is string { - if (typeof value !== 'string') return false; - - try { - const parsed = JSON.parse(value); - - return isObjectTipTapNode(parsed); - } catch { - return false; - } -} - -export function isObjectTipTapNode(value: unknown): value is TipTapNode { - if (!value || typeof value !== 'object') return false; - - const doc = value as TipTapNode; - if (doc.type !== 'doc' || !Array.isArray(doc.content)) return false; - - return true; -} - -function isValidTipTapContent(node: unknown): boolean { - if (!node || typeof node !== 'object') return false; - const content = node as TipTapNode; - if (typeof content.type !== 'string') return false; - if (content.attrs !== undefined && (typeof content.attrs !== 'object' || content.attrs === null)) { - return false; - } - if (content.text !== undefined && typeof content.text !== 'string') { - return false; - } - if (content.content !== undefined) { - if (!Array.isArray(content.content)) return false; - - return content.content.every((child) => isValidTipTapContent(child)); - } - - return true; -} diff --git a/apps/api/src/app/workflows-v2/util/utils.ts b/apps/api/src/app/workflows-v2/util/utils.ts index 96ada9a55cd..6eb13e94cba 100644 --- a/apps/api/src/app/workflows-v2/util/utils.ts +++ b/apps/api/src/app/workflows-v2/util/utils.ts @@ -1,13 +1,10 @@ /* eslint-disable no-param-reassign */ import difference from 'lodash/difference'; -import flatMap from 'lodash/flatMap'; import isArray from 'lodash/isArray'; import isObject from 'lodash/isObject'; import reduce from 'lodash/reduce'; -import values from 'lodash/values'; - import { JSONSchemaDto } from '@novu/shared'; -import { MAILY_ITERABLE_MARK } from '@novu/application-generic'; +import { MAILY_ITERABLE_MARK } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/maily.types'; export function findMissingKeys(requiredRecord: object, actualRecord: object) { const requiredKeys = collectKeys(requiredRecord); @@ -33,51 +30,6 @@ export function collectKeys(obj, prefix = '') { ); } -/** - * Recursively flattens an object's values into an array of strings. - * Handles nested objects, arrays, and converts primitive values to strings. - * - * @param obj - The object to flatten - * @returns An array of strings containing all primitive values found in the object - * - * @example - * ```typescript - * const input = { - * subject: "Hello {{name}}", - * body: "Welcome!", - * actions: { - * primary: { - * label: "Click {{here}}", - * url: "https://example.com" - * } - * }, - * data: { count: 42 } - * }; - * - * flattenObjectValues(input); - * Returns: - * [ - * "Hello {{name}}", - * "Welcome!", - * "Click {{here}}", - * "https://example.com", - * "42" - * ] - * ``` - */ -export function flattenObjectValues(obj: Record): string[] { - return flatMap(values(obj), (value) => { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - if (value && typeof value === 'object') { - return flattenObjectValues(value as Record); - } - - return []; - }); -} - /** * Recursively adds missing defaults for properties in a JSON schema object. * For properties without defaults, adds interpolated path as the default value. diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index 28a04687111..a217fc4271c 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -15,7 +15,6 @@ import { CommunityOrganizationRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; import { BridgeModule } from '../bridge'; import { ChangeModule } from '../change/change.module'; -import { HydrateEmailSchemaUseCase } from '../environments-v1/usecases/output-renderers'; import { IntegrationModule } from '../integrations/integrations.module'; import { MessageTemplateModule } from '../message-template/message-template.module'; import { SharedModule } from '../shared/shared.module'; @@ -57,7 +56,6 @@ const DAL_REPOSITORIES = [CommunityOrganizationRepository]; GeneratePreviewUsecase, BuildWorkflowTestDataUseCase, GetWorkflowUseCase, - HydrateEmailSchemaUseCase, BuildVariableSchemaUsecase, PatchStepUsecase, PatchWorkflowUsecase, diff --git a/libs/application-generic/src/index.ts b/libs/application-generic/src/index.ts index 6e3370e3e90..ee2461e8dc8 100644 --- a/libs/application-generic/src/index.ts +++ b/libs/application-generic/src/index.ts @@ -18,4 +18,3 @@ export * from './usecases'; export * from './utils'; export * from './utils/inject-auth-providers'; export * from './schemas/control'; -export * from './utils/process-node-attrs'; diff --git a/libs/application-generic/src/utils/process-node-attrs.ts b/libs/application-generic/src/utils/process-node-attrs.ts deleted file mode 100644 index 4715c1fa292..00000000000 --- a/libs/application-generic/src/utils/process-node-attrs.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { JSONContent } from '@maily-to/render'; - -export enum MailyContentTypeEnum { - VARIABLE = 'variable', - FOR = 'for', - BUTTON = 'button', - IMAGE = 'image', - LINK = 'link', -} - -export enum MailyAttrsEnum { - ID = 'id', - SHOW_IF_KEY = 'showIfKey', - EACH_KEY = 'each', - FALLBACK = 'fallback', - IS_SRC_VARIABLE = 'isSrcVariable', - IS_EXTERNAL_LINK_VARIABLE = 'isExternalLinkVariable', - IS_TEXT_VARIABLE = 'isTextVariable', - IS_URL_VARIABLE = 'isUrlVariable', - TEXT = 'text', - URL = 'url', - SRC = 'src', - EXTERNAL_LINK = 'externalLink', - HREF = 'href', -} - -export const MAILY_ITERABLE_MARK = '0'; - -const MAILY_FIRST_CITIZEN_VARIABLE_KEY = [ - MailyAttrsEnum.ID, - MailyAttrsEnum.SHOW_IF_KEY, - MailyAttrsEnum.EACH_KEY, -]; - -export const variableAttributeConfig = (type: MailyContentTypeEnum) => { - const commonConfig = [ - /* - * Maily Variable Map - * * maily_id equals to maily_variable - * * https://github.com/arikchakma/maily.to/blob/ebcf233eb1d4b16fb568fb702bf0756678db38d0/packages/render/src/maily.tsx#L787 - */ - { attr: MailyAttrsEnum.ID, flag: MailyAttrsEnum.ID }, - /* - * showIfKey is always a maily_variable - */ - { attr: MailyAttrsEnum.SHOW_IF_KEY, flag: MailyAttrsEnum.SHOW_IF_KEY }, - { attr: MailyAttrsEnum.EACH_KEY, flag: MailyAttrsEnum.EACH_KEY }, - ]; - - if (type === MailyContentTypeEnum.BUTTON) { - return [ - { attr: MailyAttrsEnum.TEXT, flag: MailyAttrsEnum.IS_TEXT_VARIABLE }, - { attr: MailyAttrsEnum.URL, flag: MailyAttrsEnum.IS_URL_VARIABLE }, - ...commonConfig, - ]; - } - - if (type === MailyContentTypeEnum.IMAGE) { - return [ - { attr: MailyAttrsEnum.SRC, flag: MailyAttrsEnum.IS_SRC_VARIABLE }, - { - attr: MailyAttrsEnum.EXTERNAL_LINK, - flag: MailyAttrsEnum.IS_EXTERNAL_LINK_VARIABLE, - }, - ...commonConfig, - ]; - } - - if (type === MailyContentTypeEnum.LINK) { - return [ - { attr: MailyAttrsEnum.HREF, flag: MailyAttrsEnum.IS_URL_VARIABLE }, - ...commonConfig, - ]; - } - - return commonConfig; -}; - -function processAttributes( - attrs: Record, - type: MailyContentTypeEnum, - forLoopVariable?: string, -): void { - if (!attrs) return; - - const typeConfig = variableAttributeConfig(type); - - for (const { attr, flag } of typeConfig) { - if (attrs[flag] && attrs[attr]) { - attrs[attr] = wrapInLiquidOutput( - attrs[attr] as string, - attrs.fallback as string, - forLoopVariable, - ); - if (!MAILY_FIRST_CITIZEN_VARIABLE_KEY.includes(flag)) { - // eslint-disable-next-line no-param-reassign - attrs[flag] = false; - } - } - } -} - -export function processNodeAttrs( - node: JSONContent, - forLoopVariable?: string, -): JSONContent { - if (!node.attrs) return node; - - processAttributes( - node.attrs, - node.type as MailyContentTypeEnum, - forLoopVariable, - ); - - return node; -} - -export function processNodeMarks(node: JSONContent): JSONContent { - if (!node.marks) return node; - - for (const mark of node.marks) { - if (mark.attrs) { - processAttributes(mark.attrs, mark.type as MailyContentTypeEnum); - } - } - - return node; -} - -function wrapInLiquidOutput( - variableName: string, - fallback?: string, - forLoopVariable?: string, -): string { - const sanitizedVariableName = sanitizeVariableName(variableName); - const sanitizedForLoopVariable = - forLoopVariable && sanitizeVariableName(forLoopVariable); - - /* - * Handle loop variable replacement - * payload.comments.name => payload.comments[0].name - */ - const processedName = - sanitizedForLoopVariable && - sanitizedVariableName.startsWith(sanitizedForLoopVariable) - ? sanitizedVariableName.replace( - sanitizedForLoopVariable, - `${sanitizedForLoopVariable}[${MAILY_ITERABLE_MARK}]`, - ) - : sanitizedVariableName; - - // Build liquid output syntax - const fallbackSuffix = fallback ? ` | default: '${fallback}'` : ''; - - return `{{ ${processedName}${fallbackSuffix} }}`; -} - -function sanitizeVariableName(variableName: string): string { - return variableName.replace(/{{|}}/g, '').trim(); -} diff --git a/packages/shared/src/dto/workflows/control-schemas.ts b/packages/shared/src/dto/workflows/control-schemas.ts deleted file mode 100644 index 37a2041bd61..00000000000 --- a/packages/shared/src/dto/workflows/control-schemas.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type TipTapNode = { - type?: string; - attrs?: Record; - content?: TipTapNode[]; - marks?: { - type: string; - attrs?: Record; - [key: string]: any; - }[]; - text?: string; - [key: string]: any; -}; diff --git a/packages/shared/src/dto/workflows/index.ts b/packages/shared/src/dto/workflows/index.ts index 8c91d5414b7..b7c87b41c3b 100644 --- a/packages/shared/src/dto/workflows/index.ts +++ b/packages/shared/src/dto/workflows/index.ts @@ -1,4 +1,3 @@ -export * from './control-schemas'; export * from './create-workflow-deprecated.dto'; export * from './generate-preview-request.dto'; export * from './get-list-query-params'; From 2d25a65af90b6b4213dfc25d4d6e1b24780d99a5 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Tue, 21 Jan 2025 14:54:09 +0200 Subject: [PATCH 04/25] fix(dashboard): Copywriting fix --- apps/dashboard/src/components/activity/activity-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index baf56dd9c54..c33db4e0036 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -92,7 +92,7 @@ export function ActivityTable({ Event Status Steps - Triggered Date + Triggered At From 4efa9567a97514f5b87db220cd2343d44295942a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 21 Jan 2025 17:36:58 +0200 Subject: [PATCH 05/25] style(dashboard): remove operational tag from template --- .../src/components/template-store/templates/usage-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/template-store/templates/usage-limit.ts b/apps/dashboard/src/components/template-store/templates/usage-limit.ts index 2b50acf137e..081f5b15e48 100644 --- a/apps/dashboard/src/components/template-store/templates/usage-limit.ts +++ b/apps/dashboard/src/components/template-store/templates/usage-limit.ts @@ -48,7 +48,7 @@ export const usageLimitTemplate: WorkflowTemplate = { }, }, ], - tags: ['Operational'], + tags: [], active: true, __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, }, From aed5f4cbc6dee83de1325a4399a6c306ddcee262 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 21 Jan 2025 18:04:37 +0200 Subject: [PATCH 06/25] feat(dashboard): Multi environments management (#7522) --- .../dtos/create-environment-request.dto.ts | 7 +- .../dtos/update-environment-request.dto.ts | 7 +- .../environments-v1/e2e/get-api-keys.e2e.ts | 7 +- .../environments-v1.controller.ts | 55 +++-- .../environments-v1/environments-v1.module.ts | 10 +- .../create-environment.command.ts | 10 +- .../create-environment.usecase.ts | 62 ++++- .../delete-environment.command.ts | 3 + .../delete-environment.usecase.ts | 53 +++++ .../usecases/delete-environment/index.ts | 2 + .../src/app/environments-v1/usecases/index.ts | 6 +- .../update-environment.command.ts | 4 + .../update-environment.usecase.ts | 15 +- .../create-organization.usecase.ts | 31 +-- .../sync-external-organization.usecase.ts | 10 +- apps/dashboard/src/api/environments.ts | 27 ++- .../src/components/billing/features.tsx | 8 + .../components/create-environment-button.tsx | 218 ++++++++++++++++++ .../components/delete-environment-dialog.tsx | 53 +++++ .../src/components/edit-environment-sheet.tsx | 149 ++++++++++++ .../src/components/environments-list.tsx | 179 ++++++++++++++ .../components/integration-card.tsx | 13 +- .../components/integration-configuration.tsx | 47 ++-- .../components/primitives/color-picker.tsx | 31 ++- .../primitives/environment-branch-icon.tsx | 79 +++++++ .../side-navigation/environment-dropdown.tsx | 55 ++--- .../side-navigation/side-navigation.tsx | 43 ++-- .../configure-workflow-form.tsx | 39 +++- .../dashboard/src/components/workflow-row.tsx | 75 ++++-- apps/dashboard/src/hooks/use-environments.ts | 37 +++ .../dashboard/src/hooks/use-sync-workflow.tsx | 96 ++++---- apps/dashboard/src/main.tsx | 55 +++-- apps/dashboard/src/pages/environments.tsx | 20 ++ apps/dashboard/src/utils/routes.ts | 1 + .../EnvironmentSelect/EnvironmentSelect.tsx | 6 +- .../environment/environment.entity.ts | 4 +- .../environment/environment.schema.ts | 3 +- .../productFeatureEnabledForServiceLevel.ts | 1 + .../environment/environment.interface.ts | 5 +- packages/shared/src/types/billing.ts | 1 + packages/shared/src/types/environment.ts | 4 + packages/shared/src/types/feature-flags.ts | 1 + 42 files changed, 1274 insertions(+), 258 deletions(-) create mode 100644 apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.command.ts create mode 100644 apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.usecase.ts create mode 100644 apps/api/src/app/environments-v1/usecases/delete-environment/index.ts create mode 100644 apps/dashboard/src/components/create-environment-button.tsx create mode 100644 apps/dashboard/src/components/delete-environment-dialog.tsx create mode 100644 apps/dashboard/src/components/edit-environment-sheet.tsx create mode 100644 apps/dashboard/src/components/environments-list.tsx create mode 100644 apps/dashboard/src/components/primitives/environment-branch-icon.tsx create mode 100644 apps/dashboard/src/hooks/use-environments.ts create mode 100644 apps/dashboard/src/pages/environments.tsx diff --git a/apps/api/src/app/environments-v1/dtos/create-environment-request.dto.ts b/apps/api/src/app/environments-v1/dtos/create-environment-request.dto.ts index 1383919bdf4..d581a19ce1a 100644 --- a/apps/api/src/app/environments-v1/dtos/create-environment-request.dto.ts +++ b/apps/api/src/app/environments-v1/dtos/create-environment-request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDefined, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { IsDefined, IsHexColor, IsMongoId, IsOptional, IsString } from 'class-validator'; export class CreateEnvironmentRequestDto { @ApiProperty() @@ -11,4 +11,9 @@ export class CreateEnvironmentRequestDto { @IsOptional() @IsMongoId() parentId?: string; + + @ApiProperty() + @IsDefined() + @IsHexColor() + color: string; } diff --git a/apps/api/src/app/environments-v1/dtos/update-environment-request.dto.ts b/apps/api/src/app/environments-v1/dtos/update-environment-request.dto.ts index 4502a82e5d7..18862f31578 100644 --- a/apps/api/src/app/environments-v1/dtos/update-environment-request.dto.ts +++ b/apps/api/src/app/environments-v1/dtos/update-environment-request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsMongoId, IsOptional, IsString } from 'class-validator'; +import { IsHexColor, IsMongoId, IsOptional, IsString } from 'class-validator'; export class InBoundParseDomainDto { @ApiPropertyOptional({ type: String }) @@ -27,6 +27,11 @@ export class UpdateEnvironmentRequestDto { @IsMongoId() parentId?: string; + @ApiPropertyOptional() + @IsOptional() + @IsHexColor() + color?: string; + @ApiPropertyOptional({ type: InBoundParseDomainDto, }) diff --git a/apps/api/src/app/environments-v1/e2e/get-api-keys.e2e.ts b/apps/api/src/app/environments-v1/e2e/get-api-keys.e2e.ts index 688e32d0ea1..2ecf4a9250c 100644 --- a/apps/api/src/app/environments-v1/e2e/get-api-keys.e2e.ts +++ b/apps/api/src/app/environments-v1/e2e/get-api-keys.e2e.ts @@ -1,6 +1,6 @@ +import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; -import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared'; describe('Get Environment API Keys - /environments/api-keys (GET) #novu-v2', async () => { let session: UserSession; @@ -10,11 +10,6 @@ describe('Get Environment API Keys - /environments/api-keys (GET) #novu-v2', asy }); it('should get environment api keys correctly', async () => { - const demoEnvironment = { - name: 'Hello App', - }; - await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201); - const { body } = await session.testAgent.get('/v1/environments/api-keys').send(); expect(body.data[0].key).to.not.contains(NOVU_ENCRYPTION_SUB_MASK); diff --git a/apps/api/src/app/environments-v1/environments-v1.controller.ts b/apps/api/src/app/environments-v1/environments-v1.controller.ts index ab435c71f7d..6f47c703799 100644 --- a/apps/api/src/app/environments-v1/environments-v1.controller.ts +++ b/apps/api/src/app/environments-v1/environments-v1.controller.ts @@ -2,6 +2,7 @@ import { Body, ClassSerializerInterceptor, Controller, + Delete, Get, Param, Post, @@ -9,28 +10,31 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; -import { ApiAuthSchemeEnum, MemberRoleEnum, UserSessionData } from '@novu/shared'; -import { ApiExcludeController, ApiExcludeEndpoint, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiExcludeEndpoint, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { Roles, RolesGuard } from '@novu/application-generic'; +import { ApiAuthSchemeEnum, MemberRoleEnum, ProductFeatureKeyEnum, UserSessionData } from '@novu/shared'; +import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; +import { ProductFeature } from '../shared/decorators/product-feature.decorator'; +import { ApiKey } from '../shared/dtos/api-key'; +import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; +import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; +import { SdkGroupName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; -import { CreateEnvironment } from './usecases/create-environment/create-environment.usecase'; -import { CreateEnvironmentCommand } from './usecases/create-environment/create-environment.command'; import { CreateEnvironmentRequestDto } from './dtos/create-environment-request.dto'; +import { EnvironmentResponseDto } from './dtos/environment-response.dto'; +import { UpdateEnvironmentRequestDto } from './dtos/update-environment-request.dto'; +import { CreateEnvironmentCommand } from './usecases/create-environment/create-environment.command'; +import { CreateEnvironment } from './usecases/create-environment/create-environment.usecase'; +import { DeleteEnvironmentCommand } from './usecases/delete-environment/delete-environment.command'; +import { DeleteEnvironment } from './usecases/delete-environment/delete-environment.usecase'; import { GetApiKeysCommand } from './usecases/get-api-keys/get-api-keys.command'; import { GetApiKeys } from './usecases/get-api-keys/get-api-keys.usecase'; import { GetEnvironment, GetEnvironmentCommand } from './usecases/get-environment'; -import { GetMyEnvironments } from './usecases/get-my-environments/get-my-environments.usecase'; import { GetMyEnvironmentsCommand } from './usecases/get-my-environments/get-my-environments.command'; -import { ApiKey } from '../shared/dtos/api-key'; -import { EnvironmentResponseDto } from './dtos/environment-response.dto'; -import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; +import { GetMyEnvironments } from './usecases/get-my-environments/get-my-environments.usecase'; import { RegenerateApiKeys } from './usecases/regenerate-api-keys/regenerate-api-keys.usecase'; import { UpdateEnvironmentCommand } from './usecases/update-environment/update-environment.command'; import { UpdateEnvironment } from './usecases/update-environment/update-environment.usecase'; -import { UpdateEnvironmentRequestDto } from './dtos/update-environment-request.dto'; -import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; -import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; -import { SdkGroupName } from '../shared/framework/swagger/sdk.decorators'; /** * @deprecated use EnvironmentsControllerV2 @@ -48,7 +52,8 @@ export class EnvironmentsControllerV1 { private getApiKeysUsecase: GetApiKeys, private regenerateApiKeysUsecase: RegenerateApiKeys, private getEnvironmentUsecase: GetEnvironment, - private getMyEnvironmentsUsecase: GetMyEnvironments + private getMyEnvironmentsUsecase: GetMyEnvironments, + private deleteEnvironmentUsecase: DeleteEnvironment ) {} @Get('/me') @@ -73,6 +78,9 @@ export class EnvironmentsControllerV1 { }) @ApiExcludeEndpoint() @ApiResponse(EnvironmentResponseDto, 201) + @ProductFeature(ProductFeatureKeyEnum.MANAGE_ENVIRONMENTS) + @UseGuards(RolesGuard) + @Roles(MemberRoleEnum.ADMIN) async createEnvironment( @UserSession() user: UserSessionData, @Body() body: CreateEnvironmentRequestDto @@ -82,6 +90,8 @@ export class EnvironmentsControllerV1 { name: body.name, userId: user._id, organizationId: user.organizationId, + color: body.color, + system: false, }) ); } @@ -121,6 +131,7 @@ export class EnvironmentsControllerV1 { name: payload.name, identifier: payload.identifier, _parentId: payload.parentId, + color: payload.color, dns: payload.dns, bridge: payload.bridge, }) @@ -157,4 +168,22 @@ export class EnvironmentsControllerV1 { return await this.regenerateApiKeysUsecase.execute(command); } + + @Delete('/:environmentId') + @ApiOperation({ + summary: 'Delete environment', + }) + @ApiParam({ name: 'environmentId', type: String, required: true }) + @ProductFeature(ProductFeatureKeyEnum.MANAGE_ENVIRONMENTS) + @UseGuards(RolesGuard) + @Roles(MemberRoleEnum.ADMIN) + async deleteEnvironment(@UserSession() user: UserSessionData, @Param('environmentId') environmentId: string) { + return await this.deleteEnvironmentUsecase.execute( + DeleteEnvironmentCommand.create({ + userId: user._id, + organizationId: user.organizationId, + environmentId, + }) + ); + } } diff --git a/apps/api/src/app/environments-v1/environments-v1.module.ts b/apps/api/src/app/environments-v1/environments-v1.module.ts index 436bd4a14a1..93ba2f38b36 100644 --- a/apps/api/src/app/environments-v1/environments-v1.module.ts +++ b/apps/api/src/app/environments-v1/environments-v1.module.ts @@ -1,12 +1,13 @@ import { forwardRef, Module } from '@nestjs/common'; -import { SharedModule } from '../shared/shared.module'; -import { USE_CASES } from './usecases'; -import { EnvironmentsControllerV1 } from './environments-v1.controller'; -import { NotificationGroupsModule } from '../notification-groups/notification-groups.module'; import { AuthModule } from '../auth/auth.module'; +import { IntegrationModule } from '../integrations/integrations.module'; import { LayoutsModule } from '../layouts/layouts.module'; +import { NotificationGroupsModule } from '../notification-groups/notification-groups.module'; +import { SharedModule } from '../shared/shared.module'; +import { EnvironmentsControllerV1 } from './environments-v1.controller'; import { NovuBridgeModule } from './novu-bridge.module'; +import { USE_CASES } from './usecases'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { NovuBridgeModule } from './novu-bridge.module'; NotificationGroupsModule, forwardRef(() => AuthModule), forwardRef(() => LayoutsModule), + forwardRef(() => IntegrationModule), NovuBridgeModule, ], controllers: [EnvironmentsControllerV1], diff --git a/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.command.ts b/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.command.ts index b4444e1bd8d..d39d1b78241 100644 --- a/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.command.ts +++ b/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.command.ts @@ -1,4 +1,4 @@ -import { IsDefined, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsDefined, IsHexColor, IsMongoId, IsOptional, IsString } from 'class-validator'; import { OrganizationCommand } from '../../../shared/commands/organization.command'; export class CreateEnvironmentCommand extends OrganizationCommand { @@ -9,4 +9,12 @@ export class CreateEnvironmentCommand extends OrganizationCommand { @IsOptional() @IsMongoId() parentEnvironmentId?: string; + + @IsOptional() + @IsHexColor() + color?: string; + + @IsBoolean() + @IsDefined() + system: boolean; } diff --git a/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts b/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts index 6d2d5488845..35e675291eb 100644 --- a/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts @@ -1,13 +1,16 @@ -import { nanoid } from 'nanoid'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, UnprocessableEntityException } from '@nestjs/common'; import { createHash } from 'crypto'; +import { nanoid } from 'nanoid'; -import { EnvironmentRepository, NotificationGroupRepository } from '@novu/dal'; import { encryptApiKey } from '@novu/application-generic'; +import { EnvironmentRepository, NotificationGroupRepository } from '@novu/dal'; -import { CreateEnvironmentCommand } from './create-environment.command'; -import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase'; +import { EnvironmentEnum, PROTECTED_ENVIRONMENTS } from '@novu/shared'; +import { CreateNovuIntegrationsCommand } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.command'; +import { CreateNovuIntegrations } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; import { CreateDefaultLayout, CreateDefaultLayoutCommand } from '../../../layouts/usecases'; +import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase'; +import { CreateEnvironmentCommand } from './create-environment.command'; @Injectable() export class CreateEnvironment { @@ -15,19 +18,51 @@ export class CreateEnvironment { private environmentRepository: EnvironmentRepository, private notificationGroupRepository: NotificationGroupRepository, private generateUniqueApiKey: GenerateUniqueApiKey, - private createDefaultLayoutUsecase: CreateDefaultLayout + private createDefaultLayoutUsecase: CreateDefaultLayout, + private createNovuIntegrationsUsecase: CreateNovuIntegrations ) {} async execute(command: CreateEnvironmentCommand) { + const environmentCount = await this.environmentRepository.count({ + _organizationId: command.organizationId, + }); + + if (environmentCount >= 10) { + throw new BadRequestException('Organization cannot have more than 10 environments'); + } + + if (!command.system) { + const { name } = command; + + if (PROTECTED_ENVIRONMENTS.includes(name as EnvironmentEnum)) { + throw new UnprocessableEntityException('Environment name cannot be Development or Production'); + } + + const environment = await this.environmentRepository.findOne({ + _organizationId: command.organizationId, + name, + }); + + if (environment) { + throw new BadRequestException('Environment name must be unique'); + } + } + const key = await this.generateUniqueApiKey.execute(); const encryptedApiKey = encryptApiKey(key); const hashedApiKey = createHash('sha256').update(key).digest('hex'); + const color = this.getEnvironmentColor(command.name, command.color); + + if (!color) { + throw new BadRequestException('Color property is required'); + } const environment = await this.environmentRepository.create({ _organizationId: command.organizationId, name: command.name, identifier: nanoid(12), _parentId: command.parentEnvironmentId, + color, apiKeys: [ { key: encryptedApiKey, @@ -67,6 +102,21 @@ export class CreateEnvironment { }); } + await this.createNovuIntegrationsUsecase.execute( + CreateNovuIntegrationsCommand.create({ + environmentId: environment._id, + organizationId: environment._organizationId, + userId: command.userId, + }) + ); + return environment; } + + private getEnvironmentColor(name: string, commandColor?: string): string | undefined { + if (name === EnvironmentEnum.DEVELOPMENT) return '#ff8547'; + if (name === EnvironmentEnum.PRODUCTION) return '#7e52f4'; + + return commandColor; + } } diff --git a/apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.command.ts b/apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.command.ts new file mode 100644 index 00000000000..189c61a2fc6 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.command.ts @@ -0,0 +1,3 @@ +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class DeleteEnvironmentCommand extends EnvironmentWithUserCommand {} diff --git a/apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.usecase.ts b/apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.usecase.ts new file mode 100644 index 00000000000..2476ae33d3d --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/delete-environment/delete-environment.usecase.ts @@ -0,0 +1,53 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { EnvironmentRepository, IntegrationRepository } from '@novu/dal'; +import { EnvironmentEnum, PROTECTED_ENVIRONMENTS } from '@novu/shared'; +import { RemoveIntegrationCommand } from '../../../integrations/usecases/remove-integration/remove-integration.command'; +import { RemoveIntegration } from '../../../integrations/usecases/remove-integration/remove-integration.usecase'; +import { DeleteEnvironmentCommand } from './delete-environment.command'; + +@Injectable() +export class DeleteEnvironment { + constructor( + private environmentRepository: EnvironmentRepository, + private removeIntegration: RemoveIntegration, + private integrationRepository: IntegrationRepository + ) {} + + async execute(command: DeleteEnvironmentCommand): Promise { + const environment = await this.environmentRepository.findOne({ + _id: command.environmentId, + _organizationId: command.organizationId, + }); + + if (!environment) { + throw new NotFoundException(`Environment ${command.environmentId} not found`); + } + + if (PROTECTED_ENVIRONMENTS.includes(environment.name as EnvironmentEnum)) { + throw new BadRequestException( + `The ${environment.name} environment is protected and cannot be deleted. Only custom environments can be deleted.` + ); + } + + await this.environmentRepository.delete({ + _id: command.environmentId, + _organizationId: command.organizationId, + }); + + const integrations = await this.integrationRepository.find({ + _organizationId: command.organizationId, + _environmentId: command.environmentId, + }); + + for (const integration of integrations) { + await this.removeIntegration.execute( + RemoveIntegrationCommand.create({ + organizationId: command.organizationId, + integrationId: integration._id, + userId: command.userId, + environmentId: command.environmentId, + }) + ); + } + } +} diff --git a/apps/api/src/app/environments-v1/usecases/delete-environment/index.ts b/apps/api/src/app/environments-v1/usecases/delete-environment/index.ts new file mode 100644 index 00000000000..77fbc92d6e8 --- /dev/null +++ b/apps/api/src/app/environments-v1/usecases/delete-environment/index.ts @@ -0,0 +1,2 @@ +export * from './delete-environment.command'; +export * from './delete-environment.usecase'; diff --git a/apps/api/src/app/environments-v1/usecases/index.ts b/apps/api/src/app/environments-v1/usecases/index.ts index 98658a41aa8..8fb987543d8 100644 --- a/apps/api/src/app/environments-v1/usecases/index.ts +++ b/apps/api/src/app/environments-v1/usecases/index.ts @@ -1,11 +1,12 @@ +import { GetMxRecord } from '../../inbound-parse/usecases/get-mx-record/get-mx-record.usecase'; import { CreateEnvironment } from './create-environment/create-environment.usecase'; +import { DeleteEnvironment } from './delete-environment/delete-environment.usecase'; import { GenerateUniqueApiKey } from './generate-unique-api-key/generate-unique-api-key.usecase'; import { GetApiKeys } from './get-api-keys/get-api-keys.usecase'; -import { RegenerateApiKeys } from './regenerate-api-keys/regenerate-api-keys.usecase'; import { GetEnvironment } from './get-environment'; import { GetMyEnvironments } from './get-my-environments/get-my-environments.usecase'; +import { RegenerateApiKeys } from './regenerate-api-keys/regenerate-api-keys.usecase'; import { UpdateEnvironment } from './update-environment/update-environment.usecase'; -import { GetMxRecord } from '../../inbound-parse/usecases/get-mx-record/get-mx-record.usecase'; export const USE_CASES = [ GetMxRecord, @@ -16,4 +17,5 @@ export const USE_CASES = [ RegenerateApiKeys, GetEnvironment, GetMyEnvironments, + DeleteEnvironment, ]; diff --git a/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.command.ts b/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.command.ts index cdc16866e1f..39e17fa58b5 100644 --- a/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.command.ts +++ b/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.command.ts @@ -19,6 +19,10 @@ export class UpdateEnvironmentCommand extends OrganizationCommand { @IsMongoId() _parentId?: string; + @IsOptional() + @IsString() + color?: string; + @IsOptional() dns?: { inboundParseDomain?: string }; diff --git a/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts b/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts index 03756dd5b5a..93c5888ab71 100644 --- a/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal'; import { UpdateEnvironmentCommand } from './update-environment.command'; @@ -7,6 +7,15 @@ export class UpdateEnvironment { constructor(private environmentRepository: EnvironmentRepository) {} async execute(command: UpdateEnvironmentCommand) { + const environment = await this.environmentRepository.findOne({ + _id: command.environmentId, + _organizationId: command.organizationId, + }); + + if (!environment) { + throw new UnauthorizedException('Environment not found'); + } + const updatePayload: Partial = {}; if (command.name && command.name !== '') { @@ -20,6 +29,10 @@ export class UpdateEnvironment { updatePayload.identifier = command.identifier; } + if (command.color) { + updatePayload.color = command.color; + } + if (command.dns && command.dns.inboundParseDomain && command.dns.inboundParseDomain !== '') { updatePayload[`dns.inboundParseDomain`] = command.dns.inboundParseDomain; } diff --git a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts index c98be3c217f..2414f323a9d 100644 --- a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts +++ b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts @@ -1,8 +1,8 @@ /* eslint-disable global-require */ -import { BadRequestException, Inject, Injectable, Logger, Scope } from '@nestjs/common'; -import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; -import { ApiServiceLevelEnum, JobTitleEnum, MemberRoleEnum, EnvironmentEnum } from '@novu/shared'; +import { BadRequestException, Injectable, Logger, Scope } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; +import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; +import { ApiServiceLevelEnum, EnvironmentEnum, JobTitleEnum, MemberRoleEnum } from '@novu/shared'; import { ModuleRef } from '@nestjs/core'; import { CreateEnvironmentCommand } from '../../../environments-v1/usecases/create-environment/create-environment.command'; @@ -13,9 +13,8 @@ import { AddMemberCommand } from '../membership/add-member/add-member.command'; import { AddMember } from '../membership/add-member/add-member.usecase'; import { CreateOrganizationCommand } from './create-organization.command'; -import { ApiException } from '../../../shared/exceptions/api.exception'; import { CreateNovuIntegrations } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; -import { CreateNovuIntegrationsCommand } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.command'; +import { ApiException } from '../../../shared/exceptions/api.exception'; @Injectable({ scope: Scope.REQUEST, @@ -61,31 +60,17 @@ export class CreateOrganization { userId: user._id, name: EnvironmentEnum.DEVELOPMENT, organizationId: createdOrganization._id, + system: true, }) ); - await this.createNovuIntegrations.execute( - CreateNovuIntegrationsCommand.create({ - environmentId: devEnv._id, - organizationId: devEnv._organizationId, - userId: user._id, - }) - ); - - const prodEnv = await this.createEnvironmentUsecase.execute( + await this.createEnvironmentUsecase.execute( CreateEnvironmentCommand.create({ userId: user._id, name: EnvironmentEnum.PRODUCTION, organizationId: createdOrganization._id, parentEnvironmentId: devEnv._id, - }) - ); - - await this.createNovuIntegrations.execute( - CreateNovuIntegrationsCommand.create({ - environmentId: prodEnv._id, - organizationId: prodEnv._organizationId, - userId: user._id, + system: true, }) ); @@ -132,9 +117,11 @@ export class CreateOrganization { if (!require('@novu/ee-billing')?.StartReverseFreeTrial) { throw new BadRequestException('Billing module is not loaded'); } + const usecase = this.moduleRef.get(require('@novu/ee-billing')?.StartReverseFreeTrial, { strict: false, }); + await usecase.execute({ userId, organizationId, diff --git a/apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.usecase.ts b/apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.usecase.ts index 6f10aeb5569..a14b3fe1733 100644 --- a/apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.usecase.ts +++ b/apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.usecase.ts @@ -1,7 +1,7 @@ /* eslint-disable global-require */ -import { BadRequestException, Injectable, Logger, Scope } from '@nestjs/common'; -import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; +import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; import { ModuleRef } from '@nestjs/core'; import { CreateEnvironmentCommand } from '../../../../environments-v1/usecases/create-environment/create-environment.command'; @@ -9,9 +9,9 @@ import { CreateEnvironment } from '../../../../environments-v1/usecases/create-e import { GetOrganizationCommand } from '../../get-organization/get-organization.command'; import { GetOrganization } from '../../get-organization/get-organization.usecase'; -import { ApiException } from '../../../../shared/exceptions/api.exception'; -import { CreateNovuIntegrations } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; import { CreateNovuIntegrationsCommand } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.command'; +import { CreateNovuIntegrations } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; +import { ApiException } from '../../../../shared/exceptions/api.exception'; import { SyncExternalOrganizationCommand } from './sync-external-organization.command'; // TODO: eventually move to @novu/ee-auth @@ -49,6 +49,7 @@ export class SyncExternalOrganization { userId: user._id, name: 'Development', organizationId: organization._id, + system: true, }) ); @@ -66,6 +67,7 @@ export class SyncExternalOrganization { name: 'Production', organizationId: organization._id, parentEnvironmentId: devEnv._id, + system: true, }) ); diff --git a/apps/dashboard/src/api/environments.ts b/apps/dashboard/src/api/environments.ts index 2c0ab54221f..8117ef762cb 100644 --- a/apps/dashboard/src/api/environments.ts +++ b/apps/dashboard/src/api/environments.ts @@ -1,12 +1,23 @@ -import type { IApiKey, IEnvironment, ITagsResponse } from '@novu/shared'; -import { get, getV2, put } from './api.client'; +import { IApiKey, IEnvironment, ITagsResponse } from '@novu/shared'; +import { del, get, getV2, post, put } from './api.client'; export async function getEnvironments() { const { data } = await get<{ data: IEnvironment[] }>('/environments'); return data; } -// TODO: Reuse the BridgeRequestDto type +export async function updateEnvironment({ + environment, + name, + color, +}: { + environment: IEnvironment; + name: string; + color?: string; +}) { + return put<{ data: IEnvironment }>(`/environments/${environment._id}`, { body: { name, color } }); +} + export async function updateBridgeUrl({ environment, url }: { environment: IEnvironment; url?: string }) { return put(`/environments/${environment._id}`, { body: { bridge: { url } } }); } @@ -21,3 +32,13 @@ export async function getTags({ environment }: { environment: IEnvironment }): P const { data } = await getV2<{ data: ITagsResponse }>(`/environments/${environment._id}/tags`); return data; } + +export async function createEnvironment(payload: { name: string; color: string }): Promise { + const response = await post<{ data: IEnvironment }>('/environments', { body: payload }); + + return response.data; +} + +export async function deleteEnvironment({ environment }: { environment: IEnvironment }): Promise { + return del(`/environments/${environment._id}`); +} diff --git a/apps/dashboard/src/components/billing/features.tsx b/apps/dashboard/src/components/billing/features.tsx index 610391e412d..1b627f3d9be 100644 --- a/apps/dashboard/src/components/billing/features.tsx +++ b/apps/dashboard/src/components/billing/features.tsx @@ -64,6 +64,14 @@ const features: Feature[] = [ [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, }, }, + { + label: 'Environments', + values: { + [SupportedPlansEnum.FREE]: { value: '2' }, + [SupportedPlansEnum.BUSINESS]: { value: '10' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, + }, + }, { label: 'Framework', isTitle: true, diff --git a/apps/dashboard/src/components/create-environment-button.tsx b/apps/dashboard/src/components/create-environment-button.tsx new file mode 100644 index 00000000000..5316b7bcb3a --- /dev/null +++ b/apps/dashboard/src/components/create-environment-button.tsx @@ -0,0 +1,218 @@ +import { Button } from '@/components/primitives/button'; +import { + Form, + FormControl, + FormField, + FormInput, + FormItem, + FormLabel, + FormMessage, +} from '@/components/primitives/form/form'; +import { Separator } from '@/components/primitives/separator'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetMain, + SheetTitle, +} from '@/components/primitives/sheet'; +import { ExternalLink } from '@/components/shared/external-link'; +import { useAuth } from '@/context/auth/hooks'; +import { useFetchEnvironments } from '@/context/environment/hooks'; +import { useCreateEnvironment } from '@/hooks/use-environments'; +import { useFetchSubscription } from '@/hooks/use-fetch-subscription'; +import { ROUTES } from '@/utils/routes'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ApiServiceLevelEnum, IEnvironment } from '@novu/shared'; +import { ComponentProps, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { RiAddLine, RiArrowRightSLine } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; +import { ColorPicker } from './primitives/color-picker'; +import { showErrorToast, showSuccessToast } from './primitives/sonner-helpers'; +import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'; + +const ENVIRONMENT_COLORS = [ + '#FF6B6B', // Vibrant Coral + '#4ECDC4', // Bright Turquoise + '#45B7D1', // Azure Blue + '#96C93D', // Lime Green + '#A66CFF', // Bright Purple + '#FF9F43', // Bright Orange + '#FF78C4', // Hot Pink + '#20C997', // Emerald + '#845EC2', // Royal Purple + '#FF5E78', // Bright Red +] as const; + +function getRandomColor(existingEnvironments: IEnvironment[] = []) { + const usedColors = new Set(existingEnvironments.map((env) => (env as any).color).filter(Boolean)); + const availableColors = ENVIRONMENT_COLORS.filter((color) => !usedColors.has(color)); + + // If all colors are used, fall back to the original list + const colorPool = availableColors.length > 0 ? availableColors : ENVIRONMENT_COLORS; + + return colorPool[Math.floor(Math.random() * colorPool.length)]; +} + +const createEnvironmentSchema = z.object({ + name: z.string().min(1, 'Name is required'), + color: z.string(), +}); + +type CreateEnvironmentFormData = z.infer; + +type CreateEnvironmentButtonProps = ComponentProps; + +export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) => { + const { currentOrganization } = useAuth(); + const { environments = [] } = useFetchEnvironments({ organizationId: currentOrganization?._id }); + const [isOpen, setIsOpen] = useState(false); + const { mutateAsync, isPending } = useCreateEnvironment(); + const { subscription } = useFetchSubscription(); + const navigate = useNavigate(); + + const isBusinessTier = subscription?.apiServiceLevel === ApiServiceLevelEnum.BUSINESS; + const isTrialActive = subscription?.trial?.isActive; + const canCreateEnvironment = isBusinessTier && !isTrialActive; + + const form = useForm({ + resolver: zodResolver(createEnvironmentSchema), + defaultValues: { + name: '', + color: getRandomColor(environments), + }, + }); + + const onSubmit = async (values: CreateEnvironmentFormData) => { + try { + await mutateAsync({ + name: values.name, + color: values.color, + }); + + setIsOpen(false); + + form.reset({ + name: '', + color: getRandomColor(environments), + }); + + showSuccessToast('Environment created successfully'); + } catch (e: any) { + const message = e?.response?.data?.message || e?.message || 'Failed to create environment'; + showErrorToast(Array.isArray(message) ? message[0] : message); + } + }; + + const handleClick = () => { + if (!canCreateEnvironment) { + navigate(ROUTES.SETTINGS_BILLING); + return; + } + + setIsOpen(true); + }; + + const getTooltipContent = () => { + if (!canCreateEnvironment) { + return 'Upgrade to Business plan to create custom environments'; + } + + return ''; + }; + + const button = ( + + ); + + return ( + + {canCreateEnvironment ? ( + button + ) : ( + + {button} + {getTooltipContent()} + + )} + + {canCreateEnvironment && ( + e.preventDefault()}> + + Create environment +
+ + Create a new environment to manage your notifications.{' '} + Learn more + +
+
+ + +
+ + ( + + Name + + { + field.onChange(e); + }} + /> + + + + )} + /> + ( + + Color + + + + Will be used to identify the environment in the UI. + + )} + /> + + +
+ + + + +
+ )} +
+ ); +}; diff --git a/apps/dashboard/src/components/delete-environment-dialog.tsx b/apps/dashboard/src/components/delete-environment-dialog.tsx new file mode 100644 index 00000000000..617221a84de --- /dev/null +++ b/apps/dashboard/src/components/delete-environment-dialog.tsx @@ -0,0 +1,53 @@ +import { Button } from '@/components/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/primitives/dialog'; +import { IEnvironment } from '@novu/shared'; + +interface DeleteEnvironmentDialogProps { + environment?: IEnvironment; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isLoading?: boolean; +} + +export const DeleteEnvironmentDialog = ({ + environment, + open, + onOpenChange, + onConfirm, + isLoading, +}: DeleteEnvironmentDialogProps) => { + if (!environment) { + return null; + } + + return ( + + + + Delete Environment + + Deleting {environment.name} will permanently remove this environment and + all the data associated with it. Including integrations, workflows, and notifications. This action cannot be + undone. Are you sure you want to proceed? + + + + + + + + + ); +}; diff --git a/apps/dashboard/src/components/edit-environment-sheet.tsx b/apps/dashboard/src/components/edit-environment-sheet.tsx new file mode 100644 index 00000000000..6f5e4e4f4db --- /dev/null +++ b/apps/dashboard/src/components/edit-environment-sheet.tsx @@ -0,0 +1,149 @@ +import { Button } from '@/components/primitives/button'; +import { + Form, + FormControl, + FormField, + FormInput, + FormItem, + FormLabel, + FormMessage, +} from '@/components/primitives/form/form'; +import { Separator } from '@/components/primitives/separator'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetMain, + SheetTitle, +} from '@/components/primitives/sheet'; +import { ExternalLink } from '@/components/shared/external-link'; +import { useUpdateEnvironment } from '@/hooks/use-environments'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { IEnvironment } from '@novu/shared'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { RiArrowRightSLine } from 'react-icons/ri'; +import { z } from 'zod'; +import { ColorPicker } from './primitives/color-picker'; + +const editEnvironmentSchema = z.object({ + name: z.string().min(1, 'Name is required'), + color: z.string().min(1, 'Color is required'), +}); + +type EditEnvironmentFormData = z.infer; + +interface EditEnvironmentSheetProps { + environment?: IEnvironment; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +export const EditEnvironmentSheet = ({ environment, isOpen, onOpenChange }: EditEnvironmentSheetProps) => { + const { mutateAsync: updateEnvironment, isPending } = useUpdateEnvironment(); + + const form = useForm({ + resolver: zodResolver(editEnvironmentSchema), + defaultValues: { + name: environment?.name || '', + color: environment?.color, + }, + }); + + useEffect(() => { + if (environment) { + form.reset({ + name: environment.name, + color: environment.color, + }); + } + }, [environment, form]); + + const onSubmit = async (values: EditEnvironmentFormData) => { + if (!environment) return; + + await updateEnvironment({ + environment, + name: values.name, + color: values.color, + }); + onOpenChange(false); + form.reset(); + }; + + return ( + + e.preventDefault()}> + + Edit environment +
+ + Update your environment settings.{' '} + Learn more + +
+
+ + +
+ + ( + + Name + + { + field.onChange(e); + }} + /> + + + + )} + /> + ( + + Color + + + + + + )} + /> + + +
+ + + + +
+
+ ); +}; diff --git a/apps/dashboard/src/components/environments-list.tsx b/apps/dashboard/src/components/environments-list.tsx new file mode 100644 index 00000000000..3ff4a539cac --- /dev/null +++ b/apps/dashboard/src/components/environments-list.tsx @@ -0,0 +1,179 @@ +import { useAuth } from '@/context/auth/hooks'; +import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; +import { useDeleteEnvironment } from '@/hooks/use-environments'; +import { cn } from '@/utils/ui'; +import { EnvironmentEnum, IEnvironment, PROTECTED_ENVIRONMENTS } from '@novu/shared'; +import { useState } from 'react'; +import { RiDeleteBin2Line, RiMore2Fill } from 'react-icons/ri'; +import { DeleteEnvironmentDialog } from './delete-environment-dialog'; +import { EditEnvironmentSheet } from './edit-environment-sheet'; +import { Badge } from './primitives/badge'; +import { CompactButton } from './primitives/button-compact'; +import { CopyButton } from './primitives/copy-button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './primitives/dropdown-menu'; +import { EnvironmentBranchIcon } from './primitives/environment-branch-icon'; +import { Skeleton } from './primitives/skeleton'; +import { showErrorToast, showSuccessToast } from './primitives/sonner-helpers'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './primitives/table'; +import { TimeDisplayHoverCard } from './time-display-hover-card'; +import TruncatedText from './truncated-text'; + +const EnvironmentRowSkeleton = () => ( + + +
+ + +
+
+ + + + + + + + + +
+); + +export function EnvironmentsList() { + const { currentOrganization } = useAuth(); + const { environments = [], areEnvironmentsInitialLoading } = useFetchEnvironments({ + organizationId: currentOrganization?._id, + }); + const { currentEnvironment } = useEnvironment(); + const [editEnvironment, setEditEnvironment] = useState(); + const [deleteEnvironment, setDeleteEnvironment] = useState(); + const { mutateAsync: deleteEnvironmentAction, isPending: isDeletePending } = useDeleteEnvironment(); + + const onDeleteEnvironment = async () => { + if (!deleteEnvironment) return; + + try { + await deleteEnvironmentAction({ environment: deleteEnvironment }); + showSuccessToast('Environment deleted successfully'); + + setDeleteEnvironment(undefined); + } catch (e: any) { + const message = e?.response?.data?.message || e?.message || 'Failed to delete environment'; + showErrorToast(Array.isArray(message) ? message[0] : message); + } + }; + + const handleDeleteClick = (environment: IEnvironment) => { + setDeleteEnvironment(environment); + }; + + return ( + <> + + + + Name + Identifier + Last Updated + + + + + {areEnvironmentsInitialLoading + ? Array.from({ length: 3 }).map((_, i) => ) + : environments.map((environment) => ( + + +
+ +
+ {environment.name} + {environment._id === currentEnvironment?._id && ( + + Current + + )} +
+
+
+ +
+ + {environment.identifier} + + +
+
+ + + {new Date(environment.updatedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + + + + {!PROTECTED_ENVIRONMENTS.includes(environment.name as EnvironmentEnum) && ( + + + + + + + setEditEnvironment(environment)}> + Edit environment + + <> + + handleDeleteClick(environment)} + disabled={ + environment._id === currentEnvironment?._id || + PROTECTED_ENVIRONMENTS.includes(environment.name as EnvironmentEnum) + } + > + + Delete environment + + + + + + )} + +
+ ))} +
+
+ !open && setEditEnvironment(undefined)} + /> + !open && setDeleteEnvironment(undefined)} + onConfirm={onDeleteEnvironment} + isLoading={isDeletePending} + /> + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-card.tsx b/apps/dashboard/src/components/integrations/components/integration-card.tsx index e451b4618d3..f5239bd26c8 100644 --- a/apps/dashboard/src/components/integrations/components/integration-card.tsx +++ b/apps/dashboard/src/components/integrations/components/integration-card.tsx @@ -3,15 +3,10 @@ import { Button } from '@/components/primitives/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { ROUTES } from '@/utils/routes'; import { ChannelTypeEnum, type IEnvironment, type IIntegration, type IProviderConfig } from '@novu/shared'; -import { - RiCheckboxCircleFill, - RiCloseCircleFill, - RiGitBranchFill, - RiSettings4Line, - RiStarSmileLine, -} from 'react-icons/ri'; +import { RiCheckboxCircleFill, RiCloseCircleFill, RiSettings4Line, RiStarSmileLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { cn } from '../../../utils/ui'; +import { EnvironmentBranchIcon } from '../../primitives/environment-branch-icon'; import { StatusBadge, StatusBadgeIcon } from '../../primitives/status-badge'; import { TableIntegration } from '../types'; import { ProviderIcon } from './provider-icon'; @@ -115,9 +110,7 @@ export function IntegrationCard({ integration, provider, environment, onClick }: )} - + {environment.name} diff --git a/apps/dashboard/src/components/integrations/components/integration-configuration.tsx b/apps/dashboard/src/components/integrations/components/integration-configuration.tsx index 2e89c66f688..e721fd5d9a9 100644 --- a/apps/dashboard/src/components/integrations/components/integration-configuration.tsx +++ b/apps/dashboard/src/components/integrations/components/integration-configuration.tsx @@ -1,20 +1,19 @@ -import { useForm, useWatch } from 'react-hook-form'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; import { Form } from '@/components/primitives/form/form'; import { Label } from '@/components/primitives/label'; import { Separator } from '@/components/primitives/separator'; -import { RiGitBranchLine, RiInputField } from 'react-icons/ri'; +import { useAuth } from '@/context/auth/hooks'; +import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; import { IIntegration, IProviderConfig } from '@novu/shared'; import { useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { RiInputField } from 'react-icons/ri'; import { InlineToast } from '../../../components/primitives/inline-toast'; -import { SegmentedControl, SegmentedControlList } from '../../../components/primitives/segmented-control'; -import { SegmentedControlTrigger } from '../../../components/primitives/segmented-control'; -import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; -import { useAuth } from '@/context/auth/hooks'; -import { GeneralSettings } from './integration-general-settings'; +import { cn } from '../../../utils/ui'; +import { EnvironmentDropdown } from '../../side-navigation/environment-dropdown'; import { CredentialsSection } from './integration-credentials'; +import { GeneralSettings } from './integration-general-settings'; import { isDemoIntegration } from './utils/helpers'; -import { cn } from '../../../utils/ui'; type IntegrationFormData = { name: string; @@ -96,24 +95,20 @@ export function IntegrationConfiguration({ - setValue('environmentId', value)} - className={cn('w-full', mode === 'update' ? 'max-w-[160px]' : 'max-w-[260px]')} - > - - {environments - ?.filter((env) => (mode === 'update' ? env._id === integration?._environmentId : true)) - .map((env) => ( - - - {env.name} - - ))} - - +
+ env._id === environmentId)} + data={environments} + onChange={(value) => { + const env = environments?.find((env) => env.name === value); + if (env) { + setValue('environmentId', env._id); + } + }} + /> +
diff --git a/apps/dashboard/src/components/primitives/color-picker.tsx b/apps/dashboard/src/components/primitives/color-picker.tsx index f1136ef883c..b82d9287402 100644 --- a/apps/dashboard/src/components/primitives/color-picker.tsx +++ b/apps/dashboard/src/components/primitives/color-picker.tsx @@ -1,24 +1,35 @@ import { HexColorPicker } from 'react-colorful'; import { cn } from '../../utils/ui'; -import { Input } from './input'; +import { Input, InputPure } from './input'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; interface ColorPickerProps { value: string; onChange: (color: string) => void; className?: string; + pureInput?: boolean; } -export function ColorPicker({ value, onChange, className }: ColorPickerProps) { +export function ColorPicker({ value, onChange, className, pureInput = true }: ColorPickerProps) { return ( -
- onChange(e.target.value)} - /> - +
+ {pureInput ? ( + onChange(e.target.value)} + /> + ) : ( + onChange(e.target.value)} + /> + )} +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/side-navigation/environment-dropdown.tsx b/apps/dashboard/src/components/side-navigation/environment-dropdown.tsx index 0d511f68ef9..2f2daab7b4e 100644 --- a/apps/dashboard/src/components/side-navigation/environment-dropdown.tsx +++ b/apps/dashboard/src/components/side-navigation/environment-dropdown.tsx @@ -1,39 +1,31 @@ -import { cva } from 'class-variance-authority'; -import { RiExpandUpDownLine, RiGitBranchLine } from 'react-icons/ri'; +import { IEnvironment } from '@novu/shared'; +import { RiExpandUpDownLine } from 'react-icons/ri'; +import { cn } from '../../utils/ui'; +import { EnvironmentBranchIcon } from '../primitives/environment-branch-icon'; import { Select, SelectContent, SelectIcon, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'; -const logoVariants = cva(`size-6 rounded-[6px] border-[1px] border-solid p-1 `, { - variants: { - variant: { - default: 'bg-warning/10 border-warning text-warning', - production: 'bg-feature/10 border-feature text-feature', - }, - }, - defaultVariants: { - variant: 'default', - }, -}); - type EnvironmentDropdownProps = { - value?: string; - data?: string[]; + currentEnvironment?: IEnvironment; + data?: IEnvironment[]; onChange?: (value: string) => void; + className?: string; + disabled?: boolean; }; -export const EnvironmentDropdown = ({ value, data, onChange }: EnvironmentDropdownProps) => { +export const EnvironmentDropdown = ({ + currentEnvironment, + data, + onChange, + className, + disabled, +}: EnvironmentDropdownProps) => { return ( - + svg]:hidden', className)}>
-
- -
- {value} + + {currentEnvironment?.name}
@@ -41,9 +33,12 @@ export const EnvironmentDropdown = ({ value, data, onChange }: EnvironmentDropdo
- {data?.map((item) => ( - - {item} + {data?.map((environment) => ( + +
+ + {environment.name} +
))}
diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 750e253253d..4179ff453fb 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -1,7 +1,16 @@ -import { ReactNode, useMemo } from 'react'; +import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { useEnvironment } from '@/context/environment/hooks'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { useTelemetry } from '@/hooks/use-telemetry'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { TelemetryEvent } from '@/utils/telemetry'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import * as Sentry from '@sentry/react'; +import { ReactNode } from 'react'; import { RiBarChartBoxLine, RiChat1Line, + RiDatabase2Line, RiGroup2Line, RiKey2Line, RiRouteFill, @@ -9,20 +18,14 @@ import { RiStore3Line, RiUserAddLine, } from 'react-icons/ri'; -import { useEnvironment } from '@/context/environment/hooks'; -import { buildRoute, ROUTES } from '@/utils/routes'; -import { TelemetryEvent } from '@/utils/telemetry'; -import { useTelemetry } from '@/hooks/use-telemetry'; +import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; +import { ChangelogStack } from './changelog-cards'; import { EnvironmentDropdown } from './environment-dropdown'; -import { OrganizationDropdown } from './organization-dropdown'; import { FreeTrialCard } from './free-trial-card'; -import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; -import { SidebarContent } from '@/components/side-navigation/sidebar'; -import { NavigationLink } from './navigation-link'; import { GettingStartedMenuItem } from './getting-started-menu-item'; -import { ChangelogStack } from './changelog-cards'; -import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; -import * as Sentry from '@sentry/react'; +import { NavigationLink } from './navigation-link'; +import { OrganizationDropdown } from './organization-dropdown'; +import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; const NavigationGroup = ({ children, label }: { children: ReactNode; label?: string }) => { return ( @@ -36,10 +39,10 @@ const NavigationGroup = ({ children, label }: { children: ReactNode; label?: str export const SideNavigation = () => { const { subscription, daysLeft, isLoading: isLoadingSubscription } = useFetchSubscription(); const isFreeTrialActive = subscription?.trial.isActive || subscription?.hasPaymentMethod; + const isEnvironmentManagementEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ENVIRONMENT_MANAGEMENT_ENABLED); const { currentEnvironment, environments, switchEnvironment } = useEnvironment(); const track = useTelemetry(); - const environmentNames = useMemo(() => environments?.map((env) => env.name), [environments]); const onEnvironmentChange = (value: string) => { const environment = environments?.find((env) => env.name === value); @@ -60,7 +63,11 @@ export const SideNavigation = () => {
-
-); + ); +}; diff --git a/apps/dashboard/src/components/workflow-list.tsx b/apps/dashboard/src/components/workflow-list.tsx index 04700bf0f63..1c11a6dcbf4 100644 --- a/apps/dashboard/src/components/workflow-list.tsx +++ b/apps/dashboard/src/components/workflow-list.tsx @@ -11,12 +11,19 @@ import { } from '@/components/primitives/table'; import { WorkflowListEmpty } from '@/components/workflow-list-empty'; import { WorkflowRow } from '@/components/workflow-row'; -import { useFetchWorkflows } from '@/hooks/use-fetch-workflows'; +import { ListWorkflowResponse } from '@novu/shared'; import { RiMore2Fill } from 'react-icons/ri'; import { createSearchParams, useLocation, useSearchParams } from 'react-router-dom'; import { ServerErrorPage } from './shared/server-error-page'; -export function WorkflowList() { +interface WorkflowListProps { + data?: ListWorkflowResponse; + isPending?: boolean; + isError?: boolean; + limit?: number; +} + +export function WorkflowList({ data, isPending, isError, limit = 12 }: WorkflowListProps) { const [searchParams] = useSearchParams(); const location = useLocation(); @@ -28,21 +35,18 @@ export function WorkflowList() { }; const offset = parseInt(searchParams.get('offset') || '0'); - const limit = parseInt(searchParams.get('limit') || '12'); - - const { data, isPending, isError, currentPage, totalPages } = useFetchWorkflows({ - limit, - offset, - }); if (isError) return ; - if (!isPending && data.totalCount === 0) { + if (!isPending && data?.totalCount === 0) { return ; } + const currentPage = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil((data?.totalCount || 0) / limit); + return ( -
+
@@ -82,11 +86,7 @@ export function WorkflowList() { ))} ) : ( - <> - {data.workflows.map((workflow) => ( - - ))} - + <>{data?.workflows.map((workflow) => )} )} {data && limit < data.totalCount && ( diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 98071ec5506..2035ac91b69 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -6,16 +6,19 @@ import { ConfigureStepTemplate } from '@/components/workflow-editor/steps/config import { ActivityFeed, ApiKeysPage, + CreateWorkflowPage, IntegrationsListPage, OrganizationListPage, QuestionnairePage, SettingsPage, SignInPage, SignUpPage, + TemplateModal, UsecaseSelectPage, WelcomePage, WorkflowsPage, } from '@/pages'; + import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; @@ -102,6 +105,20 @@ const router = createBrowserRouter([ { path: ROUTES.WORKFLOWS, element: , + children: [ + { + path: ROUTES.TEMPLATE_STORE, + element: , + }, + { + path: ROUTES.TEMPLATE_STORE_CREATE_WORKFLOW, + element: , + }, + { + path: ROUTES.WORKFLOWS_CREATE, + element: , + }, + ], }, { path: ROUTES.API_KEYS, diff --git a/apps/dashboard/src/components/create-workflow-button.tsx b/apps/dashboard/src/pages/create-workflow.tsx similarity index 74% rename from apps/dashboard/src/components/create-workflow-button.tsx rename to apps/dashboard/src/pages/create-workflow.tsx index 21b1b58f306..651b8f251e5 100644 --- a/apps/dashboard/src/components/create-workflow-button.tsx +++ b/apps/dashboard/src/pages/create-workflow.tsx @@ -8,23 +8,27 @@ import { SheetHeader, SheetMain, SheetTitle, - SheetTrigger, } from '@/components/primitives/sheet'; import { ExternalLink } from '@/components/shared/external-link'; import { CreateWorkflowForm } from '@/components/workflow-editor/create-workflow-form'; import { useCreateWorkflow } from '@/hooks/use-create-workflow'; -import { ComponentProps, forwardRef, useState } from 'react'; import { RiArrowRightSLine } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; -export const CreateWorkflowButton = forwardRef>((props, ref) => { - const [isOpen, setIsOpen] = useState(false); - const { submit, isLoading: isCreating } = useCreateWorkflow({ - onSuccess: () => setIsOpen(false), - }); +export function CreateWorkflowPage() { + const navigate = useNavigate(); + + const { submit, isLoading: isCreating } = useCreateWorkflow(); return ( - - + { + if (!isOpen) { + navigate(-1); + } + }} + > e.preventDefault()}> Create workflow @@ -55,6 +59,4 @@ export const CreateWorkflowButton = forwardRef ); -}); - -CreateWorkflowButton.displayName = 'CreateWorkflowButton'; +} diff --git a/apps/dashboard/src/pages/index.ts b/apps/dashboard/src/pages/index.ts index d2cc1621da0..349d40e3f10 100644 --- a/apps/dashboard/src/pages/index.ts +++ b/apps/dashboard/src/pages/index.ts @@ -1,11 +1,12 @@ -export * from './workflows'; -export * from './sign-in'; -export * from './sign-up'; +export * from './activity-feed'; +export * from './api-keys'; +export * from './create-workflow'; +export * from './integrations-list-page'; export * from './organization-list'; export * from './questionnaire-page'; +export * from './settings'; +export * from './sign-in'; +export * from './sign-up'; export * from './usecase-select-page'; -export * from './api-keys'; export * from './welcome-page'; -export * from './integrations-list-page'; -export * from './settings'; -export * from './activity-feed'; +export * from './workflows'; diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index df6ad1fe132..69c752e649e 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -1,33 +1,68 @@ -import { CreateWorkflowButton } from '@/components/create-workflow-button'; import { DashboardLayout } from '@/components/dashboard-layout'; import { OptInModal } from '@/components/opt-in-modal'; import { PageMeta } from '@/components/page-meta'; import { Button } from '@/components/primitives/button'; +import { ScrollArea, ScrollBar } from '@/components/primitives/scroll-area'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { useFetchWorkflows } from '@/hooks/use-fetch-workflows'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; -import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { useEffect, useState } from 'react'; -import { RiArrowDownSLine, RiFileAddLine, RiFileMarkedLine, RiRouteFill } from 'react-icons/ri'; +import { FeatureFlagsKeysEnum, StepTypeEnum } from '@novu/shared'; +import { useEffect } from 'react'; +import { RiArrowDownSLine, RiArrowRightSLine, RiFileAddLine, RiFileMarkedLine, RiRouteFill } from 'react-icons/ri'; +import { Outlet, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ButtonGroupItem, ButtonGroupRoot } from '../components/primitives/button-group'; +import { LinkButton } from '../components/primitives/button-link'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../components/primitives/dropdown-menu'; +import { getTemplates, WorkflowTemplate } from '../components/template-store/templates'; +import { WorkflowCard } from '../components/template-store/workflow-card'; import { WorkflowTemplateModal } from '../components/template-store/workflow-template-modal'; import { WorkflowList } from '../components/workflow-list'; +import { buildRoute, ROUTES } from '../utils/routes'; export const WorkflowsPage = () => { + const { environmentSlug } = useParams(); const track = useTelemetry(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const isTemplateStoreEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_TEMPLATE_STORE_ENABLED); - const [shouldOpenTemplateModal, setShouldOpenTemplateModal] = useState(false); + const templates = getTemplates(); + const popularTemplates = templates.filter((template) => template.isPopular).slice(0, 4); + + const offset = parseInt(searchParams.get('offset') || '0'); + const limit = parseInt(searchParams.get('limit') || '12'); + + const { + data: workflowsData, + isPending, + isError, + } = useFetchWorkflows({ + limit, + offset, + }); + + const shouldShowStartWith = isTemplateStoreEnabled && workflowsData && workflowsData.totalCount < 5; useEffect(() => { track(TelemetryEvent.WORKFLOWS_PAGE_VISIT); }, [track]); + const handleTemplateClick = (template: WorkflowTemplate) => { + track(TelemetryEvent.TEMPLATE_WORKFLOW_CLICK); + + navigate( + buildRoute(ROUTES.TEMPLATE_STORE_CREATE_WORKFLOW, { + environmentSlug: environmentSlug || '', + templateId: template.id, + }) + ); + }; + return ( <> @@ -39,17 +74,18 @@ export const WorkflowsPage = () => { {isTemplateStoreEnabled ? ( - - - + @@ -64,14 +100,30 @@ export const WorkflowsPage = () => { - +
{ + track(TelemetryEvent.CREATE_WORKFLOW_CLICK); + navigate(buildRoute(ROUTES.WORKFLOWS_CREATE, { environmentSlug: environmentSlug || '' })); + }} + >
Blank Workflow
- +
- setShouldOpenTemplateModal(true)}> + + navigate( + buildRoute(ROUTES.TEMPLATE_STORE, { + environmentSlug: environmentSlug || '', + source: 'create-workflow-dropdown', + }) + ) + } + > View Workflow Gallery @@ -80,17 +132,96 @@ export const WorkflowsPage = () => {
) : ( - - - + )} - {shouldOpenTemplateModal && } - + {shouldShowStartWith && ( +
+
+
Start with
+ + navigate( + buildRoute(ROUTES.TEMPLATE_STORE, { + environmentSlug: environmentSlug || '', + source: 'start-with', + }) + ) + } + trailingIcon={RiArrowRightSLine} + > + Explore templates + +
+ +
+
{ + track(TelemetryEvent.CREATE_WORKFLOW_CLICK); + + navigate(buildRoute(ROUTES.WORKFLOWS_CREATE, { environmentSlug: environmentSlug || '' })); + }} + > + +
+ {popularTemplates.map((template) => ( + step.type as StepTypeEnum)} + onClick={() => handleTemplateClick(template)} + /> + ))} +
+ +
+
+ )} + +
+ {shouldShowStartWith &&
Your Workflows
} + +
+ ); }; + +export const TemplateModal = () => { + const navigate = useNavigate(); + const { templateId, environmentSlug } = useParams(); + const templates = getTemplates(); + const selectedTemplate = templateId ? templates.find((template) => template.id === templateId) : undefined; + + const handleCloseTemplateModal = () => { + navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: environmentSlug || '' })); + }; + + return ( + { + if (!isOpen) { + handleCloseTemplateModal(); + } + }} + selectedTemplate={selectedTemplate} + /> + ); +}; diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 8009de69e36..1129017e15a 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -30,6 +30,9 @@ export const ROUTES = { API_KEYS: '/env/:environmentSlug/api-keys', ENVIRONMENTS: '/env/:environmentSlug/environments', ACTIVITY_FEED: '/env/:environmentSlug/activity-feed', + TEMPLATE_STORE: '/env/:environmentSlug/workflows/templates', + WORKFLOWS_CREATE: '/env/:environmentSlug/workflows/create', + TEMPLATE_STORE_CREATE_WORKFLOW: '/env/:environmentSlug/workflows/templates/:templateId', } as const; export const buildRoute = (route: string, params: Record) => { diff --git a/apps/dashboard/src/utils/telemetry.ts b/apps/dashboard/src/utils/telemetry.ts index b87a0df82c3..b712926a0c6 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -42,4 +42,7 @@ export enum TelemetryEvent { TEMPLATE_MODAL_OPENED = 'Template Modal Opened - [Template Store]', TEMPLATE_CATEGORY_SELECTED = 'Template Category Selected - [Template Store]', CREATE_WORKFLOW_FROM_TEMPLATE = 'Create Workflow From Template - [Template Store]', + CREATE_WORKFLOW_CLICK = 'Create Workflow Click', + GENERATE_WORKFLOW_CLICK = 'Generate Workflow Click', + TEMPLATE_WORKFLOW_CLICK = 'Template Workflow Click', } From 57750c400b01548729032f79c7254dba985e2826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Wed, 22 Jan 2025 13:06:31 +0100 Subject: [PATCH 11/25] feat(api-service,dashboard): step conditions validation logic and conditions editor perf improvements (#7543) --- .../query-validator.service.spec.ts | 480 ++++++++++++++++++ .../query-parser/query-validator.service.ts | 251 +++++++++ .../app/shared/services/query-parser/types.ts | 35 ++ .../build-step-issues.usecase.ts | 64 ++- .../conditions-editor-context.tsx | 15 +- .../conditions-editor/conditions-editor.tsx | 86 ++-- .../conditions-editor/field-selector.tsx | 51 +- .../conditions-editor/operator-selector.tsx | 41 +- .../conditions-editor/rule-actions.tsx | 79 +-- .../src/components/conditions-editor/types.ts | 4 +- .../conditions-editor/value-editor.tsx | 7 +- .../conditions-editor/variable-select.tsx | 2 +- .../control-input/control-input.tsx | 2 +- .../control-input/hooks/use-variables.ts | 6 +- .../conditions/edit-step-conditions-form.tsx | 16 +- .../edit-step-conditions-layout.tsx | 2 +- .../steps/conditions/edit-step-conditions.tsx | 4 +- .../steps/configure-step-form.tsx | 2 +- 18 files changed, 1002 insertions(+), 145 deletions(-) create mode 100644 apps/api/src/app/shared/services/query-parser/query-validator.service.spec.ts create mode 100644 apps/api/src/app/shared/services/query-parser/query-validator.service.ts create mode 100644 apps/api/src/app/shared/services/query-parser/types.ts diff --git a/apps/api/src/app/shared/services/query-parser/query-validator.service.spec.ts b/apps/api/src/app/shared/services/query-parser/query-validator.service.spec.ts new file mode 100644 index 00000000000..b7111d010d1 --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/query-validator.service.spec.ts @@ -0,0 +1,480 @@ +import { RulesLogic, AdditionalOperation } from 'json-logic-js'; +import { expect } from 'chai'; + +import { QueryIssueTypeEnum, QueryValidatorService } from './query-validator.service'; +import { COMPARISON_OPERATORS, JsonLogicOperatorEnum } from './types'; + +describe('QueryValidatorService', () => { + let queryValidatorService: QueryValidatorService; + + beforeEach(() => { + queryValidatorService = new QueryValidatorService(); + }); + + describe('validateQueryRules', () => { + it('should validate a invalid node structure', () => { + const rule: RulesLogic = null; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid node structure'); + expect(issues[0].path).to.deep.equal([]); + }); + + describe('logical operators', () => { + [JsonLogicOperatorEnum.AND, JsonLogicOperatorEnum.OR].forEach((operator) => { + it(`should validate valid ${operator} operation`, () => { + const rule: RulesLogic = { + [operator]: [{ '==': [{ var: 'field1' }, 'value1'] }, { '==': [{ var: 'field2' }, 'value2'] }], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it(`should detect invalid ${operator} structure`, () => { + const rule: any = { + [operator]: { '==': [{ var: 'field' }, 'value'] }, // Invalid: and should be an array + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include(`Invalid logical operator "${operator}"`); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + }); + + it('should validate NOT operation', () => { + const rule: RulesLogic = { + '!': { '==': [{ var: 'field' }, 'value'] }, + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it('should detect invalid NOT operation', () => { + const rule: RulesLogic = { + '!': { '==': [{ var: 'field' }, ''] }, + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + }); + + describe('in operation', () => { + it('should detect invalid array in operation', () => { + const rule: any = { + in: [], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid operation structure'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + + describe('"in" operation', () => { + it('should validate valid "in" operation', () => { + const rule: RulesLogic = { + in: [{ var: 'field' }, ['value1', 'value2']], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it('should detect invalid field reference in "in" operation', () => { + const rule: RulesLogic = { + in: [{}, [1, 2]], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid field reference in comparison'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + + it('should detect empty array in "in" operation', () => { + const rule: RulesLogic = { + in: [{ var: 'field' }, []], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + }); + + describe('"contains" operation', () => { + it('should validate valid "contains" operation', () => { + const rule: RulesLogic = { + in: ['search', { var: 'field' }], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it('should detect invalid field reference in "contains" operation', () => { + const rule: RulesLogic = { + in: ['search', {}], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid field reference in comparison'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + + it('should detect invalid value in "contains" operation', () => { + const rule: RulesLogic = { + in: ['', { var: 'field' }], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + }); + }); + + describe('between operation', () => { + it('should validate valid between operation', () => { + const rule: RulesLogic = { + '<=': [1, { var: 'field' }, 10], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it('should detect invalid between structure from lower bound', () => { + const rule: any = { + '<=': [undefined, { var: 'field' }, 10], // Missing lower bound + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + + it('should detect invalid between structure from upper bound', () => { + const rule: any = { + '<=': [1, { var: 'field' }, undefined], // Missing upper bound + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + + it('should detect invalid field reference in "contains" operation', () => { + const rule: any = { + '<=': [1, {}, 1], // invalid field reference + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid field reference in comparison'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + }); + + describe('comparison operators', () => { + COMPARISON_OPERATORS.forEach((operator) => { + it(`should validate a valid simple ${operator} rule`, () => { + const rule: RulesLogic = { + [operator]: [{ var: 'field' }, 'value'], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it(`should detect invalid ${operator} structure`, () => { + const rule: any = { + [operator]: [{ var: 'field' }], // Missing second operand + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid operation structure'); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + + it(`should detect invalid field reference in "${operator}" operation`, () => { + const rule: RulesLogic = { + [operator]: [{}, 'value'], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Invalid field reference in comparison'); + expect(issues[0].path).to.deep.equal([]); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.INVALID_STRUCTURE); + }); + }); + + it('should validate valid comparison operations', () => { + const validOperations: RulesLogic[] = [ + { '<': [{ var: 'field' }, 5] }, + { '>': [{ var: 'field' }, 5] }, + { '<=': [{ var: 'field' }, 5] }, + { '>=': [{ var: 'field' }, 5] }, + { '==': [{ var: 'field' }, 'value'] }, + { '!=': [{ var: 'field' }, 'value'] }, + ]; + + validOperations.forEach((operation) => { + const issues = queryValidatorService.validateQueryRules(operation); + expect(issues).to.be.empty; + }); + }); + + it('should handle null values correctly for isNull', () => { + const rule: RulesLogic = { + '==': [{ var: 'field' }, null], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it('should handle null values correctly for !isNull', () => { + const rule: RulesLogic = { + '!=': [{ var: 'field' }, null], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.be.empty; + }); + + it('should detect null values for non-equality operators', () => { + const rule: RulesLogic = { + '>': [{ var: 'field' }, null], + }; + + const issues = queryValidatorService.validateQueryRules(rule); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + }); + + describe('path calculation', () => { + const tests = [ + { + name: 'single rule', + rule: { + and: [ + { + '==': [ + { + var: 'subscriber.email', + }, + '', + ], + }, + ], + }, + path: [0], + }, + { + name: 'second rule', + rule: { + and: [ + { + '==': [ + { + var: 'subscriber.email', + }, + 'asdf', + ], + }, + { + '==': [ + { + var: 'subscriber.email', + }, + '', + ], + }, + ], + }, + path: [1], + }, + { + name: 'nested rule', + rule: { + and: [ + { + and: [ + { + '==': [ + { + var: 'subscriber.email', + }, + '', + ], + }, + ], + }, + ], + }, + path: [0, 0], + }, + { + name: 'nested second rule', + rule: { + and: [ + { + and: [ + { + '==': [ + { + var: 'subscriber.email', + }, + 'asdf', + ], + }, + { + '!=': [ + { + var: 'subscriber.email', + }, + undefined, + ], + }, + ], + }, + ], + }, + path: [0, 1], + }, + { + name: 'second or operator first rule', + rule: { + or: [ + { + and: [ + { + '==': [ + { + var: 'subscriber.email', + }, + 'asdf', + ], + }, + { + '!=': [ + { + var: 'subscriber.email', + }, + '22', + ], + }, + ], + }, + { + or: [ + { + '==': [ + { + var: 'subscriber.email', + }, + '', + ], + }, + ], + }, + ], + }, + path: [1, 0], + }, + { + name: 'nested not in operation', + rule: { + or: [ + { + and: [ + { + '==': [ + { + var: 'subscriber.email', + }, + 'asdf', + ], + }, + { + '!': { + in: [ + '', + { + var: 'subscriber.firstName', + }, + ], + }, + }, + ], + }, + ], + }, + path: [0, 1], + }, + ]; + + tests.forEach((test) => { + it(`should return the correct path for ${test.name}`, () => { + const { rule, path } = test; + + const issues = queryValidatorService.validateQueryRules(rule as any); + + expect(issues).to.have.lengthOf(1); + expect(issues[0].message).to.include('Value is required'); + expect(issues[0].path).to.deep.equal(path); + expect(issues[0].type).to.equal(QueryIssueTypeEnum.MISSING_VALUE); + }); + }); + }); + }); +}); diff --git a/apps/api/src/app/shared/services/query-parser/query-validator.service.ts b/apps/api/src/app/shared/services/query-parser/query-validator.service.ts new file mode 100644 index 00000000000..6d731884fd3 --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/query-validator.service.ts @@ -0,0 +1,251 @@ +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; + +import { COMPARISON_OPERATORS, JsonComparisonOperatorEnum, JsonLogicOperatorEnum } from './types'; + +type QueryIssue = { + message: string; + path: number[]; + type: QueryIssueTypeEnum; +}; + +export enum QueryIssueTypeEnum { + INVALID_STRUCTURE = 'INVALID_STRUCTURE', + MISSING_VALUE = 'MISSING_VALUE', +} + +export class QueryValidatorService { + private isInvalidFieldReference(field: unknown) { + return !field || typeof field !== 'object' || !('var' in field); + } + + private getLogicalOperatorIssue(operator: string, path: number[]): QueryIssue { + return { + message: `Invalid logical operator "${operator}" structure`, + path, + type: QueryIssueTypeEnum.INVALID_STRUCTURE, + }; + } + + private getFieldReferenceIssue(path: number[]): QueryIssue { + return { + message: 'Invalid field reference in comparison', + path, + type: QueryIssueTypeEnum.INVALID_STRUCTURE, + }; + } + + private getOperationIssue(operator: string, path: number[]): QueryIssue { + return { + message: `Invalid operation structure for operator "${operator}"`, + path, + type: QueryIssueTypeEnum.INVALID_STRUCTURE, + }; + } + + private getValueIssue(path: number[]): QueryIssue { + return { + message: 'Value is required', + path, + type: QueryIssueTypeEnum.MISSING_VALUE, + }; + } + + private validateNode({ + node, + issues, + path = [], + }: { + node: RulesLogic; + issues: QueryIssue[]; + path?: number[]; + }) { + if (!node || typeof node !== 'object') { + issues.push({ + message: 'Invalid node structure', + path, + type: QueryIssueTypeEnum.INVALID_STRUCTURE, + }); + + return; + } + + const entries = Object.entries(node); + + for (const [key, value] of entries) { + // handle logical operators "and" and "or" + if ([JsonLogicOperatorEnum.AND, JsonLogicOperatorEnum.OR].includes(key as JsonLogicOperatorEnum)) { + if (!Array.isArray(value)) { + issues.push(this.getLogicalOperatorIssue(key, path)); + continue; + } + + value.forEach((item, index) => this.validateNode({ node: item, issues, path: [...path, index] })); + continue; + } + + // handle negation '!' operator + if (key === JsonLogicOperatorEnum.NOT) { + this.validateNode({ node: value as RulesLogic, issues, path }); + continue; + } + + // handle 'in' and 'contains' operators + if (key === JsonComparisonOperatorEnum.IN) { + this.validateInOperation({ value, issues, path }); + continue; + } + + const isBetween = + key === JsonComparisonOperatorEnum.LESS_THAN_OR_EQUAL && Array.isArray(value) && value.length === 3; + if (isBetween) { + this.validateBetweenOperation({ value: value as [unknown, unknown, unknown], issues, path }); + continue; + } + + // handle the rest of the comparison operators + if (COMPARISON_OPERATORS.includes(key as JsonComparisonOperatorEnum)) { + this.validateComparisonOperation({ operator: key as JsonComparisonOperatorEnum, value, issues, path }); + continue; + } + + // handle field variable + if (key === 'var') { + if (!value) { + issues.push({ + message: 'Field variable is required', + path, + type: QueryIssueTypeEnum.MISSING_VALUE, + }); + } + + continue; + } + } + } + + private validateBetweenOperation({ + value, + issues, + path, + }: { + value: [unknown, unknown, unknown]; + issues: QueryIssue[]; + path: number[]; + }) { + const [lowerBound, field, upperBound] = value; + + if (this.isInvalidFieldReference(field)) { + issues.push(this.getFieldReferenceIssue(path)); + } + + const lowerBoundIsUndefined = lowerBound === undefined || lowerBound === null; + const upperBoundIsUndefined = upperBound === undefined || upperBound === null; + if (lowerBoundIsUndefined || upperBoundIsUndefined) { + issues.push(this.getValueIssue(path)); + } + } + + private validateComparisonOperation({ + operator, + value, + issues, + path, + }: { + operator: JsonComparisonOperatorEnum; + value: unknown; + issues: QueryIssue[]; + path: number[]; + }) { + if (!Array.isArray(value) || value.length !== 2) { + issues.push(this.getOperationIssue(operator, path)); + + return; + } + + const [field, comparisonValue] = value; + + // Validate field reference + if (this.isInvalidFieldReference(field)) { + issues.push(this.getFieldReferenceIssue(path)); + } + + // Validate comparison value exists + const valueIsUndefinedOrEmptyCase = + (comparisonValue === undefined || comparisonValue === '') && + [ + JsonComparisonOperatorEnum.EQUAL, + JsonComparisonOperatorEnum.NOT_EQUAL, + JsonComparisonOperatorEnum.LESS_THAN, + JsonComparisonOperatorEnum.GREATER_THAN, + JsonComparisonOperatorEnum.LESS_THAN_OR_EQUAL, + JsonComparisonOperatorEnum.GREATER_THAN_OR_EQUAL, + JsonComparisonOperatorEnum.STARTS_WITH, + JsonComparisonOperatorEnum.ENDS_WITH, + ].includes(operator); + const valueIsNullCase = + comparisonValue === null && + ![JsonComparisonOperatorEnum.EQUAL, JsonComparisonOperatorEnum.NOT_EQUAL].includes(operator); + + if (valueIsUndefinedOrEmptyCase || valueIsNullCase) { + issues.push(this.getValueIssue(path)); + } + + // Validate array for 'in' operations + const invalidComparisonValue = operator === 'in' && !Array.isArray(comparisonValue); + if (invalidComparisonValue) { + issues.push(this.getOperationIssue(operator, path)); + } + } + + /* + * in operator has field and the array as operands + * but as contains it has the search value and the field as operands + */ + private validateInOperation({ value, issues, path }: { value: unknown; issues: QueryIssue[]; path: number[] }) { + if (!Array.isArray(value) || value.length !== 2) { + issues.push(this.getOperationIssue('in', path)); + + return; + } + + const [firstOperand, secondOperand] = value; + const isContains = typeof firstOperand === 'string'; + + if (isContains) { + // Validate search value exists + const searchValueExists = firstOperand === undefined || firstOperand === ''; + if (searchValueExists) { + issues.push(this.getValueIssue(path)); + } + + // Validate field reference + const secondOperandInvalid = !secondOperand || typeof secondOperand !== 'object' || !('var' in secondOperand); + if (secondOperandInvalid) { + issues.push(this.getFieldReferenceIssue(path)); + } + } else { + // Validate field reference + const firstOperandInvalid = !firstOperand || typeof firstOperand !== 'object' || !('var' in firstOperand); + if (firstOperandInvalid) { + issues.push(this.getFieldReferenceIssue(path)); + } + + // Validate the in array is not empty + const secondOperandEmpty = + secondOperand === undefined || + secondOperand === null || + (Array.isArray(secondOperand) && secondOperand.length === 0); + if (secondOperandEmpty) { + issues.push(this.getValueIssue(path)); + } + } + } + + public validateQueryRules(node: RulesLogic): QueryIssue[] { + const issues: QueryIssue[] = []; + + this.validateNode({ node, issues }); + + return issues; + } +} diff --git a/apps/api/src/app/shared/services/query-parser/types.ts b/apps/api/src/app/shared/services/query-parser/types.ts new file mode 100644 index 00000000000..c9df38d780b --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/types.ts @@ -0,0 +1,35 @@ +export enum JsonComparisonOperatorEnum { + EQUAL = '==', + NOT_EQUAL = '!=', + GREATER_THAN = '>', + LESS_THAN = '<', + GREATER_THAN_OR_EQUAL = '>=', + LESS_THAN_OR_EQUAL = '<=', + IN = 'in', + STARTS_WITH = 'startsWith', + ENDS_WITH = 'endsWith', +} + +export enum JsonLogicOperatorEnum { + NOT = '!', + AND = 'and', + OR = 'or', +} + +export const COMPARISON_OPERATORS = [ + JsonComparisonOperatorEnum.EQUAL, + JsonComparisonOperatorEnum.NOT_EQUAL, + JsonComparisonOperatorEnum.GREATER_THAN, + JsonComparisonOperatorEnum.LESS_THAN, + JsonComparisonOperatorEnum.GREATER_THAN_OR_EQUAL, + JsonComparisonOperatorEnum.LESS_THAN_OR_EQUAL, + JsonComparisonOperatorEnum.IN, + JsonComparisonOperatorEnum.STARTS_WITH, + JsonComparisonOperatorEnum.ENDS_WITH, +] as const; + +export const LOGICAL_OPERATORS = [ + JsonLogicOperatorEnum.AND, + JsonLogicOperatorEnum.OR, + JsonLogicOperatorEnum.NOT, +] as const; diff --git a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts index 16cfa6a249d..7ff98a4f61f 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts @@ -1,3 +1,9 @@ +import merge from 'lodash/merge'; +import capitalize from 'lodash/capitalize'; +import isEmpty from 'lodash/isEmpty'; +import Ajv, { ErrorObject } from 'ajv'; +import addFormats from 'ajv-formats'; +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import { Injectable } from '@nestjs/common'; import { ControlValuesRepository } from '@novu/dal'; import { @@ -16,16 +22,16 @@ import { TierRestrictionsValidateCommand, dashboardSanitizeControlValues, PinoLogger, + Instrument, } from '@novu/application-generic'; -import merge from 'lodash/merge'; -import capitalize from 'lodash/capitalize'; -import isEmpty from 'lodash/isEmpty'; -import Ajv, { ErrorObject } from 'ajv'; -import addFormats from 'ajv-formats'; import { buildVariables } from '../../util/build-variables'; import { BuildVariableSchemaCommand, BuildVariableSchemaUsecase } from '../build-variable-schema'; import { BuildStepIssuesCommand } from './build-step-issues.command'; +import { + QueryIssueTypeEnum, + QueryValidatorService, +} from '../../../shared/services/query-parser/query-validator.service'; @Injectable() export class BuildStepIssuesUsecase { @@ -73,18 +79,29 @@ export class BuildStepIssuesUsecase { )?.controls; } - const sanitizedControlValues = - newControlValues && workflowOrigin === WorkflowOriginEnum.NOVU_CLOUD - ? dashboardSanitizeControlValues(this.logger, newControlValues, stepTypeDto) || {} - : this.frameworkSanitizeEmptyStringsToNull(newControlValues) || {}; - + const sanitizedControlValues = this.sanitizeControlValues(newControlValues, workflowOrigin, stepTypeDto); const schemaIssues = this.processControlValuesBySchema(controlSchema, sanitizedControlValues || {}); const liquidIssues = this.processControlValuesByLiquid(variableSchema, newControlValues || {}); const customIssues = await this.processControlValuesByCustomeRules(user, stepTypeDto, sanitizedControlValues || {}); + const skipLogicIssues = sanitizedControlValues?.skip + ? this.validateSkipField(sanitizedControlValues.skip as RulesLogic) + : {}; + + return merge(schemaIssues, liquidIssues, customIssues, skipLogicIssues); + } - return merge(schemaIssues, liquidIssues, customIssues); + @Instrument() + private sanitizeControlValues( + newControlValues: Record | undefined, + workflowOrigin: WorkflowOriginEnum, + stepTypeDto: StepTypeEnum + ) { + return newControlValues && workflowOrigin === WorkflowOriginEnum.NOVU_CLOUD + ? dashboardSanitizeControlValues(this.logger, newControlValues, stepTypeDto) || {} + : this.frameworkSanitizeEmptyStringsToNull(newControlValues) || {}; } + @Instrument() private processControlValuesByLiquid( variableSchema: JSONSchemaDto | undefined, controlValues: Record | null @@ -125,6 +142,7 @@ export class BuildStepIssuesUsecase { return issues; } + @Instrument() private processControlValuesBySchema( controlSchema: JSONSchemaDto | undefined, controlValues: Record | null @@ -167,6 +185,7 @@ export class BuildStepIssuesUsecase { return issues; } + @Instrument() private async processControlValuesByCustomeRules( user: UserSessionData, stepType: StepTypeEnum, @@ -257,4 +276,27 @@ export class BuildStepIssuesUsecase { return error.message || 'Invalid value'; } + + @Instrument() + private validateSkipField(skipLogic: RulesLogic): StepIssuesDto { + const issues: StepIssuesDto = {}; + + const queryValidatorService = new QueryValidatorService(); + const skipRulesIssues = queryValidatorService.validateQueryRules(skipLogic); + + if (skipRulesIssues.length > 0) { + issues.controls = { + skip: skipRulesIssues.map((issue) => ({ + issueType: + issue.type === QueryIssueTypeEnum.MISSING_VALUE + ? StepContentIssueEnum.MISSING_VALUE + : StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE, + message: issue.message, + variableName: issue.path.join('.'), + })), + }; + } + + return issues.controls?.skip.length ? issues : {}; + } } diff --git a/apps/dashboard/src/components/conditions-editor/conditions-editor-context.tsx b/apps/dashboard/src/components/conditions-editor/conditions-editor-context.tsx index 33c60d90ec4..8df1d3ef55f 100644 --- a/apps/dashboard/src/components/conditions-editor/conditions-editor-context.tsx +++ b/apps/dashboard/src/components/conditions-editor/conditions-editor-context.tsx @@ -5,8 +5,6 @@ import { ConditionsEditorContextType } from './types'; import { useDataRef } from '@/hooks/use-data-ref'; export const ConditionsEditorContext = createContext({ - query: { combinator: 'and', rules: [] }, - setQuery: () => {}, removeRuleOrGroup: () => {}, cloneRuleOrGroup: () => {}, getParentGroup: () => null, @@ -23,6 +21,7 @@ export function ConditionsEditorProvider({ }) { const queryRef = useDataRef(query); const queryChangeRef = useDataRef(onQueryChange); + const removeRuleOrGroup = useCallback( (path: Path) => { queryChangeRef.current(remove(queryRef.current, path)); @@ -37,11 +36,9 @@ export function ConditionsEditorProvider({ [queryChangeRef, queryRef] ); - const setQuery = useCallback((query: RuleGroupType) => queryChangeRef.current(query), [queryChangeRef]); - const getParentGroup = useCallback( (id?: string) => { - if (!id) return query; + if (!id) return queryRef.current; const findParent = (group: RuleGroupTypeAny): RuleGroupTypeAny | null => { for (const rule of group.rules) { @@ -60,14 +57,14 @@ export function ConditionsEditorProvider({ return null; }; - return findParent(query); + return findParent(queryRef.current); }, - [query] + [queryRef] ); const contextValue = useMemo( - () => ({ query, setQuery, removeRuleOrGroup, cloneRuleOrGroup, getParentGroup }), - [query, setQuery, removeRuleOrGroup, cloneRuleOrGroup, getParentGroup] + () => ({ removeRuleOrGroup, cloneRuleOrGroup, getParentGroup }), + [removeRuleOrGroup, cloneRuleOrGroup, getParentGroup] ); return {children}; diff --git a/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx b/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx index 45891273e96..d1b91002bf7 100644 --- a/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx +++ b/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx @@ -1,11 +1,9 @@ +import { useMemo } from 'react'; import { type Field, QueryBuilder, RuleGroupType } from 'react-querybuilder'; import 'react-querybuilder/dist/query-builder.css'; import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables'; -import { - ConditionsEditorProvider, - useConditionsEditorContext, -} from '@/components/conditions-editor/conditions-editor-context'; +import { ConditionsEditorProvider } from '@/components/conditions-editor/conditions-editor-context'; import { AddConditionAction } from '@/components/conditions-editor/add-condition-action'; import { AddGroupAction } from '@/components/conditions-editor/add-group-action'; import { OperatorSelector } from '@/components/conditions-editor/operator-selector'; @@ -20,43 +18,57 @@ const nestedGroupClassName = `[&.ruleGroup_.ruleGroup]:p-3 [&.ruleGroup_.ruleGro const ruleGroupClassName = `[&.ruleGroup]:[background:transparent] [&.ruleGroup]:[border:none] [&.ruleGroup]:p-0 ${nestedGroupClassName} [&_.ruleGroup-body_.rule]:items-start ${groupActionsClassName}`; const ruleClassName = `${ruleActionsClassName}`; -function InternalConditionsEditor({ fields, variables }: { fields: Field[]; variables: LiquidVariable[] }) { - const { query, setQuery } = useConditionsEditorContext(); +const controlClassnames = { + ruleGroup: ruleGroupClassName, + rule: ruleClassName, +}; + +const translations = { + addRule: { + label: 'Add condition', + title: 'Add condition', + }, + addGroup: { + label: 'Add group', + title: 'Add group', + }, +}; + +const controlElements = { + operatorSelector: OperatorSelector, + combinatorSelector: CombinatorSelector, + fieldSelector: FieldSelector, + valueEditor: ValueEditor, + addRuleAction: AddConditionAction, + addGroupAction: AddGroupAction, + removeGroupAction: RuleActions, + removeRuleAction: RuleActions, + cloneGroupAction: null, + cloneRuleAction: null, +}; + +function InternalConditionsEditor({ + fields, + variables, + query, + onQueryChange, +}: { + fields: Field[]; + variables: LiquidVariable[]; + query: RuleGroupType; + onQueryChange: (query: RuleGroupType) => void; +}) { + const context = useMemo(() => ({ variables }), [variables]); return ( ); } @@ -74,7 +86,7 @@ export function ConditionsEditor({ }) { return ( - + ); } diff --git a/apps/dashboard/src/components/conditions-editor/field-selector.tsx b/apps/dashboard/src/components/conditions-editor/field-selector.tsx index 1e9df5a122d..7ab493bc630 100644 --- a/apps/dashboard/src/components/conditions-editor/field-selector.tsx +++ b/apps/dashboard/src/components/conditions-editor/field-selector.tsx @@ -1,26 +1,37 @@ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { FieldSelectorProps } from 'react-querybuilder'; import { Code2 } from '@/components/icons/code-2'; import { VariableSelect } from '@/components/conditions-editor/variable-select'; -export const FieldSelector = ({ handleOnChange, options, ...restFieldSelectorProps }: FieldSelectorProps) => { - const optionsArray = useMemo( - () => - options.map((option) => ({ - label: option.label, - value: 'value' in option ? option.value : '', - })), - [options] - ); +export const FieldSelector = React.memo( + ({ handleOnChange, options, value, disabled }: FieldSelectorProps) => { + const optionsArray = useMemo( + () => + options.map((option) => ({ + label: option.label, + value: 'value' in option ? option.value : '', + })), + [options] + ); - return ( - } - onChange={handleOnChange} - options={optionsArray} - title="Fields" - {...restFieldSelectorProps} - /> - ); -}; + return ( + } + onChange={handleOnChange} + options={optionsArray} + title="Fields" + value={value} + disabled={disabled} + /> + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.value === nextProps.value && + prevProps.disabled === nextProps.disabled && + prevProps.options === nextProps.options && + prevProps.handleOnChange === nextProps.handleOnChange + ); + } +); diff --git a/apps/dashboard/src/components/conditions-editor/operator-selector.tsx b/apps/dashboard/src/components/conditions-editor/operator-selector.tsx index c1f6caf6d55..0140efca510 100644 --- a/apps/dashboard/src/components/conditions-editor/operator-selector.tsx +++ b/apps/dashboard/src/components/conditions-editor/operator-selector.tsx @@ -1,21 +1,32 @@ +import React from 'react'; import { OperatorSelectorProps } from 'react-querybuilder'; import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { cn } from '@/utils/ui'; import { toSelectOptions } from '@/components/conditions-editor/select-option-utils'; -export const OperatorSelector = ({ disabled, value, options, handleOnChange }: OperatorSelectorProps) => { - return ( - - ); -}; +export const OperatorSelector = React.memo( + ({ disabled, value, options, handleOnChange }: OperatorSelectorProps) => { + return ( + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.value === nextProps.value && + prevProps.disabled === nextProps.disabled && + prevProps.options === nextProps.options && + prevProps.handleOnChange === nextProps.handleOnChange + ); + } +); diff --git a/apps/dashboard/src/components/conditions-editor/rule-actions.tsx b/apps/dashboard/src/components/conditions-editor/rule-actions.tsx index 374bce58439..4a09d3a8af0 100644 --- a/apps/dashboard/src/components/conditions-editor/rule-actions.tsx +++ b/apps/dashboard/src/components/conditions-editor/rule-actions.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { isRuleGroup, ActionWithRulesProps, getParentPath } from 'react-querybuilder'; import { RiMore2Fill } from 'react-icons/ri'; @@ -14,40 +14,45 @@ import { CompactButton } from '@/components/primitives/button-compact'; import { Delete } from '@/components/icons/delete'; import { SquareTwoStack } from '@/components/icons/square-two-stack'; -export const RuleActions = ({ path, ruleOrGroup }: ActionWithRulesProps) => { - const { removeRuleOrGroup, cloneRuleOrGroup, getParentGroup } = useConditionsEditorContext(); - const parentGroup = useMemo(() => getParentGroup(ruleOrGroup.id), [ruleOrGroup.id, getParentGroup]); - const isGroup = isRuleGroup(ruleOrGroup); - const isDuplicateDisabled = !!(parentGroup && parentGroup.rules && parentGroup.rules.length >= 10); +export const RuleActions = React.memo( + ({ path, ruleOrGroup }: ActionWithRulesProps) => { + const { removeRuleOrGroup, cloneRuleOrGroup, getParentGroup } = useConditionsEditorContext(); + const parentGroup = useMemo(() => getParentGroup(ruleOrGroup.id), [ruleOrGroup.id, getParentGroup]); + const isGroup = isRuleGroup(ruleOrGroup); + const isDuplicateDisabled = !!(parentGroup && parentGroup.rules && parentGroup.rules.length >= 10); - return ( - - - - - - - { - cloneRuleOrGroup(ruleOrGroup, getParentPath(path)); - }} - className="text-foreground-600 text-label-xs h-7" - disabled={isDuplicateDisabled} - > - Duplicate {isGroup ? `group` : `condition`} - - removeRuleOrGroup(path)} className="text-error-base text-label-xs h-7"> - - Delete {isGroup ? `group` : `condition`} - - - - - ); -}; + return ( + + + + + + + { + cloneRuleOrGroup(ruleOrGroup, getParentPath(path)); + }} + className="text-foreground-600 text-label-xs h-7" + disabled={isDuplicateDisabled} + > + Duplicate {isGroup ? `group` : `condition`} + + removeRuleOrGroup(path)} className="text-error-base text-label-xs h-7"> + + Delete {isGroup ? `group` : `condition`} + + + + + ); + }, + (prevProps, nextProps) => { + return prevProps.path === nextProps.path && prevProps.ruleOrGroup === nextProps.ruleOrGroup; + } +); diff --git a/apps/dashboard/src/components/conditions-editor/types.ts b/apps/dashboard/src/components/conditions-editor/types.ts index 0271b279b7d..6c456cfaa32 100644 --- a/apps/dashboard/src/components/conditions-editor/types.ts +++ b/apps/dashboard/src/components/conditions-editor/types.ts @@ -1,8 +1,6 @@ -import { BaseOption, RuleGroupType, RuleGroupTypeAny, RuleType, Path } from 'react-querybuilder'; +import { BaseOption, RuleGroupTypeAny, RuleType, Path } from 'react-querybuilder'; export interface ConditionsEditorContextType { - query: RuleGroupType; - setQuery: (query: RuleGroupType) => void; removeRuleOrGroup: (path: Path) => void; cloneRuleOrGroup: (ruleOrGroup: RuleGroupTypeAny | RuleType, path?: Path) => void; getParentGroup: (id?: string) => RuleGroupTypeAny | null; diff --git a/apps/dashboard/src/components/conditions-editor/value-editor.tsx b/apps/dashboard/src/components/conditions-editor/value-editor.tsx index 63e6e75aac4..7c35e7aa257 100644 --- a/apps/dashboard/src/components/conditions-editor/value-editor.tsx +++ b/apps/dashboard/src/components/conditions-editor/value-editor.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useValueEditor, ValueEditorProps } from 'react-querybuilder'; import { useFormContext } from 'react-hook-form'; @@ -6,11 +5,11 @@ import { InputRoot, InputWrapper } from '@/components/primitives/input'; import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables'; import { ControlInput } from '../primitives/control-input/control-input'; -export const ValueEditor = React.memo((props: ValueEditorProps) => { +export const ValueEditor = (props: ValueEditorProps) => { const form = useFormContext(); const queryPath = 'query.rules.' + props.path.join('.rules.') + '.value'; const { error } = form.getFieldState(queryPath, form.formState); - const { variables } = props.context as { variables: LiquidVariable[] }; + const { variables = [] } = (props.context as { variables: LiquidVariable[] }) ?? {}; const { value, handleOnChange, operator, type } = props; const { valueAsArray, multiValueHandler } = useValueEditor(props); @@ -65,4 +64,4 @@ export const ValueEditor = React.memo((props: ValueEditorProps) => { {error && {error?.message}} ); -}); +}; diff --git a/apps/dashboard/src/components/conditions-editor/variable-select.tsx b/apps/dashboard/src/components/conditions-editor/variable-select.tsx index 0e89589609a..ac262c56990 100644 --- a/apps/dashboard/src/components/conditions-editor/variable-select.tsx +++ b/apps/dashboard/src/components/conditions-editor/variable-select.tsx @@ -44,7 +44,7 @@ const VariablesList = React.forwardRef( {options.map((option, index) => (
  • , onChange: (value: string) => void) { const [selectedVariable, setSelectedVariable] = useState(null); const isUpdatingRef = useRef(false); + const onChangeRef = useDataRef(onChange); const handleVariableSelect = useCallback((value: string, from: number, to: number) => { if (isUpdatingRef.current) return; @@ -62,7 +64,7 @@ export function useVariables(viewRef: React.RefObject, onChange: (va selection: { anchor: from + newVariableText.length }, }); - onChange(view.state.doc.toString()); + onChangeRef.current(view.state.doc.toString()); // Update the selected variable with new bounds setSelectedVariable(null); @@ -70,7 +72,7 @@ export function useVariables(viewRef: React.RefObject, onChange: (va isUpdatingRef.current = false; } }, - [selectedVariable, onChange, viewRef] + [selectedVariable, onChangeRef, viewRef] ); return { diff --git a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx index e80f57828e4..6ebb963dece 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useBlocker } from 'react-router-dom'; import { formatQuery, RQBJsonLogic, RuleGroupType, RuleType } from 'react-querybuilder'; import { useForm } from 'react-hook-form'; @@ -107,6 +107,20 @@ export const EditStepConditionsForm = () => { form.reset(values); }; + useEffect(() => { + if (!step) return; + + const stepConditionIssues = step.issues?.controls?.skip; + if (stepConditionIssues && stepConditionIssues.length > 0) { + stepConditionIssues.forEach((issue) => { + const queryPath = 'query.rules.' + issue.variableName?.split('.').join('.rules.') + '.value'; + form.setError(queryPath as keyof typeof form.formState.errors, { + message: issue.message, + }); + }); + } + }, [form, step]); + return ( <>
    diff --git a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx index 4ca271d0ac2..4dee3de0d93 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-layout.tsx @@ -22,7 +22,7 @@ export const EditStepConditionsLayout = ({ - Step conditions for — {stepName} + Skip conditions for — {stepName} {children} diff --git a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions.tsx b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions.tsx index 00e52420d8d..0a6de73beb2 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions.tsx @@ -24,11 +24,11 @@ export const EditStepConditions = () => { } return ( - +
    - Step Conditions + Skip Conditions
    { className="flex w-full justify-start gap-1.5 text-xs font-medium" > - Step Conditions + Skip Conditions {conditionsCount} From 763e6c819ece5eccd1fd5ab9d54fd38e1a68eb2c Mon Sep 17 00:00:00 2001 From: Aminul Islam <5006546+AminulBD@users.noreply.github.com> Date: Wed, 22 Jan 2025 18:16:15 +0600 Subject: [PATCH 12/25] fix(api-service): add missing environment variable (#7553) --- docker/community/.env.example | 3 +++ docker/community/docker-compose.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/docker/community/.env.example b/docker/community/.env.example index 5a4329b1a0d..af692cc591c 100644 --- a/docker/community/.env.example +++ b/docker/community/.env.example @@ -7,6 +7,9 @@ SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME="15 days" # used to encrypt/decrypt the provider credentials STORE_ENCRYPTION_KEY= +# Random generated secret key. +NOVU_SECRET_KEY= + # Host HOST_NAME=http://localhost diff --git a/docker/community/docker-compose.yml b/docker/community/docker-compose.yml index 3e9ce03d765..cafaaad0c3b 100644 --- a/docker/community/docker-compose.yml +++ b/docker/community/docker-compose.yml @@ -81,6 +81,7 @@ services: AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} JWT_SECRET: ${JWT_SECRET} STORE_ENCRYPTION_KEY: ${STORE_ENCRYPTION_KEY} + NOVU_SECRET_KEY: ${NOVU_SECRET_KEY} SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME: ${SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME} SENTRY_DSN: ${SENTRY_DSN} NEW_RELIC_APP_NAME: ${NEW_RELIC_APP_NAME} From 82e3270510401179f880160d7a64628acd82d0fa Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 22 Jan 2025 17:48:54 +0530 Subject: [PATCH 13/25] =?UTF-8?q?refactor(worker,=20application-generic):?= =?UTF-8?q?=20remove=20cloudwatch=20metrics=20serv=E2=80=A6=20(#7558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/src/.env.development | 2 - apps/worker/src/.env.production | 3 - apps/worker/src/.env.test | 2 - apps/worker/src/.example.env | 3 - apps/worker/src/config/env.validators.ts | 3 +- libs/application-generic/package.json | 5 +- .../src/modules/metrics.module.ts | 10 +- .../src/services/metrics/index.ts | 29 +---- .../services/metrics/metrics.service.spec.ts | 100 +----------------- .../src/services/metrics/metrics.service.ts | 54 ---------- pnpm-lock.yaml | 76 ------------- 11 files changed, 11 insertions(+), 276 deletions(-) diff --git a/apps/worker/src/.env.development b/apps/worker/src/.env.development index 14a55d5485f..9e21df14288 100644 --- a/apps/worker/src/.env.development +++ b/apps/worker/src/.env.development @@ -10,8 +10,6 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 # Storage Service # STORAGE_SERVICE= -# Metrics Service -# METRICS_SERVICE= # Redis REDIS_HOST=localhost diff --git a/apps/worker/src/.env.production b/apps/worker/src/.env.production index 227c2b91e8c..5f492fa3be0 100644 --- a/apps/worker/src/.env.production +++ b/apps/worker/src/.env.production @@ -13,9 +13,6 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 # Storage Service # STORAGE_SERVICE= -# Metrics Service -# METRICS_SERVICE= - # Redis # REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/apps/worker/src/.env.test b/apps/worker/src/.env.test index 7676c8ca8e6..fefe19d28c8 100644 --- a/apps/worker/src/.env.test +++ b/apps/worker/src/.env.test @@ -14,8 +14,6 @@ NOVU_SMS_INTEGRATION_SENDER=1234567890 # Storage Service # STORAGE_SERVICE= -# Metrics Service -# METRICS_SERVICE= # Redis REDIS_PORT=6379 diff --git a/apps/worker/src/.example.env b/apps/worker/src/.example.env index 3e936c036b1..35f826a00b4 100644 --- a/apps/worker/src/.example.env +++ b/apps/worker/src/.example.env @@ -12,9 +12,6 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 # Storage Service # STORAGE_SERVICE= -# Metrics Service -# METRICS_SERVICE= - # Redis REDIS_PORT=6379 REDIS_HOST=localhost diff --git a/apps/worker/src/config/env.validators.ts b/apps/worker/src/config/env.validators.ts index 837f9568f44..87981502841 100644 --- a/apps/worker/src/config/env.validators.ts +++ b/apps/worker/src/config/env.validators.ts @@ -1,5 +1,5 @@ -import { json, port, str, num, ValidatorSpec, makeValidator, bool, CleanedEnv, cleanEnv, url } from 'envalid'; import { DEFAULT_NOTIFICATION_RETENTION_DAYS, FeatureFlagsKeysEnum, StringifyEnv } from '@novu/shared'; +import { bool, CleanedEnv, cleanEnv, json, makeValidator, num, port, str, url, ValidatorSpec } from 'envalid'; export function validateEnv() { return cleanEnv(process.env, envValidators); @@ -31,7 +31,6 @@ export const envValidators = { MAX_NOVU_INTEGRATION_MAIL_REQUESTS: num({ default: 300 }), NOVU_EMAIL_INTEGRATION_API_KEY: str({ default: '' }), STORAGE_SERVICE: str({ default: undefined }), - METRICS_SERVICE: str({ default: '' }), REDIS_HOST: str(), REDIS_PORT: port(), REDIS_PASSWORD: str({ default: undefined }), diff --git a/libs/application-generic/package.json b/libs/application-generic/package.json index 844ad144e17..0ef5609e393 100644 --- a/libs/application-generic/package.json +++ b/libs/application-generic/package.json @@ -38,12 +38,12 @@ "reflect-metadata": "0.2.2" }, "dependencies": { - "@aws-sdk/client-cloudwatch": "^3.567.0", "@aws-sdk/client-s3": "^3.567.0", "@aws-sdk/s3-request-presigner": "^3.567.0", "@azure/storage-blob": "^12.11.0", "@google-cloud/storage": "^6.2.3", "@hokify/agenda": "^6.3.0", + "@maily-to/render": "^0.0.17", "@novu/dal": "workspace:*", "@novu/framework": "workspace:*", "@novu/providers": "workspace:*", @@ -74,6 +74,7 @@ "bullmq": "^3.10.2", "class-transformer": "0.5.1", "class-validator": "0.14.1", + "cron-parser": "^4.9.0", "date-fns": "^2.29.2", "got": "^11.8.6", "handlebars": "^4.7.7", @@ -83,7 +84,6 @@ "launchdarkly-node-server-sdk": "^7.0.1", "lodash": "^4.17.15", "mixpanel": "^0.17.0", - "cron-parser": "^4.9.0", "nanoid": "^3.1.20", "nestjs-otel": "6.1.1", "nestjs-pino": "4.1.0", @@ -96,7 +96,6 @@ "rxjs": "7.8.1", "sanitize-html": "^2.4.0", "shortid": "^2.2.16", - "@maily-to/render": "^0.0.17", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.3" }, diff --git a/libs/application-generic/src/modules/metrics.module.ts b/libs/application-generic/src/modules/metrics.module.ts index e04ed04672b..e1af4e185c6 100644 --- a/libs/application-generic/src/modules/metrics.module.ts +++ b/libs/application-generic/src/modules/metrics.module.ts @@ -1,18 +1,10 @@ import { Module, Provider } from '@nestjs/common'; import { MetricsService, metricsServiceList } from '../services/metrics'; -import { - AwsMetricsService, - AzureMetricsService, - GCPMetricsService, - NewRelicMetricsService, -} from '../services/metrics/metrics.service'; +import { NewRelicMetricsService } from '../services/metrics/metrics.service'; const PROVIDERS: Provider[] = [ MetricsService, NewRelicMetricsService, - GCPMetricsService, - AzureMetricsService, - AwsMetricsService, metricsServiceList, ]; diff --git a/libs/application-generic/src/services/metrics/index.ts b/libs/application-generic/src/services/metrics/index.ts index 240ec4a3f5d..35271bc5a8c 100644 --- a/libs/application-generic/src/services/metrics/index.ts +++ b/libs/application-generic/src/services/metrics/index.ts @@ -1,25 +1,9 @@ -import { - AzureMetricsService, - GCPMetricsService, - AwsMetricsService, - MetricsService, - NewRelicMetricsService, -} from './metrics.service'; +import { MetricsService, NewRelicMetricsService } from './metrics.service'; export const metricsServiceList = { provide: 'MetricsServices', - useFactory: ( - newRelicMetricsService: NewRelicMetricsService, - gcsMetricsService: GCPMetricsService, - azureMetricsService: AzureMetricsService, - awsMetricsService: AwsMetricsService, - ) => { - const allMetricsServices = [ - newRelicMetricsService, - gcsMetricsService, - azureMetricsService, - awsMetricsService, - ]; + useFactory: (newRelicMetricsService: NewRelicMetricsService) => { + const allMetricsServices = [newRelicMetricsService]; const activeMetricsServices = allMetricsServices.filter((service) => service.isActive(process.env), @@ -27,12 +11,7 @@ export const metricsServiceList = { return activeMetricsServices; }, - inject: [ - NewRelicMetricsService, - GCPMetricsService, - AzureMetricsService, - AwsMetricsService, - ], + inject: [NewRelicMetricsService], }; export { MetricsService }; diff --git a/libs/application-generic/src/services/metrics/metrics.service.spec.ts b/libs/application-generic/src/services/metrics/metrics.service.spec.ts index 1bbe0a69b41..d1f31b9c09b 100644 --- a/libs/application-generic/src/services/metrics/metrics.service.spec.ts +++ b/libs/application-generic/src/services/metrics/metrics.service.spec.ts @@ -1,13 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { - MetricsService, - NewRelicMetricsService, - AwsMetricsService, - GCPMetricsService, - AzureMetricsService, -} from './metrics.service'; import { metricsServiceList } from './index'; import { IMetricsService } from './metrics.interface'; +import { MetricsService, NewRelicMetricsService } from './metrics.service'; describe('MetricsService', () => { let service: MetricsService; @@ -17,28 +11,12 @@ describe('MetricsService', () => { providers: [ MetricsService, NewRelicMetricsService, - AwsMetricsService, - GCPMetricsService, - AzureMetricsService, { provide: 'MetricsServices', - useFactory: ( - newRelicMetricsService: NewRelicMetricsService, - gcsMetricsService: GCPMetricsService, - azureMetricsService: AzureMetricsService, - awsMetricsService: AwsMetricsService, - ) => [ + useFactory: (newRelicMetricsService: NewRelicMetricsService) => [ newRelicMetricsService, - gcsMetricsService, - azureMetricsService, - awsMetricsService, - ], - inject: [ - NewRelicMetricsService, - GCPMetricsService, - AzureMetricsService, - AwsMetricsService, ], + inject: [NewRelicMetricsService], }, ], }).compile(); @@ -59,19 +37,9 @@ describe('MetricsService', () => { NewRelicMetricsService.prototype, 'recordMetric', ); - const spyAws = jest.spyOn(AwsMetricsService.prototype, 'recordMetric'); - const spyGcs = jest.spyOn(GCPMetricsService.prototype, 'recordMetric'); - const spyAzure = jest.spyOn( - AzureMetricsService.prototype, - 'recordMetric', - ); - service.recordMetric(metricName, metricValue); expect(spyNewRelic).toHaveBeenCalledWith(metricName, metricValue); - expect(spyAws).toHaveBeenCalledWith(metricName, metricValue); - expect(spyGcs).toHaveBeenCalledWith(metricName, metricValue); - expect(spyAzure).toHaveBeenCalledWith(metricName, metricValue); }); }); @@ -83,9 +51,6 @@ describe('MetricsService', () => { metricsServiceList, MetricsService, NewRelicMetricsService, - AwsMetricsService, - GCPMetricsService, - AzureMetricsService, ], }).compile()) as TestingModule ).get('MetricsServices'); @@ -104,64 +69,5 @@ describe('MetricsService', () => { delete process.env.NEW_RELIC_LICENSE_KEY; }); }); - - describe('AWS', () => { - it('should contain AwsMetricsService if METRICS_SERVICE is set to AWS and Credentials and Region are set', async () => { - process.env.METRICS_SERVICE = 'AWS'; - process.env.AWS_ACCESS_KEY_ID = 'test'; - process.env.AWS_SECRET_ACCESS_KEY = 'test'; - process.env.AWS_REGION = 'test'; - const metricsServices = await createServices(); - - expect( - metricsServices.some( - (metricsService) => metricsService instanceof AwsMetricsService, - ), - ).toBe(true); - delete process.env.METRICS_SERVICE; - delete process.env.AWS_ACCESS_KEY_ID; - delete process.env.AWS_SECRET_ACCESS_KEY; - delete process.env.AWS_REGION; - }); - - it('should throw an error if METRICS_SERVICE is set to AWS and Credentials or Region are not set', async () => { - process.env.METRICS_SERVICE = 'AWS'; - delete process.env.AWS_ACCESS_KEY_ID; - delete process.env.AWS_SECRET_ACCESS_KEY; - delete process.env.AWS_REGION; - await expect(createServices()).rejects.toThrow( - 'Missing AWS credentials', - ); - delete process.env.METRICS_SERVICE; - }); - }); - - describe('GCP', () => { - it('should contain GCPMetricsService if METRICS_SERVICE is set to GCS', async () => { - process.env.METRICS_SERVICE = 'GCP'; - const metricsServices = await createServices(); - - expect( - metricsServices.some( - (metricsService) => metricsService instanceof GCPMetricsService, - ), - ).toBe(true); - delete process.env.METRICS_SERVICE; - }); - }); - - describe('Azure', () => { - it('should contain AzureMetricsService if METRICS_SERVICE is set to AZURE', async () => { - process.env.METRICS_SERVICE = 'AZURE'; - const metricsServices = await createServices(); - - expect( - metricsServices.some( - (metricsService) => metricsService instanceof AzureMetricsService, - ), - ).toBe(true); - delete process.env.METRICS_SERVICE; - }); - }); }); }); diff --git a/libs/application-generic/src/services/metrics/metrics.service.ts b/libs/application-generic/src/services/metrics/metrics.service.ts index 5f88b42d27e..43d60ccd609 100644 --- a/libs/application-generic/src/services/metrics/metrics.service.ts +++ b/libs/application-generic/src/services/metrics/metrics.service.ts @@ -1,13 +1,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; -import { - CloudWatchClient, - PutMetricDataCommand, -} from '@aws-sdk/client-cloudwatch'; import { IMetricsService } from './metrics.interface'; const nr = require('newrelic'); -const NAMESPACE = 'Novu'; const LOG_CONTEXT = 'MetricsService'; @Injectable() @@ -46,52 +41,3 @@ export class NewRelicMetricsService implements IMetricsService { return !!env.NEW_RELIC_LICENSE_KEY; } } - -@Injectable() -export class AwsMetricsService implements IMetricsService { - private client = new CloudWatchClient(); - - async recordMetric(name: string, value: number): Promise { - const command = new PutMetricDataCommand({ - Namespace: NAMESPACE, - MetricData: [{ MetricName: name, Value: value, StorageResolution: 1 }], - }); - await this.client.send(command); - } - - isActive(env: Record): boolean { - if (env.METRICS_SERVICE === 'AWS') { - if ( - !!env.AWS_ACCESS_KEY_ID && - !!env.AWS_SECRET_ACCESS_KEY && - !!env.AWS_REGION - ) { - return true; - } else { - throw new Error('Missing AWS credentials'); - } - } - } -} - -@Injectable() -export class GCPMetricsService implements IMetricsService { - async recordMetric(name: string, value: number): Promise { - throw new Error('Method not implemented.'); - } - - isActive(env: Record): boolean { - return env.METRICS_SERVICE === 'GCP'; - } -} - -@Injectable() -export class AzureMetricsService implements IMetricsService { - async recordMetric(key: string, value: number): Promise { - throw new Error('Method not implemented.'); - } - - isActive(env: Record): boolean { - return env.METRICS_SERVICE === 'AZURE'; - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca3b87f5de..4ff7d5fc0d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2640,9 +2640,6 @@ importers: libs/application-generic: dependencies: - '@aws-sdk/client-cloudwatch': - specifier: ^3.567.0 - version: 3.575.0 '@aws-sdk/client-s3': specifier: ^3.567.0 version: 3.575.0 @@ -4924,10 +4921,6 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-cloudwatch@3.575.0': - resolution: {integrity: sha512-BGwMHWm6hQiGPoIBcPJRcfAjiU1D5qx+tkwf9bhnFTEkKNaPupfDTwz9aUmKDHQTOLyPRKuhGNOuuLNhzipPqA==} - engines: {node: '>=16.0.0'} - '@aws-sdk/client-cognito-identity@3.504.0': resolution: {integrity: sha512-WsQY6CRDC9Y1rKjpsk187EHKES6nLmM9sD6iHAKZFLhi/DiYsy8SIafPFPEvluubYlheeLzgUB8Oxpj6Z69hlA==} engines: {node: '>=14.0.0'} @@ -14679,10 +14672,6 @@ packages: '@smithy/md5-js@3.0.0': resolution: {integrity: sha512-Tm0vrrVzjlD+6RCQTx7D3Ls58S3FUH1ZCtU1MIh/qQmaOo1H9lMN2as6CikcEwgattnA9SURSdoJJ27xMcEfMA==} - '@smithy/middleware-compression@3.0.0': - resolution: {integrity: sha512-ZOIYISXy3cNyy/yddpmVrpo/MR7wPqPgzwwuR9ZpQrTx0PnzLOzESFTj+Vg0Ev6uh/7XhoCbOnJukrq5y48TAw==} - engines: {node: '>=16.0.0'} - '@smithy/middleware-content-length@2.0.3': resolution: {integrity: sha512-2FiZ5vu2+iMRL8XWNaREUqqNHjtBubaY9Jb2b3huZ9EbgrXsJfCszK6PPidHTLe+B4T7AISqdF4ZSp9VPXuelg==} engines: {node: '>=14.0.0'} @@ -23520,9 +23509,6 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} - fflate@0.8.1: - resolution: {integrity: sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==} - fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -36374,54 +36360,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.7.0 - '@aws-sdk/client-cloudwatch@3.575.0': - dependencies: - '@aws-crypto/sha256-browser': 3.0.0 - '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) - '@aws-sdk/core': 3.575.0 - '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) - '@aws-sdk/middleware-host-header': 3.575.0 - '@aws-sdk/middleware-logger': 3.575.0 - '@aws-sdk/middleware-recursion-detection': 3.575.0 - '@aws-sdk/middleware-user-agent': 3.575.0 - '@aws-sdk/region-config-resolver': 3.575.0 - '@aws-sdk/types': 3.575.0 - '@aws-sdk/util-endpoints': 3.575.0 - '@aws-sdk/util-user-agent-browser': 3.575.0 - '@aws-sdk/util-user-agent-node': 3.575.0 - '@smithy/config-resolver': 3.0.0 - '@smithy/core': 2.0.0 - '@smithy/fetch-http-handler': 3.0.0 - '@smithy/hash-node': 3.0.0 - '@smithy/invalid-dependency': 3.0.0 - '@smithy/middleware-compression': 3.0.0 - '@smithy/middleware-content-length': 3.0.0 - '@smithy/middleware-endpoint': 3.0.0 - '@smithy/middleware-retry': 3.0.0 - '@smithy/middleware-serde': 3.0.0 - '@smithy/middleware-stack': 3.0.0 - '@smithy/node-config-provider': 3.0.0 - '@smithy/node-http-handler': 3.0.0 - '@smithy/protocol-http': 4.0.0 - '@smithy/smithy-client': 3.0.0 - '@smithy/types': 3.0.0 - '@smithy/url-parser': 3.0.0 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.0 - '@smithy/util-defaults-mode-node': 3.0.0 - '@smithy/util-endpoints': 2.0.0 - '@smithy/util-middleware': 3.0.0 - '@smithy/util-retry': 3.0.0 - '@smithy/util-utf8': 3.0.0 - '@smithy/util-waiter': 3.0.0 - tslib: 2.6.2 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-cognito-identity@3.504.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 @@ -53654,18 +53592,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 - '@smithy/middleware-compression@3.0.0': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - '@smithy/node-config-provider': 3.0.0 - '@smithy/protocol-http': 4.0.0 - '@smithy/types': 3.0.0 - '@smithy/util-config-provider': 3.0.0 - '@smithy/util-middleware': 3.0.0 - '@smithy/util-utf8': 3.0.0 - fflate: 0.8.1 - tslib: 2.7.0 - '@smithy/middleware-content-length@2.0.3': dependencies: '@smithy/protocol-http': 2.0.3 @@ -67335,8 +67261,6 @@ snapshots: fflate@0.4.8: {} - fflate@0.8.1: {} - fflate@0.8.2: {} figures@1.7.0: From 6e3a5d843f6a48e90ff50cb4defac787612d107c Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 22 Jan 2025 15:48:31 +0200 Subject: [PATCH 14/25] fix(dashboard): fix issues on environment management NV-5232 (#7559) --- .../create-environment.usecase.ts | 5 ++- .../update-environment.usecase.ts | 10 ++++- .../components/create-environment-button.tsx | 1 + .../src/components/edit-environment-sheet.tsx | 21 ++++++--- .../src/components/environments-list.tsx | 2 +- .../src/components/primitives/button.tsx | 4 +- .../components/primitives/color-picker.tsx | 44 ++++++++++++------- 7 files changed, 56 insertions(+), 31 deletions(-) diff --git a/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts b/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts index 35e675291eb..f9693fa2e2b 100644 --- a/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/create-environment/create-environment.usecase.ts @@ -30,11 +30,12 @@ export class CreateEnvironment { if (environmentCount >= 10) { throw new BadRequestException('Organization cannot have more than 10 environments'); } + const normalizedName = command.name.trim(); if (!command.system) { const { name } = command; - if (PROTECTED_ENVIRONMENTS.includes(name as EnvironmentEnum)) { + if (PROTECTED_ENVIRONMENTS?.map((env) => env.toLowerCase()).includes(normalizedName.toLowerCase())) { throw new UnprocessableEntityException('Environment name cannot be Development or Production'); } @@ -59,7 +60,7 @@ export class CreateEnvironment { const environment = await this.environmentRepository.create({ _organizationId: command.organizationId, - name: command.name, + name: normalizedName, identifier: nanoid(12), _parentId: command.parentEnvironmentId, color, diff --git a/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts b/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts index 93c5888ab71..417b353983d 100644 --- a/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/update-environment/update-environment.usecase.ts @@ -1,5 +1,6 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal'; +import { PROTECTED_ENVIRONMENTS } from '@novu/shared'; import { UpdateEnvironmentCommand } from './update-environment.command'; @Injectable() @@ -19,7 +20,12 @@ export class UpdateEnvironment { const updatePayload: Partial = {}; if (command.name && command.name !== '') { - updatePayload.name = command.name; + const normalizedName = command.name.trim(); + if (PROTECTED_ENVIRONMENTS?.map((env) => env.toLowerCase()).includes(normalizedName.toLowerCase())) { + throw new UnprocessableEntityException('Environment name cannot be Development or Production'); + } + + updatePayload.name = normalizedName; } if (command._parentId && command.name !== '') { updatePayload._parentId = command._parentId; diff --git a/apps/dashboard/src/components/create-environment-button.tsx b/apps/dashboard/src/components/create-environment-button.tsx index 5316b7bcb3a..8246717fe8e 100644 --- a/apps/dashboard/src/components/create-environment-button.tsx +++ b/apps/dashboard/src/components/create-environment-button.tsx @@ -201,6 +201,7 @@ export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) =>
  • - ) -); +const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( + +)); TableHeader.displayName = 'TableHeader'; -const TableBody = React.forwardRef>( - ({ className, ...props }, ref) => +const TableHead = React.forwardRef( + ({ className, children, sortable, sortDirection, onSort, ...props }, ref) => { + const content = ( +
    + {children} + {sortable && ( + <> + {sortDirection === 'asc' && } + {sortDirection === 'desc' && } + {!sortDirection && } + + )} +
    + ); + + return ( +
    + ); + } ); +TableHead.displayName = 'TableHead'; + +const TableBody = React.forwardRef(({ className, ...props }, ref) => ( + +)); TableBody.displayName = 'TableBody'; -const TableFooter = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ) -); +const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( + +)); TableFooter.displayName = 'TableFooter'; -const TableRow = React.forwardRef>( - ({ className, ...props }, ref) => ( - td]:border-neutral-alpha-100 [&>td]:border-b [&>td]:last-of-type:border-0', className)} - {...props} - /> - ) -); +const TableRow = React.forwardRef(({ className, ...props }, ref) => ( + td]:border-neutral-alpha-100 [&>td]:border-b [&>td]:last-of-type:border-0', className)} + {...props} + /> +)); TableRow.displayName = 'TableRow'; -const TableHead = React.forwardRef>( - ({ className, ...props }, ref) => ( -
    [role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + > + {sortable ? ( +
    + {content} +
    + ) : ( + content + )} +
    [role=checkbox]]:translate-y-[2px]', - className - )} - {...props} - /> - ) -); -TableHead.displayName = 'TableHead'; - export const tableCellVariants = cva(`px-6 py-2 align-middle`); -const TableCell = React.forwardRef>( - ({ className, ...props }, ref) => -); + +const TableCell = React.forwardRef(({ className, ...props }, ref) => ( + +)); TableCell.displayName = 'TableCell'; export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow }; diff --git a/apps/dashboard/src/components/workflow-list-empty.tsx b/apps/dashboard/src/components/workflow-list-empty.tsx index 2fdd171ff38..9d292883135 100644 --- a/apps/dashboard/src/components/workflow-list-empty.tsx +++ b/apps/dashboard/src/components/workflow-list-empty.tsx @@ -2,14 +2,23 @@ import { VersionControlDev } from '@/components/icons/version-control-dev'; import { VersionControlProd } from '@/components/icons/version-control-prod'; import { Button } from '@/components/primitives/button'; import { useEnvironment } from '@/context/environment/hooks'; -import { RiBookMarkedLine, RiRouteFill } from 'react-icons/ri'; +import { RiBookMarkedLine, RiRouteFill, RiSearchLine } from 'react-icons/ri'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { buildRoute, ROUTES } from '../utils/routes'; import { LinkButton } from './primitives/button-link'; -export const WorkflowListEmpty = () => { +interface WorkflowListEmptyProps { + emptySearchResults?: boolean; + onClearFilters?: () => void; +} + +export const WorkflowListEmpty = ({ emptySearchResults, onClearFilters }: WorkflowListEmptyProps) => { const { currentEnvironment, switchEnvironment, oppositeEnvironment } = useEnvironment(); + if (emptySearchResults) { + return ; + } + const isProd = currentEnvironment?.name === 'Production'; return isProd ? ( @@ -19,6 +28,25 @@ export const WorkflowListEmpty = () => { ); }; +const NoResultsFound = ({ onClearFilters }: { onClearFilters?: () => void }) => ( +
    +
    + +
    +
    + No workflows found +

    + We couldn't find any workflows matching your search criteria. +

    +
    + {onClearFilters && ( + + )} +
    +); + const WorkflowListEmptyProd = ({ switchToDev }: { switchToDev: () => void }) => (
    diff --git a/apps/dashboard/src/components/workflow-list.tsx b/apps/dashboard/src/components/workflow-list.tsx index 1c11a6dcbf4..665a876a656 100644 --- a/apps/dashboard/src/components/workflow-list.tsx +++ b/apps/dashboard/src/components/workflow-list.tsx @@ -16,15 +16,30 @@ import { RiMore2Fill } from 'react-icons/ri'; import { createSearchParams, useLocation, useSearchParams } from 'react-router-dom'; import { ServerErrorPage } from './shared/server-error-page'; +export type SortableColumn = 'name' | 'updatedAt'; + interface WorkflowListProps { data?: ListWorkflowResponse; - isPending?: boolean; + isLoading?: boolean; isError?: boolean; limit?: number; + orderBy?: SortableColumn; + orderDirection?: 'asc' | 'desc'; + hasActiveFilters?: boolean; + onClearFilters?: () => void; } -export function WorkflowList({ data, isPending, isError, limit = 12 }: WorkflowListProps) { - const [searchParams] = useSearchParams(); +export function WorkflowList({ + data, + isLoading, + isError, + limit = 12, + orderBy, + orderDirection, + hasActiveFilters, + onClearFilters, +}: WorkflowListProps) { + const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const hrefFromOffset = (offset: number) => { @@ -36,10 +51,17 @@ export function WorkflowList({ data, isPending, isError, limit = 12 }: WorkflowL const offset = parseInt(searchParams.get('offset') || '0'); + const toggleSort = (column: SortableColumn) => { + const newDirection = column === orderBy ? (orderDirection === 'desc' ? 'asc' : 'desc') : 'desc'; + searchParams.set('orderDirection', newDirection); + searchParams.set('orderBy', column); + setSearchParams(searchParams); + }; + if (isError) return ; - if (!isPending && data?.totalCount === 0) { - return ; + if (!isLoading && data?.totalCount === 0) { + return ; } const currentPage = Math.floor(offset / limit) + 1; @@ -50,16 +72,28 @@ export function WorkflowList({ data, isPending, isError, limit = 12 }: WorkflowL - Workflows + toggleSort('name')} + > + Workflows + Status Steps Tags - Last updated + toggleSort('updatedAt')} + > + Last updated + - {isPending ? ( + {isLoading ? ( <> {new Array(limit).fill(0).map((_, index) => ( diff --git a/apps/dashboard/src/hooks/use-fetch-workflows.ts b/apps/dashboard/src/hooks/use-fetch-workflows.ts index a28fc3bad1b..a8082124317 100644 --- a/apps/dashboard/src/hooks/use-fetch-workflows.ts +++ b/apps/dashboard/src/hooks/use-fetch-workflows.ts @@ -1,20 +1,28 @@ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { getWorkflows } from '@/api/workflows'; import { QueryKeys } from '@/utils/query-keys'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useEnvironment } from '../context/environment/hooks'; interface UseWorkflowsParams { limit?: number; offset?: number; query?: string; + orderBy?: string; + orderDirection?: 'asc' | 'desc'; } -export function useFetchWorkflows({ limit = 12, offset = 0, query = '' }: UseWorkflowsParams = {}) { +export function useFetchWorkflows({ + limit = 12, + offset = 0, + query = '', + orderBy = '', + orderDirection = 'desc', +}: UseWorkflowsParams = {}) { const { currentEnvironment } = useEnvironment(); const workflowsQuery = useQuery({ - queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id, { limit, offset, query }], - queryFn: () => getWorkflows({ environment: currentEnvironment!, limit, offset, query }), + queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id, { limit, offset, query, orderBy, orderDirection }], + queryFn: () => getWorkflows({ environment: currentEnvironment!, limit, offset, query, orderBy, orderDirection }), placeholderData: keepPreviousData, enabled: !!currentEnvironment?._id, refetchOnWindowFocus: true, diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index 69c752e649e..3246a2f3de5 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -2,14 +2,24 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { OptInModal } from '@/components/opt-in-modal'; import { PageMeta } from '@/components/page-meta'; import { Button } from '@/components/primitives/button'; +import { Input } from '@/components/primitives/input'; import { ScrollArea, ScrollBar } from '@/components/primitives/scroll-area'; +import { useDebounce } from '@/hooks/use-debounce'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useFetchWorkflows } from '@/hooks/use-fetch-workflows'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; import { FeatureFlagsKeysEnum, StepTypeEnum } from '@novu/shared'; import { useEffect } from 'react'; -import { RiArrowDownSLine, RiArrowRightSLine, RiFileAddLine, RiFileMarkedLine, RiRouteFill } from 'react-icons/ri'; +import { useForm } from 'react-hook-form'; +import { + RiArrowDownSLine, + RiArrowRightSLine, + RiFileAddLine, + RiFileMarkedLine, + RiRouteFill, + RiSearchLine, +} from 'react-icons/ri'; import { Outlet, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ButtonGroupItem, ButtonGroupRoot } from '../components/primitives/button-group'; import { LinkButton } from '../components/primitives/button-link'; @@ -19,18 +29,58 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../components/primitives/dropdown-menu'; +import { Form, FormField, FormItem } from '../components/primitives/form/form'; import { getTemplates, WorkflowTemplate } from '../components/template-store/templates'; import { WorkflowCard } from '../components/template-store/workflow-card'; import { WorkflowTemplateModal } from '../components/template-store/workflow-template-modal'; -import { WorkflowList } from '../components/workflow-list'; +import { SortableColumn, WorkflowList } from '../components/workflow-list'; import { buildRoute, ROUTES } from '../utils/routes'; +interface WorkflowFilters { + query: string; +} + export const WorkflowsPage = () => { const { environmentSlug } = useParams(); const track = useTelemetry(); const navigate = useNavigate(); - const [searchParams] = useSearchParams(); const isTemplateStoreEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_TEMPLATE_STORE_ENABLED); + const [searchParams, setSearchParams] = useSearchParams({ + orderDirection: 'desc', + orderBy: 'updatedAt', + query: '', + }); + const form = useForm({ + defaultValues: { + query: searchParams.get('query') || '', + }, + }); + + const updateSearchParam = (value: string) => { + if (value) { + searchParams.set('query', value); + } else { + searchParams.delete('query'); + } + setSearchParams(searchParams); + }; + + const debouncedSearch = useDebounce((value: string) => updateSearchParam(value), 500); + + const clearFilters = () => { + form.reset({ query: '' }); + }; + + useEffect(() => { + const subscription = form.watch((value: { query?: string }) => { + debouncedSearch(value.query || ''); + }); + + return () => { + subscription.unsubscribe(); + debouncedSearch.cancel(); + }; + }, [form, debouncedSearch]); const templates = getTemplates(); const popularTemplates = templates.filter((template) => template.isPopular).slice(0, 4); @@ -44,9 +94,15 @@ export const WorkflowsPage = () => { } = useFetchWorkflows({ limit, offset, + orderBy: searchParams.get('orderBy') as SortableColumn, + orderDirection: searchParams.get('orderDirection') as 'asc' | 'desc', + query: searchParams.get('query') || '', }); - const shouldShowStartWith = isTemplateStoreEnabled && workflowsData && workflowsData.totalCount < 5; + const hasActiveFilters = searchParams.get('query') && searchParams.get('query') !== null; + + const shouldShowStartWith = + isTemplateStoreEnabled && workflowsData && workflowsData.totalCount < 5 && !hasActiveFilters; useEffect(() => { track(TelemetryEvent.WORKFLOWS_PAGE_VISIT); @@ -70,7 +126,19 @@ export const WorkflowsPage = () => {
    -
    + + + ( + + + + )} + /> + + {isTemplateStoreEnabled ? ( @@ -194,7 +262,16 @@ export const WorkflowsPage = () => {
    {shouldShowStartWith &&
    Your Workflows
    } - +
    diff --git a/libs/application-generic/src/commands/project.command.ts b/libs/application-generic/src/commands/project.command.ts index 9e18e7ba316..fb8bda7cdc6 100644 --- a/libs/application-generic/src/commands/project.command.ts +++ b/libs/application-generic/src/commands/project.command.ts @@ -75,7 +75,7 @@ export abstract class PaginatedListCommand extends EnvironmentWithUserObjectComm @IsDefined() @IsString() - orderByField: string; + orderBy: string; } export abstract class EnvironmentWithSubscriber extends BaseCommand { diff --git a/libs/dal/src/repositories/notification-template/notification-template.repository.ts b/libs/dal/src/repositories/notification-template/notification-template.repository.ts index e9e23fe18cd..6718962f076 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.repository.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.repository.ts @@ -1,12 +1,13 @@ import { FilterQuery } from 'mongoose'; import { SoftDeleteModel } from 'mongoose-delete'; -import { BaseRepository } from '../base-repository'; -import { NotificationTemplate } from './notification-template.schema'; -import { NotificationTemplateDBModel, NotificationTemplateEntity } from './notification-template.entity'; +import { DirectionEnum } from '@novu/shared'; import { DalException } from '../../shared'; import type { EnforceEnvOrOrgIds } from '../../types/enforce'; +import { BaseRepository } from '../base-repository'; import { EnvironmentRepository } from '../environment'; +import { NotificationTemplateDBModel, NotificationTemplateEntity } from './notification-template.entity'; +import { NotificationTemplate } from './notification-template.schema'; type NotificationTemplateQuery = FilterQuery & EnforceEnvOrOrgIds; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -194,7 +195,9 @@ export class NotificationTemplateRepository extends BaseRepository< skip: number = 0, limit: number = 10, query?: string, - excludeNewDashboardWorkflows: boolean = false + excludeNewDashboardWorkflows: boolean = false, + orderBy: string = 'createdAt', + orderDirection: DirectionEnum = DirectionEnum.DESC ): Promise<{ totalCount: number; data: NotificationTemplateEntity[] }> { const searchQuery: FilterQuery = {}; @@ -219,7 +222,7 @@ export class NotificationTemplateRepository extends BaseRepository< _organizationId: organizationId, ...searchQuery, }) - .sort({ createdAt: -1 }) + .sort({ [orderBy]: orderDirection === DirectionEnum.ASC ? 1 : -1 }) .skip(skip) .limit(limit) .populate({ path: 'notificationGroup' }) diff --git a/packages/shared/src/clients/workflows-client.ts b/packages/shared/src/clients/workflows-client.ts index 1efbbfcd439..79ac6198fa1 100644 --- a/packages/shared/src/clients/workflows-client.ts +++ b/packages/shared/src/clients/workflows-client.ts @@ -1,4 +1,3 @@ -import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client'; import { CreateWorkflowDto, GeneratePreviewRequestDto, @@ -13,6 +12,7 @@ import { WorkflowResponseDto, WorkflowTestDataResponseDto, } from '../dto'; +import { createNovuBaseClient, HttpError, NovuRestResult } from './novu-base-client'; export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) => { const baseClient = createNovuBaseClient(baseUrl, headers); @@ -76,8 +76,8 @@ export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {}) if (queryParams.orderDirection) { query.append('orderDirection', queryParams.orderDirection); } - if (queryParams.orderByField) { - query.append('orderByField', queryParams.orderByField); + if (queryParams.orderBy) { + query.append('orderBy', queryParams.orderBy); } if (queryParams.query) { query.append('query', queryParams.query); diff --git a/packages/shared/src/dto/workflows/get-list-query-params.ts b/packages/shared/src/dto/workflows/get-list-query-params.ts index d6b6434bedd..a69afe16764 100644 --- a/packages/shared/src/dto/workflows/get-list-query-params.ts +++ b/packages/shared/src/dto/workflows/get-list-query-params.ts @@ -1,6 +1,6 @@ -import { WorkflowResponseDto } from './workflow.dto'; import { LimitOffsetPaginationDto } from '../../types'; +import { WorkflowResponseDto } from './workflow.dto'; -export class GetListQueryParams extends LimitOffsetPaginationDto { +export class GetListQueryParams extends LimitOffsetPaginationDto { query?: string; } diff --git a/packages/shared/src/types/response.ts b/packages/shared/src/types/response.ts index 662c2f7e6e3..ae7ce209f54 100644 --- a/packages/shared/src/types/response.ts +++ b/packages/shared/src/types/response.ts @@ -22,7 +22,7 @@ export class LimitOffsetPaginationDto> { limit: string; offset: string; orderDirection?: DirectionEnum; - orderByField?: K; + orderBy?: K; } export interface IPaginationParams { From f9067c548e85d1eaa77d0d05a89773b1d48e1418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Thu, 23 Jan 2025 11:53:07 +0100 Subject: [PATCH 20/25] chore(api-service,dashboard): step condition rules compile the liquid template (#7547) --- apps/api/src/app/bridge/bridge.module.ts | 4 +- .../construct-framework-workflow.usecase.ts | 5 +- .../email-output-renderer.usecase.ts | 31 +------- apps/api/src/app/shared/helpers/liquid.ts | 25 +++++++ .../query-parser/query-parser.service.ts | 36 +++++++++ .../build-workflow-test-data.usecase.ts | 13 ++-- ...build-available-variable-schema.usecase.ts | 39 +++++----- .../extract-variables.command.ts} | 2 +- .../extract-variables.usecase.ts} | 73 ++----------------- .../generate-preview.usecase.ts | 12 +-- .../app/workflows-v2/util/build-variables.ts | 12 ++- .../app/workflows-v2/util/create-schema.ts | 59 +++++++++++++++ .../src/app/workflows-v2/workflow.module.ts | 4 +- .../conditions-editor/variable-select.tsx | 3 +- .../icons/workflow-trigger-inbox.tsx | 4 +- packages/framework/src/client.ts | 3 +- packages/framework/src/servers/h3.ts | 5 +- packages/framework/src/servers/nuxt.ts | 1 + 18 files changed, 195 insertions(+), 136 deletions(-) create mode 100644 apps/api/src/app/shared/helpers/liquid.ts rename apps/api/src/app/workflows-v2/usecases/{build-payload-schema/build-payload-schema.command.ts => extract-variables/extract-variables.command.ts} (84%) rename apps/api/src/app/workflows-v2/usecases/{build-payload-schema/build-payload-schema.usecase.ts => extract-variables/extract-variables.usecase.ts} (50%) create mode 100644 apps/api/src/app/workflows-v2/util/create-schema.ts diff --git a/apps/api/src/app/bridge/bridge.module.ts b/apps/api/src/app/bridge/bridge.module.ts index eee79d5997f..1bc46fbb3d8 100644 --- a/apps/api/src/app/bridge/bridge.module.ts +++ b/apps/api/src/app/bridge/bridge.module.ts @@ -20,7 +20,7 @@ import { SharedModule } from '../shared/shared.module'; import { BridgeController } from './bridge.controller'; import { USECASES } from './usecases'; import { BuildVariableSchemaUsecase } from '../workflows-v2/usecases/build-variable-schema'; -import { BuildPayloadSchema } from '../workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase'; +import { ExtractVariables } from '../workflows-v2/usecases/extract-variables/extract-variables.usecase'; import { BuildStepIssuesUsecase } from '../workflows-v2/usecases/build-step-issues/build-step-issues.usecase'; const PROVIDERS = [ @@ -42,7 +42,7 @@ const PROVIDERS = [ BuildVariableSchemaUsecase, TierRestrictionsValidateUsecase, CommunityOrganizationRepository, - BuildPayloadSchema, + ExtractVariables, BuildStepIssuesUsecase, ]; 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 95f1c9d4dc9..20519ca276d 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 @@ -230,7 +230,10 @@ export class ConstructFrameworkWorkflow { return foundWorkflow; } - private processSkipOption(controlValues: { [x: string]: unknown }, variables: FullPayloadForRender) { + private async processSkipOption( + controlValues: { [x: string]: unknown }, + variables: FullPayloadForRender + ): Promise { const skipRules = controlValues.skip as RulesLogic; if (_.isEmpty(skipRules)) { 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 index 82e61ba24c9..70ddc167e0c 100644 --- 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 @@ -7,6 +7,7 @@ import { InstrumentUsecase } from '@novu/application-generic'; import { FullPayloadForRender, RenderCommand } from './render-command'; import { WrapMailyInLiquidUseCase } from './maily-to-liquid/wrap-maily-in-liquid.usecase'; import { MAILY_ITERABLE_MARK, MailyAttrsEnum, MailyContentTypeEnum } from './maily-to-liquid/maily.types'; +import { parseLiquid } from '../../../shared/helpers/liquid'; export class EmailOutputRendererCommand extends RenderCommand {} @@ -72,7 +73,7 @@ export class EmailOutputRendererUsecase { mailyContent: MailyJSONContent, variables: FullPayloadForRender ): Promise { - const parsedString = await this.parseLiquid(JSON.stringify(mailyContent), variables); + const parsedString = await parseLiquid(JSON.stringify(mailyContent), variables); return JSON.parse(parsedString); } @@ -145,7 +146,7 @@ export class EmailOutputRendererUsecase { node: MailyJSONContent & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } ): Promise { const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs; - const parsedShowIfValue = await this.parseLiquid(showIfKey, variables); + const parsedShowIfValue = await parseLiquid(showIfKey, variables); return this.stringToBoolean(parsedShowIfValue); } @@ -208,7 +209,7 @@ export class EmailOutputRendererUsecase { } private async getIterableArray(iterablePath: string, variables: FullPayloadForRender): Promise { - const iterableArrayString = await this.parseLiquid(iterablePath, variables); + const iterableArrayString = await parseLiquid(iterablePath, variables); try { const parsedArray = JSON.parse(iterableArrayString.replace(/'/g, '"')); @@ -263,28 +264,4 @@ export class EmailOutputRendererUsecase { typeof node.attrs[MailyAttrsEnum.ID] === 'string' ); } - - private parseLiquid(value: string, variables: FullPayloadForRender): Promise { - const client = new Liquid({ - outputEscape: (output) => { - return this.stringifyDataStructureWithSingleQuotes(output); - }, - }); - - const template = client.parse(value); - - return client.render(template, variables); - } - - private stringifyDataStructureWithSingleQuotes(value: unknown, spaces: number = 0): string { - if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { - const valueStringified = JSON.stringify(value, null, spaces); - const valueSingleQuotes = valueStringified.replace(/"/g, "'"); - const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); - - return valueEscapedNewLines; - } else { - return String(value); - } - } } diff --git a/apps/api/src/app/shared/helpers/liquid.ts b/apps/api/src/app/shared/helpers/liquid.ts new file mode 100644 index 00000000000..959869897e2 --- /dev/null +++ b/apps/api/src/app/shared/helpers/liquid.ts @@ -0,0 +1,25 @@ +import { Liquid } from 'liquidjs'; + +export const parseLiquid = async (value: string, variables: object): Promise => { + const client = new Liquid({ + outputEscape: (output) => { + return stringifyDataStructureWithSingleQuotes(output); + }, + }); + + const template = client.parse(value); + + return await client.render(template, variables); +}; + +const stringifyDataStructureWithSingleQuotes = (value: unknown, spaces: number = 0): string => { + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + const valueStringified = JSON.stringify(value, null, spaces); + const valueSingleQuotes = valueStringified.replace(/"/g, "'"); + const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); + + return valueEscapedNewLines; + } else { + return String(value); + } +}; diff --git a/apps/api/src/app/shared/services/query-parser/query-parser.service.ts b/apps/api/src/app/shared/services/query-parser/query-parser.service.ts index 35ac695bb2d..2f63a2967d4 100644 --- a/apps/api/src/app/shared/services/query-parser/query-parser.service.ts +++ b/apps/api/src/app/shared/services/query-parser/query-parser.service.ts @@ -144,3 +144,39 @@ export function isValidRule(rule: RulesLogic): boolean { return false; } } + +export function extractFieldsFromRules(rules: RulesLogic): string[] { + const variables = new Set(); + + const collectVariables = (node: RulesLogic) => { + if (!node || typeof node !== 'object') { + return; + } + + const entries = Object.entries(node); + + for (const [key, value] of entries) { + if (key === 'var' && typeof value === 'string') { + variables.add(value); + continue; + } + + if (Array.isArray(value)) { + value.forEach((item) => { + if (typeof item === 'object') { + collectVariables(item); + } + }); + continue; + } + + if (typeof value === 'object') { + collectVariables(value as RulesLogic); + } + } + }; + + collectVariables(rules); + + return Array.from(variables); +} diff --git a/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts index 799c001197f..acf788abd81 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts @@ -16,14 +16,15 @@ import { import { WorkflowTestDataCommand } from './build-workflow-test-data.command'; import { parsePayloadSchema } from '../../shared/parse-payload-schema'; import { mockSchemaDefaults } from '../../util/utils'; -import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; -import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; +import { ExtractVariables } from '../extract-variables/extract-variables.usecase'; +import { ExtractVariablesCommand } from '../extract-variables/extract-variables.command'; +import { buildVariablesSchema } from '../../util/create-schema'; @Injectable() export class BuildWorkflowTestDataUseCase { constructor( private readonly getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private readonly buildPayloadSchema: BuildPayloadSchema + private readonly extractVariables: ExtractVariables ) {} @InstrumentUsecase() @@ -48,14 +49,16 @@ export class BuildWorkflowTestDataUseCase { return parsePayloadSchema(workflow.payloadSchema, { safe: true }) || {}; } - return this.buildPayloadSchema.execute( - BuildPayloadSchemaCommand.create({ + const { payload } = await this.extractVariables.execute( + ExtractVariablesCommand.create({ environmentId: command.user.environmentId, organizationId: command.user.organizationId, userId: command.user._id, workflowId: workflow._id, }) ); + + return buildVariablesSchema(payload); } private generatePayloadMock(schema: JSONSchemaDto): Record { diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts index e8625d0f3db..2015de825f9 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts @@ -5,13 +5,14 @@ import { Instrument } from '@novu/application-generic'; import { computeResultSchema } from '../../shared'; import { BuildVariableSchemaCommand } from './build-available-variable-schema.command'; import { parsePayloadSchema } from '../../shared/parse-payload-schema'; -import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; -import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; +import { ExtractVariablesCommand } from '../extract-variables/extract-variables.command'; +import { ExtractVariables } from '../extract-variables/extract-variables.usecase'; import { emptyJsonSchema } from '../../util/jsonToSchema'; +import { buildVariablesSchema } from '../../util/create-schema'; @Injectable() export class BuildVariableSchemaUsecase { - constructor(private readonly buildPayloadSchema: BuildPayloadSchema) {} + constructor(private readonly extractVariables: ExtractVariables) {} async execute(command: BuildVariableSchemaCommand): Promise { const { workflow, stepInternalId } = command; @@ -19,6 +20,15 @@ export class BuildVariableSchemaUsecase { 0, workflow?.steps.findIndex((stepItem) => stepItem._id === stepInternalId) ); + const { payload, subscriber } = await this.extractVariables.execute( + ExtractVariablesCommand.create({ + environmentId: command.environmentId, + organizationId: command.organizationId, + userId: command.userId, + workflowId: workflow?._id, + ...(command.optimisticControlValues ? { controlValues: command.optimisticControlValues } : {}), + }) + ); return { type: 'object', @@ -40,18 +50,15 @@ export class BuildVariableSchemaUsecase { format: 'date-time', description: 'The last time the subscriber was online (optional)', }, - data: { - type: 'object', - properties: {}, - description: 'Additional data about the subscriber', - additionalProperties: true, - }, + data: buildVariablesSchema( + subscriber && typeof subscriber === 'object' && 'data' in subscriber ? subscriber.data : {} + ), }, required: ['firstName', 'lastName', 'email', 'subscriberId'], additionalProperties: false, }, steps: buildPreviousStepsSchema(previousSteps, workflow?.payloadSchema), - payload: await this.resolvePayloadSchema(workflow, command), + payload: await this.resolvePayloadSchema(workflow, payload), }, additionalProperties: false, } as const satisfies JSONSchemaDto; @@ -60,7 +67,7 @@ export class BuildVariableSchemaUsecase { @Instrument() private async resolvePayloadSchema( workflow: NotificationTemplateEntity | undefined, - command: BuildVariableSchemaCommand + payload: unknown ): Promise { if (workflow && workflow.steps.length === 0) { return { @@ -74,15 +81,7 @@ export class BuildVariableSchemaUsecase { return parsePayloadSchema(workflow.payloadSchema, { safe: true }) || emptyJsonSchema(); } - return this.buildPayloadSchema.execute( - BuildPayloadSchemaCommand.create({ - environmentId: command.environmentId, - organizationId: command.organizationId, - userId: command.userId, - workflowId: workflow?._id, - ...(command.optimisticControlValues ? { controlValues: command.optimisticControlValues } : {}), - }) - ); + return buildVariablesSchema(payload); } } diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.command.ts b/apps/api/src/app/workflows-v2/usecases/extract-variables/extract-variables.command.ts similarity index 84% rename from apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.command.ts rename to apps/api/src/app/workflows-v2/usecases/extract-variables/extract-variables.command.ts index f18e6313fc8..831c8b8106a 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/extract-variables/extract-variables.command.ts @@ -1,7 +1,7 @@ import { EnvironmentWithUserCommand } from '@novu/application-generic'; import { IsString, IsObject, IsOptional } from 'class-validator'; -export class BuildPayloadSchemaCommand extends EnvironmentWithUserCommand { +export class ExtractVariablesCommand extends EnvironmentWithUserCommand { @IsString() @IsOptional() workflowId?: string; diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/extract-variables/extract-variables.usecase.ts similarity index 50% rename from apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts rename to apps/api/src/app/workflows-v2/usecases/extract-variables/extract-variables.usecase.ts index 5172bbd0c75..f29652faf1b 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/extract-variables/extract-variables.usecase.ts @@ -1,24 +1,25 @@ import { Injectable } from '@nestjs/common'; import { ControlValuesRepository } from '@novu/dal'; -import { ControlValuesLevelEnum, JSONSchemaDto } from '@novu/shared'; +import { ControlValuesLevelEnum } from '@novu/shared'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; + import { keysToObject } from '../../util/utils'; -import { BuildPayloadSchemaCommand } from './build-payload-schema.command'; import { buildVariables } from '../../util/build-variables'; +import { ExtractVariablesCommand } from './extract-variables.command'; @Injectable() -export class BuildPayloadSchema { +export class ExtractVariables { constructor(private readonly controlValuesRepository: ControlValuesRepository) {} @InstrumentUsecase() - async execute(command: BuildPayloadSchemaCommand): Promise { + async execute(command: ExtractVariablesCommand): Promise> { const controlValues = await this.getControlValues(command); const extractedVariables = await this.extractAllVariables(controlValues); - return this.buildVariablesSchema(extractedVariables); + return keysToObject(extractedVariables); } - private async getControlValues(command: BuildPayloadSchemaCommand) { + private async getControlValues(command: ExtractVariablesCommand) { let controlValues = command.controlValues ? [command.controlValues] : []; if (!controlValues.length && command.workflowId) { @@ -59,64 +60,4 @@ export class BuildPayloadSchema { return [...new Set(allVariables)]; } - - private async buildVariablesSchema(variables: string[]) { - const { payload } = keysToObject(variables); - - const schema: JSONSchemaDto = { - type: 'object', - properties: {}, - required: [], - additionalProperties: true, - }; - - if (payload) { - for (const [key, value] of Object.entries(payload)) { - if (schema.properties && schema.required) { - schema.properties[key] = determineSchemaType(value); - schema.required.push(key); - } - } - } - - return schema; - } -} - -function determineSchemaType(value: unknown): JSONSchemaDto { - if (value === null) { - return { type: 'null' }; - } - - if (Array.isArray(value)) { - return { - type: 'array', - items: value.length > 0 ? determineSchemaType(value[0]) : { type: 'null' }, - }; - } - - switch (typeof value) { - case 'string': - return { type: 'string', default: value }; - case 'number': - return { type: 'number', default: value }; - case 'boolean': - return { type: 'boolean', default: value }; - case 'object': - return { - type: 'object', - properties: Object.entries(value).reduce( - (acc, [key, val]) => { - acc[key] = determineSchemaType(val); - - return acc; - }, - {} as { [key: string]: JSONSchemaDto } - ), - required: Object.keys(value), - }; - - default: - return { type: 'null' }; - } } 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 0b4f852f22d..4f635db5748 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 @@ -29,11 +29,12 @@ import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/previe import { FrameworkPreviousStepsOutputState } from '../../../bridge/usecases/preview-step/preview-step.command'; import { BuildStepDataUsecase } from '../build-step-data'; import { GeneratePreviewCommand } from './generate-preview.command'; -import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; -import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; +import { ExtractVariablesCommand } from '../extract-variables/extract-variables.command'; +import { ExtractVariables } from '../extract-variables/extract-variables.usecase'; import { Variable } from '../../util/template-parser/liquid-parser'; import { buildVariables } from '../../util/build-variables'; import { keysToObject, mergeCommonObjectKeys, multiplyArrayItems } from '../../util/utils'; +import { buildVariablesSchema } from '../../util/create-schema'; import { isObjectMailyJSONContent } from '../../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command'; const LOG_CONTEXT = 'GeneratePreviewUsecase'; @@ -44,7 +45,7 @@ export class GeneratePreviewUsecase { private previewStepUsecase: PreviewStep, private buildStepDataUsecase: BuildStepDataUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private buildPayloadSchema: BuildPayloadSchema, + private extractVariables: ExtractVariables, private readonly logger: PinoLogger ) {} @@ -216,8 +217,8 @@ export class GeneratePreviewUsecase { command: GeneratePreviewCommand, controlValues: Record ) { - const payloadSchema = await this.buildPayloadSchema.execute( - BuildPayloadSchemaCommand.create({ + const { payload } = await this.extractVariables.execute( + ExtractVariablesCommand.create({ environmentId: command.user.environmentId, organizationId: command.user.organizationId, userId: command.user._id, @@ -225,6 +226,7 @@ export class GeneratePreviewUsecase { controlValues, }) ); + const payloadSchema = buildVariablesSchema(payload); if (Object.keys(payloadSchema).length === 0) { return variables; diff --git a/apps/api/src/app/workflows-v2/util/build-variables.ts b/apps/api/src/app/workflows-v2/util/build-variables.ts index eef2220f87f..fd42a7eb838 100644 --- a/apps/api/src/app/workflows-v2/util/build-variables.ts +++ b/apps/api/src/app/workflows-v2/util/build-variables.ts @@ -1,11 +1,12 @@ import _ from 'lodash'; - +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import { PinoLogger } from '@novu/application-generic'; import { Variable, extractLiquidTemplateVariables, TemplateVariables } from './template-parser/liquid-parser'; import { WrapMailyInLiquidUseCase } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.usecase'; import { MAILY_ITERABLE_MARK } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/maily.types'; import { isStringifiedMailyJSONContent } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/wrap-maily-in-liquid.command'; +import { extractFieldsFromRules, isValidRule } from '../../shared/services/query-parser/query-parser.service'; export function buildVariables( variableSchema: Record | undefined, @@ -29,6 +30,15 @@ export function buildVariables( 'BuildVariables' ); } + } else if (isValidRule(variableControlValue as RulesLogic)) { + const fields = extractFieldsFromRules(variableControlValue as RulesLogic) + .filter((field) => field.startsWith('payload.') || field.startsWith('subscriber.data.')) + .map((field) => `{{${field}}}`); + + variableControlValue = { + rules: variableControlValue, + fields, + }; } const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue)); diff --git a/apps/api/src/app/workflows-v2/util/create-schema.ts b/apps/api/src/app/workflows-v2/util/create-schema.ts new file mode 100644 index 00000000000..3fa28045978 --- /dev/null +++ b/apps/api/src/app/workflows-v2/util/create-schema.ts @@ -0,0 +1,59 @@ +import { JSONSchemaDto } from '@novu/shared'; + +function determineSchemaType(value: unknown): JSONSchemaDto { + if (value === null) { + return { type: 'null' }; + } + + if (Array.isArray(value)) { + return { + type: 'array', + items: value.length > 0 ? determineSchemaType(value[0]) : { type: 'null' }, + }; + } + + switch (typeof value) { + case 'string': + return { type: 'string', default: value }; + case 'number': + return { type: 'number', default: value }; + case 'boolean': + return { type: 'boolean', default: value }; + case 'object': + return { + type: 'object', + properties: Object.entries(value).reduce( + (acc, [key, val]) => { + acc[key] = determineSchemaType(val); + + return acc; + }, + {} as { [key: string]: JSONSchemaDto } + ), + required: Object.keys(value), + }; + + default: + return { type: 'null' }; + } +} + +export function buildVariablesSchema(object: unknown) { + const schema: JSONSchemaDto = { + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }; + + if (object) { + for (const [key, value] of Object.entries(object)) { + if (schema.properties && schema.required) { + schema.properties[key] = determineSchemaType(value); + schema.required.push(key); + } + } + } + + return schema; +} diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index a217fc4271c..e30d8c3d1ba 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -30,7 +30,7 @@ import { } from './usecases'; import { PatchWorkflowUsecase } from './usecases/patch-workflow'; import { PatchStepUsecase } from './usecases/patch-step-data/patch-step.usecase'; -import { BuildPayloadSchema } from './usecases/build-payload-schema/build-payload-schema.usecase'; +import { ExtractVariables } from './usecases/extract-variables/extract-variables.usecase'; import { BuildStepIssuesUsecase } from './usecases/build-step-issues/build-step-issues.usecase'; import { WorkflowController } from './workflow.controller'; @@ -60,7 +60,7 @@ const DAL_REPOSITORIES = [CommunityOrganizationRepository]; PatchStepUsecase, PatchWorkflowUsecase, TierRestrictionsValidateUsecase, - BuildPayloadSchema, + ExtractVariables, BuildStepIssuesUsecase, ], }) diff --git a/apps/dashboard/src/components/conditions-editor/variable-select.tsx b/apps/dashboard/src/components/conditions-editor/variable-select.tsx index ac262c56990..7a3db2ec68e 100644 --- a/apps/dashboard/src/components/conditions-editor/variable-select.tsx +++ b/apps/dashboard/src/components/conditions-editor/variable-select.tsx @@ -187,7 +187,8 @@ export const VariableSelect = ({ e.preventDefault(); } else if (e.key === 'Enter') { if (hoveredOptionIndex !== -1) { - onSelect(options[hoveredOptionIndex].value ?? ''); + e.preventDefault(); + onSelect(filteredOptions[hoveredOptionIndex].value ?? ''); setHoveredOptionIndex(-1); } } diff --git a/apps/dashboard/src/components/icons/workflow-trigger-inbox.tsx b/apps/dashboard/src/components/icons/workflow-trigger-inbox.tsx index 5399f8e3030..f17916960b2 100644 --- a/apps/dashboard/src/components/icons/workflow-trigger-inbox.tsx +++ b/apps/dashboard/src/components/icons/workflow-trigger-inbox.tsx @@ -31,7 +31,7 @@ export function WorkflowTriggerInboxIllustration() { y2="98.6257" gradientUnits="userSpaceOnUse" > - + @@ -43,7 +43,7 @@ export function WorkflowTriggerInboxIllustration() { y2="105.626" gradientUnits="userSpaceOnUse" > - + diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index e760163670a..b47295073c4 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -312,7 +312,8 @@ export class Client { // Only evaluate a skip condition when the step is the current step and not in preview mode. if (!isPreview && stepId === event.stepId) { - const controls = await this.createStepControls(step, event); + const templateControls = await this.createStepControls(step, event); + const controls = await this.compileControls(templateControls, event); const shouldSkip = await this.shouldSkip(options?.skip as typeof step.options.skip, controls); if (shouldSkip) { diff --git a/packages/framework/src/servers/h3.ts b/packages/framework/src/servers/h3.ts index 9970b717af7..b4ea17d7f4f 100644 --- a/packages/framework/src/servers/h3.ts +++ b/packages/framework/src/servers/h3.ts @@ -1,4 +1,4 @@ -import { getHeader, getQuery, type H3Event, readBody, send, setHeaders, type EventHandlerRequest } from 'h3'; +import { getHeader, getQuery, type H3Event, readBody, send, setHeaders } from 'h3'; import { NovuRequestHandler, type ServeHandlerOptions } from '../handler'; import { type SupportedFrameworkName } from '../types'; @@ -49,7 +49,7 @@ export const serve = (options: ServeHandlerOptions) => { const handler = new NovuRequestHandler({ frameworkName, ...options, - handler: (event: H3Event) => { + handler: (event: H3Event) => { return { body: () => readBody(event), headers: (key) => getHeader(event, key), @@ -59,6 +59,7 @@ export const serve = (options: ServeHandlerOptions) => { String(event.path), `${process.env.NODE_ENV === 'development' ? 'http' : 'https'}://${String(getHeader(event, 'host'))}` ), + // eslint-disable-next-line @typescript-eslint/no-base-to-string queryString: (key) => String(getQuery(event)[key]), transformResponse: (actionRes) => { const { res } = event.node; diff --git a/packages/framework/src/servers/nuxt.ts b/packages/framework/src/servers/nuxt.ts index 6b32be4382a..9fb3c3e4c42 100644 --- a/packages/framework/src/servers/nuxt.ts +++ b/packages/framework/src/servers/nuxt.ts @@ -27,6 +27,7 @@ export const serve = (options: ServeHandlerOptions) => { * TODO: Fix this */ handler: (event: H3Event) => ({ + // eslint-disable-next-line @typescript-eslint/no-base-to-string queryString: (key) => String(getQuery(event)[key]), body: () => readBody(event), headers: (key) => getHeader(event, key), From 059ab6f6ab82c35c87c0b169b29450afccad8793 Mon Sep 17 00:00:00 2001 From: "blacksmith-sh[bot]" <157653362+blacksmith-sh[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 17:35:13 +0530 Subject: [PATCH 21/25] blacksmith.sh: Migrate workflows to Blacksmith (#7568) Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com> --- .github/workflows/reusable-api-e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-api-e2e.yml b/.github/workflows/reusable-api-e2e.yml index 45f06209162..ed4a065610e 100644 --- a/.github/workflows/reusable-api-e2e.yml +++ b/.github/workflows/reusable-api-e2e.yml @@ -52,7 +52,7 @@ jobs: # This workflow contains a single job called "build" e2e_api: name: Test E2E - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 80 permissions: contents: read From 75d2a5c592b19ab3352fc446da9f7df0351d7213 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Thu, 23 Jan 2025 14:47:13 +0200 Subject: [PATCH 22/25] chore(root): Update .source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 998124fc5c8..d034791c4e3 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 998124fc5c815f759f366b1c47c0a6e29511571b +Subproject commit d034791c4e39516d4a9e4161e632fa7dcef5bb39 From 692100c2feb47b5c1fe8b95125b0609c65bbfe21 Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:55:10 +0200 Subject: [PATCH 23/25] fix(worker): digest by key (#7569) --- .../steps/digest/digest-control-values.tsx | 6 ++--- .../usecases/add-job/add-job.usecase.ts | 14 ++++++----- .../add-job/merge-or-create-digest.usecase.ts | 10 +++++--- .../src/repositories/job/job.repository.ts | 23 +++++++++++-------- libs/dal/src/repositories/job/job.schema.ts | 3 +++ packages/shared/src/entities/step/index.ts | 1 + 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx index a5e4eae3bd1..edfe866de89 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx @@ -12,11 +12,11 @@ export const DigestControlValues = () => { return null; } - const { ['amount']: amount, ['unit']: unit, ['cron']: cron } = uiSchema.properties ?? {}; + const { ['amount']: amount, ['digestKey']: digestKey, ['unit']: unit, ['cron']: cron } = uiSchema.properties ?? {}; return (
    - {/* {digestKey && ( + {digestKey && ( <> {getComponentByType({ @@ -25,7 +25,7 @@ export const DigestControlValues = () => { - )} */} + )} {((amount && unit) || cron) && ( <> diff --git a/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts b/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts index 453b5428e1c..2dc4d6e81a5 100644 --- a/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/add-job/add-job.usecase.ts @@ -261,12 +261,14 @@ export class AddJob { private async updateMetadata(response: ExecuteOutput, command: AddJobCommand) { let metadata = {} as IWorkflowStepMetadata; const outputs = response.outputs as DigestOutput; + // digest value is pre-computed by framework and passed as digestKey + const outputDigestValue = outputs?.digestKey; const digestType = getDigestType(outputs); if (isTimedDigestOutput(outputs)) { metadata = { type: DigestTypeEnum.TIMED, - digestKey: outputs?.digestKey, + digestValue: outputDigestValue, timed: { cronExpression: outputs?.cron }, } as IDigestTimedMetadata; @@ -278,7 +280,7 @@ export class AddJob { { $set: { 'digest.type': metadata.type, - 'digest.digestKey': metadata.digestKey, + 'digest.digestValue': metadata.digestValue, 'digest.amount': metadata.amount, 'digest.unit': metadata.unit, 'digest.timed.cronExpression': metadata.timed?.cronExpression, @@ -291,7 +293,7 @@ export class AddJob { metadata = { type: digestType, amount: outputs?.amount, - digestKey: outputs?.digestKey, + digestValue: outputDigestValue, unit: outputs.unit ? castUnitToDigestUnitEnum(outputs?.unit) : undefined, backoff: digestType === DigestTypeEnum.BACKOFF, backoffAmount: outputs.lookBackWindow?.amount, @@ -306,7 +308,7 @@ export class AddJob { { $set: { 'digest.type': metadata.type, - 'digest.digestKey': metadata.digestKey, + 'digest.digestValue': metadata.digestValue, 'digest.amount': metadata.amount, 'digest.unit': metadata.unit, 'digest.backoff': metadata.backoff, @@ -321,7 +323,7 @@ export class AddJob { metadata = { type: digestType, amount: outputs?.amount, - digestKey: outputs?.digestKey, + digestValue: outputDigestValue, unit: outputs.unit ? castUnitToDigestUnitEnum(outputs?.unit) : undefined, } as IDigestRegularMetadata; @@ -333,7 +335,7 @@ export class AddJob { { $set: { 'digest.type': metadata.type, - 'digest.digestKey': metadata.digestKey, + 'digest.digestValue': metadata.digestValue, 'digest.amount': metadata.amount, 'digest.unit': metadata.unit, }, diff --git a/apps/worker/src/app/workflow/usecases/add-job/merge-or-create-digest.usecase.ts b/apps/worker/src/app/workflow/usecases/add-job/merge-or-create-digest.usecase.ts index efc8742c72d..f8cdb977c37 100644 --- a/apps/worker/src/app/workflow/usecases/add-job/merge-or-create-digest.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/add-job/merge-or-create-digest.usecase.ts @@ -47,7 +47,7 @@ export class MergeOrCreateDigest { const digestMeta = job.digest as IDigestBaseMetadata; const digestKey = digestMeta?.digestKey; - const digestValue = getNestedValue(job.payload, digestKey); + const digestValue = digestMeta?.digestValue ?? getNestedValue(job.payload, digestKey); const digestAction = command.filtered ? { digestResult: DigestCreationResultEnum.SKIPPED } @@ -150,9 +150,13 @@ export class MergeOrCreateDigest { } private getLockKey(job: JobEntity, digestKey: string | undefined, digestValue: string | number | undefined): string { - let resource = `environment:${job._environmentId}:template:${job._templateId}:subscriber:${job._subscriberId}`; + const resource = `environment:${job._environmentId}:template:${job._templateId}:subscriber:${job._subscriberId}`; if (digestKey && digestValue) { - resource = `${resource}:digestKey:${digestKey}:digestValue:${digestValue}`; + return `${resource}:digestKey:${digestKey}:digestValue:${digestValue}`; + } + + if (digestValue) { + return `${resource}:digestValue:${digestValue}`; } return resource; diff --git a/libs/dal/src/repositories/job/job.repository.ts b/libs/dal/src/repositories/job/job.repository.ts index b5db39c6236..f295bc9a9a1 100644 --- a/libs/dal/src/repositories/job/job.repository.ts +++ b/libs/dal/src/repositories/job/job.repository.ts @@ -189,9 +189,10 @@ export class JobRepository extends BaseRepository) { const query = { updatedAt: { $gte: this.getBackoffDate(metadata), @@ -276,7 +281,7 @@ export class JobRepository extends BaseRepository( digestKey: { type: Schema.Types.String, }, + digestValue: { + type: Schema.Types.String, + }, type: { type: Schema.Types.String, }, diff --git a/packages/shared/src/entities/step/index.ts b/packages/shared/src/entities/step/index.ts index ed408e14904..9e57ebb1c58 100644 --- a/packages/shared/src/entities/step/index.ts +++ b/packages/shared/src/entities/step/index.ts @@ -88,6 +88,7 @@ export interface IAmountAndUnitDigest { export interface IDigestBaseMetadata extends IAmountAndUnitDigest { digestKey?: string; + digestValue?: string; } export interface IDigestRegularMetadata extends IDigestBaseMetadata { From 88635d42f71203a02c5b9b5b922e32fc1354aac0 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 23 Jan 2025 17:05:43 +0200 Subject: [PATCH 24/25] feat(dashboard): improve telemetry for env (#7571) --- .../src/components/create-environment-button.tsx | 7 +++++++ apps/dashboard/src/pages/environments.tsx | 9 +++++++++ apps/dashboard/src/pages/workflows.tsx | 14 ++++++-------- apps/dashboard/src/utils/telemetry.ts | 2 ++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/components/create-environment-button.tsx b/apps/dashboard/src/components/create-environment-button.tsx index 8246717fe8e..d02e7e932a3 100644 --- a/apps/dashboard/src/components/create-environment-button.tsx +++ b/apps/dashboard/src/components/create-environment-button.tsx @@ -31,6 +31,8 @@ import { useForm } from 'react-hook-form'; import { RiAddLine, RiArrowRightSLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; import { ColorPicker } from './primitives/color-picker'; import { showErrorToast, showSuccessToast } from './primitives/sonner-helpers'; import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'; @@ -74,6 +76,7 @@ export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) => const { mutateAsync, isPending } = useCreateEnvironment(); const { subscription } = useFetchSubscription(); const navigate = useNavigate(); + const track = useTelemetry(); const isBusinessTier = subscription?.apiServiceLevel === ApiServiceLevelEnum.BUSINESS; const isTrialActive = subscription?.trial?.isActive; @@ -109,6 +112,10 @@ export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) => }; const handleClick = () => { + track(TelemetryEvent.CREATE_ENVIRONMENT_CLICK, { + createAllowed: !!canCreateEnvironment, + }); + if (!canCreateEnvironment) { navigate(ROUTES.SETTINGS_BILLING); return; diff --git a/apps/dashboard/src/pages/environments.tsx b/apps/dashboard/src/pages/environments.tsx index fc0b49a63c0..c87c610f38d 100644 --- a/apps/dashboard/src/pages/environments.tsx +++ b/apps/dashboard/src/pages/environments.tsx @@ -1,9 +1,18 @@ import { PageMeta } from '@/components/page-meta'; +import { useEffect } from 'react'; import { CreateEnvironmentButton } from '../components/create-environment-button'; import { DashboardLayout } from '../components/dashboard-layout'; import { EnvironmentsList } from '../components/environments-list'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; export function EnvironmentsPage() { + const track = useTelemetry(); + + useEffect(() => { + track(TelemetryEvent.ENVIRONMENTS_PAGE_VIEWED); + }, [track]); + return ( <> diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index 3246a2f3de5..495322002dd 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -115,7 +115,7 @@ export const WorkflowsPage = () => { buildRoute(ROUTES.TEMPLATE_STORE_CREATE_WORKFLOW, { environmentSlug: environmentSlug || '', templateId: template.id, - }) + }) + '?source=template-store-card-row' ); }; @@ -183,14 +183,13 @@ export const WorkflowsPage = () => { + onSelect={() => { navigate( buildRoute(ROUTES.TEMPLATE_STORE, { environmentSlug: environmentSlug || '', - source: 'create-workflow-dropdown', - }) - ) - } + }) + '?source=create-workflow-dropdown' + ); + }} > View Workflow Gallery @@ -224,8 +223,7 @@ export const WorkflowsPage = () => { navigate( buildRoute(ROUTES.TEMPLATE_STORE, { environmentSlug: environmentSlug || '', - source: 'start-with', - }) + }) + '?source=start-with' ) } trailingIcon={RiArrowRightSLine} diff --git a/apps/dashboard/src/utils/telemetry.ts b/apps/dashboard/src/utils/telemetry.ts index b712926a0c6..c1dfc3c7c9a 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -45,4 +45,6 @@ export enum TelemetryEvent { CREATE_WORKFLOW_CLICK = 'Create Workflow Click', GENERATE_WORKFLOW_CLICK = 'Generate Workflow Click', TEMPLATE_WORKFLOW_CLICK = 'Template Workflow Click', + ENVIRONMENTS_PAGE_VIEWED = 'Environments Page Viewed', + CREATE_ENVIRONMENT_CLICK = 'Create Environment Click', } From 6fea7064c10dbfa605fe3e03d01781f40b6af972 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 23 Jan 2025 17:10:45 +0200 Subject: [PATCH 25/25] fix(dashboard): enterprise tier environments management --- apps/dashboard/src/components/create-environment-button.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/create-environment-button.tsx b/apps/dashboard/src/components/create-environment-button.tsx index d02e7e932a3..6e4c41b0219 100644 --- a/apps/dashboard/src/components/create-environment-button.tsx +++ b/apps/dashboard/src/components/create-environment-button.tsx @@ -78,9 +78,11 @@ export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) => const navigate = useNavigate(); const track = useTelemetry(); - const isBusinessTier = subscription?.apiServiceLevel === ApiServiceLevelEnum.BUSINESS; + const isPaidTier = + subscription?.apiServiceLevel === ApiServiceLevelEnum.BUSINESS || + subscription?.apiServiceLevel === ApiServiceLevelEnum.ENTERPRISE; const isTrialActive = subscription?.trial?.isActive; - const canCreateEnvironment = isBusinessTier && !isTrialActive; + const canCreateEnvironment = isPaidTier && !isTrialActive; const form = useForm({ resolver: zodResolver(createEnvironmentSchema),