From 490250d7e33adb02aff7b45bc669776ca357bd83 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Mon, 16 Dec 2024 13:49:09 +0530 Subject: [PATCH 01/10] =?UTF-8?q?chore(root):=20update=20service=20images?= =?UTF-8?q?=20to=20version=202.1.0=20and=20use=20dynamic=E2=80=A6=20(#7297?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/community/docker-compose.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/community/docker-compose.yml b/docker/community/docker-compose.yml index 979c8f1a749..747e56446af 100644 --- a/docker/community/docker-compose.yml +++ b/docker/community/docker-compose.yml @@ -46,7 +46,7 @@ services: start_period: 60s api: - image: 'ghcr.io/novuhq/novu/api:2.0.0' + image: 'ghcr.io/novuhq/novu/api:2.1.0' depends_on: mongodb: condition: service_healthy @@ -87,10 +87,10 @@ services: NEW_RELIC_LICENSE_KEY: ${NEW_RELIC_LICENSE_KEY} API_CONTEXT_PATH: ${API_CONTEXT_PATH} ports: - - '3000:3000' + - ${API_PORT}:${API_PORT} worker: - image: 'ghcr.io/novuhq/novu/worker:2.0.0' + image: 'ghcr.io/novuhq/novu/worker:2.1.0' depends_on: mongodb: condition: service_healthy @@ -127,7 +127,7 @@ services: MULTICAST_QUEUE_CHUNK_SIZE: ${MULTICAST_QUEUE_CHUNK_SIZE} ws: - image: 'ghcr.io/novuhq/novu/ws:2.0.0' + image: 'ghcr.io/novuhq/novu/ws:2.1.0' depends_on: mongodb: condition: service_healthy @@ -153,10 +153,10 @@ services: NEW_RELIC_APP_NAME: ${NEW_RELIC_APP_NAME} NEW_RELIC_LICENSE_KEY: ${NEW_RELIC_LICENSE_KEY} ports: - - '3002:3002' + - ${WS_PORT}:${WS_PORT} web: - image: 'ghcr.io/novuhq/novu/web:2.0.0' + image: 'ghcr.io/novuhq/novu/web:2.1.0' depends_on: - api - worker From fde15d939975f242ceb7b5386e3373d91645d998 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Mon, 16 Dec 2024 10:42:13 +0200 Subject: [PATCH 02/10] fix(api): Crate of fixes part 2 (#7292) --- .../render-email-output.usecase.ts | 10 +++++-- .../app/workflows-v2/generate-preview.e2e.ts | 21 ++++++--------- .../shared/schemas/email-control.schema.ts | 15 +++++------ ...ract-default-values-from-schema.usecase.ts | 27 +++---------------- .../workflows-v2/workflow.controller.e2e.ts | 8 +++--- .../workflow-editor/steps/component-utils.tsx | 2 +- .../email/configure-email-step-preview.tsx | 19 +++++++------ .../steps/email/email-editor.tsx | 7 ++--- .../workflow-editor/steps/email/maily.tsx | 4 +-- .../src/dto/workflows/control-schemas.ts | 23 +--------------- .../workflows/preview-step-response.dto.ts | 2 +- .../dto/workflows/step-content-issue.enum.ts | 4 --- packages/shared/src/dto/workflows/step.dto.ts | 2 +- 13 files changed, 45 insertions(+), 99 deletions(-) diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index e3bfa56781e..36309a69827 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -2,6 +2,7 @@ import { EmailRenderOutput, TipTapNode } from '@novu/shared'; import { Injectable } from '@nestjs/common'; import { render as mailyRender } from '@maily-to/render'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; +import isEmpty from 'lodash/isEmpty'; import { FullPayloadForRender, RenderCommand } from './render-command'; import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; import { EmailStepControlZodSchema } from '../../../workflows-v2/shared'; @@ -14,8 +15,13 @@ export class RenderEmailOutputUsecase { @InstrumentUsecase() async execute(renderCommand: RenderEmailOutputCommand): Promise { - const { emailEditor, subject } = EmailStepControlZodSchema.parse(renderCommand.controlValues); - const expandedSchema = this.transformForAndShowLogic(emailEditor, renderCommand.fullPayloadForRender); + const { body, subject } = EmailStepControlZodSchema.parse(renderCommand.controlValues); + + if (isEmpty(body)) { + return { subject, body: '' }; + } + + const expandedSchema = this.transformForAndShowLogic(body, renderCommand.fullPayloadForRender); const htmlRendered = await this.renderEmail(expandedSchema); return { subject, body: htmlRendered }; 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 fd6024fd280..88f9d3626ba 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -8,7 +8,6 @@ import { createWorkflowClient, CreateWorkflowDto, CronExpressionEnum, - EmailStepControlSchemaDto, GeneratePreviewRequestDto, GeneratePreviewResponseDto, HttpError, @@ -20,6 +19,7 @@ import { import { buildCreateWorkflowDto } from './workflow.controller.e2e'; import { forSnippet, fullCodeSnippet } from './maily-test-data'; import { InAppControlType } from './shared'; +import { EmailStepControlType } from './shared/schemas/email-control.schema'; const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; @@ -441,7 +441,8 @@ describe('Generate Preview', () => { const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; channelTypes.forEach(({ type, description }) => { - it(`[${type}] should assign default values to missing elements`, async () => { + // TODO: We need to get back to the drawing board on this one to make the preview action of the framework more forgiving + it(`[${type}] catches the 400 error returned by the Bridge Preview action`, async () => { const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(workflowsClient, type); const requestDto = buildDtoWithMissingControlValues(type, stepId); @@ -453,11 +454,7 @@ describe('Generate Preview', () => { description ); - if (previewResponseDto.result!.type !== ChannelTypeEnum.IN_APP) { - throw new Error('Expected email'); - } - expect(previewResponseDto.result!.preview.body).to.exist; - expect(previewResponseDto.result!.preview.body).to.equal('PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING'); + expect(previewResponseDto.result).to.eql({ preview: {} }); }); }); }); @@ -538,16 +535,16 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId?: string): Generat }; } -function buildEmailControlValuesPayload(stepId?: string): EmailStepControlSchemaDto { +function buildEmailControlValuesPayload(stepId?: string): EmailStepControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - emailEditor: JSON.stringify(fullCodeSnippet(stepId)), + body: JSON.stringify(fullCodeSnippet(stepId)), }; } -function buildSimpleForEmail(): EmailStepControlSchemaDto { +function buildSimpleForEmail(): EmailStepControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, - emailEditor: JSON.stringify(forSnippet), + body: JSON.stringify(forSnippet), }; } function buildInAppControlValues() { @@ -642,8 +639,6 @@ async function assertHttpError( dto: GeneratePreviewRequestDto ) { if (novuRestResult.error) { - console.log(JSON.stringify(JSON.parse(novuRestResult.error.responseText), null, 2)); - return new Error( `${description}: Failed to generate preview: ${novuRestResult.error.message}payload: ${JSON.stringify(dto, null, 2)} ` ); diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 8b4648d300d..9e5a372a161 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -5,23 +5,20 @@ import { zodToJsonSchema } from 'zod-to-json-schema'; export const EmailStepControlZodSchema = z .object({ - emailEditor: z.string(), - subject: z.string(), + body: z.string().optional().default(''), + subject: z.string().optional().default(''), }) - .strict() - .required({ - emailEditor: true, - subject: true, - }); + .strict(); export const emailStepControlSchema = zodToJsonSchema(EmailStepControlZodSchema) as JSONSchemaDto; export type EmailStepControlType = z.infer; + export const emailStepUiSchema: UiSchema = { group: UiSchemaGroupEnum.EMAIL, properties: { - emailEditor: { - component: UiComponentEnum.MAILY, + body: { + component: UiComponentEnum.BLOCK_EDITOR, }, subject: { component: UiComponentEnum.TEXT_INLINE_LABEL, diff --git a/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts index 3da736c44f9..7e3ff2d610b 100644 --- a/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/extract-default-values-from-schema/extract-default-values-from-schema.usecase.ts @@ -1,26 +1,8 @@ -import { JSONSchemaDto, PreviewIssueEnum, TipTapNode } from '@novu/shared'; +import { JSONSchemaDto } from '@novu/shared'; import { Injectable } from '@nestjs/common'; import { ExtractDefaultValuesFromSchemaCommand } from './extract-default-values-from-schema.command'; import { isMatchingJsonSchema } from '../../util/jsonToSchema'; -const DEFAULT_PREVIEW_ISSUE_MESSAGE: TipTapNode = { - type: 'doc', - content: [ - { - type: 'paragraph', - attrs: { - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: PreviewIssueEnum.PREVIEW_ISSUE_REQUIRED_CONTROL_VALUE_IS_MISSING, - }, - ], - }, - ], -}; - @Injectable() export class ExtractDefaultValuesFromSchemaUsecase { /** @@ -82,15 +64,12 @@ export class ExtractDefaultValuesFromSchemaUsecase { return value.default; } + // TODO: Move this to a default value in the step controls schema. if (normalizedKey.includes('url')) { return 'https://www.example.com/search?query=placeholder'; } - if (normalizedKey.includes('emaileditor')) { - return JSON.stringify(DEFAULT_PREVIEW_ISSUE_MESSAGE); - } - - return PreviewIssueEnum.PREVIEW_ISSUE_REQUIRED_CONTROL_VALUE_IS_MISSING; + return null; } } diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts index 74d42989d7a..2c55a9dc5d0 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -227,13 +227,13 @@ describe('Workflow Controller E2E API Testing', () => { describe('Workflow Step content Issues', () => { it('should show control value required when missing', async () => { - const { issues, status } = await createWorkflowAndReturnStepIssues({ steps: [{ ...buildEmailStep() }] }, 0); + const { issues, status } = await createWorkflowAndReturnStepIssues({ steps: [{ ...buildInAppStep() }] }, 0); expect(status, JSON.stringify(issues)).to.equal(WorkflowStatusEnum.ERROR); expect(issues).to.be.ok; if (issues.controls) { - expect(issues.controls?.emailEditor).to.be.ok; - if (issues.controls?.emailEditor) { - expect(issues.controls?.emailEditor[0].issueType).to.be.equal(StepContentIssueEnum.MISSING_VALUE); + expect(issues.controls?.body).to.be.ok; + if (issues.controls?.body) { + expect(issues.controls?.body[0].issueType).to.be.equal(StepContentIssueEnum.MISSING_VALUE); } } }); diff --git a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx index 18d0303efad..91f42bbbbba 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx @@ -33,7 +33,7 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum case UiComponentEnum.DELAY_TYPE: { return ; } - case UiComponentEnum.MAILY: { + case UiComponentEnum.BLOCK_EDITOR: { return ; } case UiComponentEnum.TEXT_INLINE_LABEL: { diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx index 72dbe9297af..963393ae4bb 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/configure-email-step-preview.tsx @@ -6,7 +6,6 @@ import { usePreviewStep } from '@/hooks/use-preview-step'; import { EmailPreviewHeader } from '@/components/workflow-editor/steps/email/email-preview'; import { Separator } from '@/components/primitives/separator'; import { Skeleton } from '@/components/primitives/skeleton'; -import { ChannelTypeEnum } from '@novu/shared'; import { cn } from '@/utils/ui'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; @@ -16,7 +15,7 @@ const MiniEmailPreview = (props: MiniEmailPreviewProps) => { return (
@@ -65,13 +64,13 @@ export function ConfigureEmailStepPreview(props: ConfigureEmailStepPreviewProps) ); } - if (previewData?.result?.type !== ChannelTypeEnum.EMAIL) { - return No preview available; + if (previewData.result.type === 'email') { + return ( + +
{previewData.result.preview.subject}
+
+ ); } - return ( - -
{previewData.result.preview.subject}
-
- ); + return null; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx index 904496048ea..46000ffec5e 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx @@ -4,13 +4,10 @@ import { EmailPreviewHeader } from '@/components/workflow-editor/steps/email/ema import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; import { type UiSchema } from '@novu/shared'; -const subjectKey = 'subject'; -const emailEditorKey = 'emailEditor'; - type EmailEditorProps = { uiSchema: UiSchema }; export const EmailEditor = (props: EmailEditorProps) => { const { uiSchema } = props; - const { [emailEditorKey]: emailEditor, [subjectKey]: subject } = uiSchema?.properties ?? {}; + const { body, subject } = uiSchema?.properties ?? {}; return (
@@ -23,7 +20,7 @@ export const EmailEditor = (props: EmailEditorProps) => { {/* extra padding on the left to account for the drag handle */} - {emailEditor && getComponentByType({ component: emailEditor.component })} + {getComponentByType({ component: body.component })}
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx index bf320de8f08..0e172707647 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx @@ -24,8 +24,6 @@ import type { Editor as TiptapEditor } from '@tiptap/core'; import { HTMLAttributes, useMemo, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -const bodyKey = 'emailEditor'; - type MailyProps = HTMLAttributes; export const Maily = (props: MailyProps) => { const { className, ...rest } = props; @@ -37,7 +35,7 @@ export const Maily = (props: MailyProps) => { return ( { return ( <> diff --git a/packages/shared/src/dto/workflows/control-schemas.ts b/packages/shared/src/dto/workflows/control-schemas.ts index 998fa944e7b..37a2041bd61 100644 --- a/packages/shared/src/dto/workflows/control-schemas.ts +++ b/packages/shared/src/dto/workflows/control-schemas.ts @@ -1,7 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { JSONSchemaDto } from './json-schema-dto'; - -export interface TipTapNode { +export type TipTapNode = { type?: string; attrs?: Record; content?: TipTapNode[]; @@ -12,22 +9,4 @@ export interface TipTapNode { }[]; text?: string; [key: string]: any; -} -export interface EmailStepControlSchemaDto { - emailEditor: string; - subject: string; -} - -export const EmailStepControlSchema: JSONSchemaDto = { - type: 'object', - properties: { - emailEditor: { - type: 'string', - }, - subject: { - type: 'string', - }, - }, - required: ['emailEditor', 'subject'], - additionalProperties: false, }; diff --git a/packages/shared/src/dto/workflows/preview-step-response.dto.ts b/packages/shared/src/dto/workflows/preview-step-response.dto.ts index bd25043fd1f..4b627a0474c 100644 --- a/packages/shared/src/dto/workflows/preview-step-response.dto.ts +++ b/packages/shared/src/dto/workflows/preview-step-response.dto.ts @@ -113,7 +113,7 @@ export class PreviewPayload { export class GeneratePreviewResponseDto { previewPayloadExample: PreviewPayload; - result?: + result: | { type: ChannelTypeEnum.EMAIL; preview: EmailRenderOutput; diff --git a/packages/shared/src/dto/workflows/step-content-issue.enum.ts b/packages/shared/src/dto/workflows/step-content-issue.enum.ts index 2de4cd39964..afc31add422 100644 --- a/packages/shared/src/dto/workflows/step-content-issue.enum.ts +++ b/packages/shared/src/dto/workflows/step-content-issue.enum.ts @@ -11,7 +11,3 @@ export enum StepIssueEnum { STEP_ID_EXISTS = 'STEP_ID_EXISTS', MISSING_REQUIRED_VALUE = 'MISSING_REQUIRED_VALUE', } - -export enum PreviewIssueEnum { - PREVIEW_ISSUE_REQUIRED_CONTROL_VALUE_IS_MISSING = 'PREVIEW_ISSUE:REQUIRED_CONTROL_VALUE_IS_MISSING', -} diff --git a/packages/shared/src/dto/workflows/step.dto.ts b/packages/shared/src/dto/workflows/step.dto.ts index 156dda463e3..236a2909439 100644 --- a/packages/shared/src/dto/workflows/step.dto.ts +++ b/packages/shared/src/dto/workflows/step.dto.ts @@ -65,7 +65,7 @@ export enum UiSchemaGroupEnum { } export enum UiComponentEnum { - MAILY = 'MAILY', + BLOCK_EDITOR = 'BLOCK_EDITOR', TEXT_FULL_LINE = 'TEXT_FULL_LINE', TEXT_INLINE_LABEL = 'TEXT_INLINE_LABEL', IN_APP_BODY = 'IN_APP_BODY', From 62e8e43289f83d698d6248fbe0699b10de6eb9b1 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Mon, 16 Dec 2024 11:02:59 +0200 Subject: [PATCH 03/10] fix(dashboard): Align In-App step copywriting --- apps/dashboard/src/components/workflow-editor/nodes.tsx | 2 +- .../workflow-editor/steps/in-app/in-app-editor-preview.tsx | 2 +- .../components/workflow-editor/steps/in-app/in-app-editor.tsx | 2 +- .../src/components/workflow-editor/steps/step-skeleton.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/nodes.tsx b/apps/dashboard/src/components/workflow-editor/nodes.tsx index 319fa79615f..32816bcd7c6 100644 --- a/apps/dashboard/src/components/workflow-editor/nodes.tsx +++ b/apps/dashboard/src/components/workflow-editor/nodes.tsx @@ -121,7 +121,7 @@ export const InAppNode = (props: NodeProps) => { {data.name || 'In-App Step'} - Sends In-app notification to your subscribers + Sends In-App notification to your subscribers {data.error && {data.error}} diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx index fa9189898ba..dacfe550a16 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx @@ -60,7 +60,7 @@ export const InAppEditorPreview = ({ workflow, step, formValues }: InAppEditorPr
- In-app template editor + In-App template editor
diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx index d9095a049d5..17656dbc99c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx @@ -31,7 +31,7 @@ export const InAppEditor = ({ uiSchema }: { uiSchema?: UiSchema }) => {
- In-app template editor + In-App template editor
{(avatar || subject) && ( diff --git a/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx b/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx index 73bc29cec45..ab0845004a9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/step-skeleton.tsx @@ -34,7 +34,7 @@ const STEP_TYPE_TO_SKELETON_CONTENT: Record React.J <>
- In-app template editor + In-App template editor
From 3fe7e1620b53178602efab130107e2c9c2dcca40 Mon Sep 17 00:00:00 2001 From: Pawan Jain Date: Mon, 16 Dec 2024 15:02:13 +0530 Subject: [PATCH 04/10] fix(web): remove isV2Enabled condition for opt in option (#7298) --- apps/web/src/ee/clerk/components/UserProfileButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/ee/clerk/components/UserProfileButton.tsx b/apps/web/src/ee/clerk/components/UserProfileButton.tsx index 39423224c46..6f519e78b55 100644 --- a/apps/web/src/ee/clerk/components/UserProfileButton.tsx +++ b/apps/web/src/ee/clerk/components/UserProfileButton.tsx @@ -9,14 +9,13 @@ import { WEB_APP_URL } from '../../../config'; export function UserProfileButton() { const { optIn } = useNewDashboardOptIn(); const isNewDashboardEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_NEW_DASHBOARD_ENABLED); - const isV2Enabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_ENABLED); return ( - {isNewDashboardEnabled && isV2Enabled && ( + {isNewDashboardEnabled && ( Date: Mon, 16 Dec 2024 10:33:18 +0100 Subject: [PATCH 05/10] feat(web,dashboard): set member limit for business tier to 50 (#7301) --- .source | 2 +- apps/dashboard/src/components/billing/features.tsx | 4 ++-- apps/dashboard/src/components/billing/plans-row.tsx | 4 ++-- apps/web/src/ee/billing/components/Features.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.source b/.source index 1eaf99c1369..14591b3e482 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 1eaf99c1369b3d2fe6eeaa062462ff6027435992 +Subproject commit 14591b3e48225e5291405b3f8eee7156c2ec30b2 diff --git a/apps/dashboard/src/components/billing/features.tsx b/apps/dashboard/src/components/billing/features.tsx index 6baa84667fd..9413e9d4405 100644 --- a/apps/dashboard/src/components/billing/features.tsx +++ b/apps/dashboard/src/components/billing/features.tsx @@ -1,5 +1,5 @@ -import { Check } from 'lucide-react'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { Check } from 'lucide-react'; import { cn } from '../../utils/ui'; enum SupportedPlansEnum { @@ -159,7 +159,7 @@ const features: Feature[] = [ label: 'Team members', values: { [SupportedPlansEnum.FREE]: { value: '3' }, - [SupportedPlansEnum.BUSINESS]: { value: '10' }, + [SupportedPlansEnum.BUSINESS]: { value: '50' }, [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, }, }, diff --git a/apps/dashboard/src/components/billing/plans-row.tsx b/apps/dashboard/src/components/billing/plans-row.tsx index d5b2d1a397d..c039962ac47 100644 --- a/apps/dashboard/src/components/billing/plans-row.tsx +++ b/apps/dashboard/src/components/billing/plans-row.tsx @@ -1,8 +1,8 @@ import { Badge } from '@/components/primitives/badge'; import { Card } from '@/components/primitives/card'; import { Check } from 'lucide-react'; -import { PlanActionButton } from './plan-action-button'; import { ContactSalesButton } from './contact-sales-button'; +import { PlanActionButton } from './plan-action-button'; interface PlansRowProps { selectedBillingInterval: 'month' | 'year'; @@ -87,7 +87,7 @@ export function PlansRow({ selectedBillingInterval, currentPlan, trial }: PlansR
  • - Up to 10 team members + Up to 50 team members
  • diff --git a/apps/web/src/ee/billing/components/Features.tsx b/apps/web/src/ee/billing/components/Features.tsx index d5c45b3f471..ba210d44059 100644 --- a/apps/web/src/ee/billing/components/Features.tsx +++ b/apps/web/src/ee/billing/components/Features.tsx @@ -194,7 +194,7 @@ const features: Feature[] = [ label: 'Team members', values: { [SupportedPlansEnum.FREE]: { value: '3' }, - [SupportedPlansEnum.BUSINESS]: { value: '10' }, + [SupportedPlansEnum.BUSINESS]: { value: '50' }, [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, }, }, From 0b6a990a60ce444ed490ff8a8043f05f86a62aaa Mon Sep 17 00:00:00 2001 From: Pawan Jain Date: Mon, 16 Dec 2024 16:24:15 +0530 Subject: [PATCH 06/10] feat(web): add org tier info in sentry and ld (#7308) --- apps/dashboard/src/context/identity-provider.tsx | 3 +++ apps/web/src/hooks/useMonitoring.ts | 3 +++ apps/web/src/utils/segment.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/context/identity-provider.tsx b/apps/dashboard/src/context/identity-provider.tsx index 0e5cc9ae50e..9bdea043de0 100644 --- a/apps/dashboard/src/context/identity-provider.tsx +++ b/apps/dashboard/src/context/identity-provider.tsx @@ -26,6 +26,8 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { id: currentUser._id, organizationId: currentOrganization._id, organizationName: currentOrganization.name, + organizationTier: currentOrganization.apiServiceLevel, + organizationCreatedAt: currentOrganization.createdAt, }); if (ldClient) { @@ -35,6 +37,7 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { key: currentOrganization._id, name: currentOrganization.name, createdAt: currentOrganization.createdAt, + tier: currentOrganization.apiServiceLevel, }, user: { key: currentUser._id, diff --git a/apps/web/src/hooks/useMonitoring.ts b/apps/web/src/hooks/useMonitoring.ts index 804715e6ea6..c1ef7e0d283 100644 --- a/apps/web/src/hooks/useMonitoring.ts +++ b/apps/web/src/hooks/useMonitoring.ts @@ -29,6 +29,8 @@ export function useMonitoring() { id: currentUser._id, organizationId: currentOrganization._id, organizationName: currentOrganization.name, + organizationTier: currentOrganization.apiServiceLevel, + organizationCreatedAt: currentOrganization.createdAt, }); } else { sentryConfigureScope((scope) => scope.setUser(null)); @@ -46,6 +48,7 @@ export function useMonitoring() { key: currentOrganization._id, name: currentOrganization.name, createdAt: currentOrganization.createdAt, + tier: currentOrganization.apiServiceLevel, }); } else { ldClient.identify({ diff --git a/apps/web/src/utils/segment.ts b/apps/web/src/utils/segment.ts index 577cd0cba70..03e8f568ecb 100644 --- a/apps/web/src/utils/segment.ts +++ b/apps/web/src/utils/segment.ts @@ -56,7 +56,7 @@ export class SegmentService { this._segment?.identify(user?._id, { email: user.email, - name: user.firstName + ' ' + user.lastName, + name: `${user.firstName} ${user.lastName}`, firstName: user.firstName, lastName: user.lastName, avatar: user.profilePicture, From 340c71c52384f39b143cf29eeef5c9274877ab6f Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:04:39 +0200 Subject: [PATCH 07/10] feat(api): add query parser (#7267) --- .vscode/tasks.json | 19 +- apps/api/package.json | 2 + .../construct-framework-workflow.usecase.ts | 64 ++- .../src/app/events/e2e/trigger-event.e2e.ts | 237 ++++++++++- .../query-parser/query-parser.service.spec.ts | 384 ++++++++++++++++++ .../query-parser/query-parser.service.ts | 146 +++++++ .../shared/schemas/chat-control.schema.ts | 3 + .../shared/schemas/delay-control.schema.ts | 3 + .../shared/schemas/digest-control.schema.ts | 3 + .../shared/schemas/email-control.schema.ts | 3 + .../shared/schemas/in-app-control.schema.ts | 3 + .../shared/schemas/push-control.schema.ts | 3 + .../shared/schemas/skip-control.schema.ts | 18 + .../shared/schemas/sms-control.schema.ts | 3 + packages/shared/src/dto/workflows/step.dto.ts | 2 + pnpm-lock.yaml | 16 + 16 files changed, 875 insertions(+), 34 deletions(-) create mode 100644 apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts create mode 100644 apps/api/src/app/shared/services/query-parser/query-parser.service.ts create mode 100644 apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 14df9002691..49873547f75 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -37,11 +37,26 @@ "endsPattern": "Started application in NODE_ENV" } }, + }, + { + "type": "npm", + "script": "start:test", + "isBackground": true, + "label": "WORKER TEST", + "path": "/apps/worker", + "problemMatcher": { + "base": "$tsc-watch", + "owner": "typescript", + "background": { + "activeOnStart": true, + "beginsPattern": "Successfully compiled", + "endsPattern": "Started application in NODE_ENV" + } + }, "icon": { "id": "server", "color": "terminal.ansiGreen" - }, - "dependsOn": ["SHARED", "API", "APPLICATION GENERIC", "DAL", "EE - TRANSLATION", "EE - BILLING"] + } }, { "type": "npm", diff --git a/apps/api/package.json b/apps/api/package.json index 97aaf593c05..11933cc3b73 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -74,6 +74,7 @@ "helmet": "^6.0.1", "i18next": "^23.7.6", "ioredis": "5.3.2", + "json-logic-js": "^2.0.5", "json-schema-to-ts": "^3.0.0", "jsonwebtoken": "9.0.0", "liquidjs": "^10.14.0", @@ -119,6 +120,7 @@ "@types/passport-jwt": "^3.0.3", "@types/sinon": "^9.0.0", "@types/supertest": "^2.0.8", + "@types/json-logic-js": "^2.0.8", "async": "^3.2.0", "chai": "^4.2.0", "mocha": "^10.2.0", 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 59b53303a4b..dcf3f1e3eec 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 @@ -3,7 +3,9 @@ import { workflow } from '@novu/framework/express'; import { ActionStep, ChannelStep, JsonSchema, Step, StepOptions, StepOutput, Workflow } from '@novu/framework/internal'; import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { StepTypeEnum } from '@novu/shared'; -import { Instrument, InstrumentUsecase } from '@novu/application-generic'; +import { Instrument, InstrumentUsecase, PinoLogger } from '@novu/application-generic'; +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; +import _ from 'lodash'; import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflow.command'; import { ChatOutputRendererUsecase, @@ -15,10 +17,14 @@ import { } from '../output-renderers'; import { DelayOutputRendererUsecase } from '../output-renderers/delay-output-renderer.usecase'; import { DigestOutputRendererUsecase } from '../output-renderers/digest-output-renderer.usecase'; +import { evaluateRules } from '../../../shared/services/query-parser/query-parser.service'; + +const LOG_CONTEXT = 'ConstructFrameworkWorkflow'; @Injectable() export class ConstructFrameworkWorkflow { constructor( + private logger: PinoLogger, private workflowsRepository: NotificationTemplateRepository, private inAppOutputRendererUseCase: InAppOutputRendererUsecase, private emailOutputRendererUseCase: RenderEmailOutputUsecase, @@ -103,7 +109,7 @@ export class ConstructFrameworkWorkflow { return this.inAppOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, // Step options - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.EMAIL: return step.email( @@ -111,7 +117,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.emailOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.SMS: return step.inApp( @@ -119,7 +125,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.smsOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.CHAT: return step.inApp( @@ -127,7 +133,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.chatOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.PUSH: return step.inApp( @@ -135,7 +141,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.pushOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructChannelStepOptions(staticStep) + this.constructChannelStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.DIGEST: return step.digest( @@ -143,7 +149,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.digestOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructActionStepOptions(staticStep) + this.constructActionStepOptions(staticStep, fullPayloadForRender) ); case StepTypeEnum.DELAY: return step.delay( @@ -151,7 +157,7 @@ export class ConstructFrameworkWorkflow { async (controlValues) => { return this.delayOutputRendererUseCase.execute({ controlValues, fullPayloadForRender }); }, - this.constructActionStepOptions(staticStep) + this.constructActionStepOptions(staticStep, fullPayloadForRender) ); default: throw new InternalServerErrorException(`Step type ${stepType} is not supported`); @@ -159,9 +165,12 @@ export class ConstructFrameworkWorkflow { } @Instrument() - private constructChannelStepOptions(staticStep: NotificationStepEntity): Required[2]> { + private constructChannelStepOptions( + staticStep: NotificationStepEntity, + fullPayloadForRender: FullPayloadForRender + ): Required[2]> { return { - ...this.constructCommonStepOptions(staticStep), + ...this.constructCommonStepOptions(staticStep, fullPayloadForRender), // TODO: resolve this from the Step options disableOutputSanitization: false, // TODO: add providers @@ -170,22 +179,24 @@ export class ConstructFrameworkWorkflow { } @Instrument() - private constructActionStepOptions(staticStep: NotificationStepEntity): Required[2]> { + private constructActionStepOptions( + staticStep: NotificationStepEntity, + fullPayloadForRender: FullPayloadForRender + ): Required[2]> { return { - ...this.constructCommonStepOptions(staticStep), + ...this.constructCommonStepOptions(staticStep, fullPayloadForRender), }; } @Instrument() - private constructCommonStepOptions(staticStep: NotificationStepEntity): Required { + private constructCommonStepOptions( + staticStep: NotificationStepEntity, + fullPayloadForRender: FullPayloadForRender + ): Required { return { // TODO: fix the `JSONSchemaDto` type to enforce a non-primitive schema type. controlSchema: staticStep.template!.controls!.schema as JsonSchema, - /* - * TODO: add conditions - * Used to construct conditions defined with https://react-querybuilder.js.org/ or similar - */ - skip: (controlValues) => false, + skip: (controlValues: Record) => this.processSkipOption(controlValues, fullPayloadForRender), }; } @@ -199,7 +210,24 @@ export class ConstructFrameworkWorkflow { return foundWorkflow; } + + private processSkipOption(controlValues: { [x: string]: unknown }, variables: FullPayloadForRender) { + const skipRules = controlValues.skip as RulesLogic; + + if (_.isEmpty(skipRules)) { + return false; + } + + const { result, error } = evaluateRules(skipRules, variables); + + if (error) { + this.logger.error({ err: error }, 'Failed to evaluate skip rule', LOG_CONTEXT); + } + + return result; + } } + const PERMISSIVE_EMPTY_SCHEMA = { type: 'object', properties: {}, diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts index a65c6ca9561..3e8fbdc35cf 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -33,6 +33,10 @@ import { StepTypeEnum, SystemAvatarIconEnum, TemplateVariableTypeEnum, + CreateWorkflowDto, + WorkflowCreationSourceEnum, + WorkflowResponseDto, + ExecutionDetailsStatusEnum, } from '@novu/shared'; import { EmailEventStatusEnum } from '@novu/stateless'; import { DetailEnum } from '@novu/application-generic'; @@ -64,20 +68,20 @@ describe(`Trigger event - /v1/events/trigger (POST)`, function () { const tenantRepository = new TenantRepository(); let novuClient: Novu; - describe(`Trigger Event - /v1/events/trigger (POST)`, function () { - beforeEach(async () => { - session = new UserSession(); - await session.initialize(); - template = await session.createTemplate(); - subscriberService = new SubscribersService(session.organization._id, session.environment._id); - subscriber = await subscriberService.createSubscriber(); - workflowOverrideService = new WorkflowOverrideService({ - organizationId: session.organization._id, - environmentId: session.environment._id, - }); - novuClient = initNovuClassSdk(session); + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + template = await session.createTemplate(); + subscriberService = new SubscribersService(session.organization._id, session.environment._id); + subscriber = await subscriberService.createSubscriber(); + workflowOverrideService = new WorkflowOverrideService({ + organizationId: session.organization._id, + environmentId: session.environment._id, }); + novuClient = initNovuClassSdk(session); + }); + describe(`Trigger Event - /v1/events/trigger (POST)`, function () { it('should filter delay step', async function () { const firstStepUuid = uuid(); template = await session.createTemplate({ @@ -2198,7 +2202,7 @@ describe(`Trigger event - /v1/events/trigger (POST)`, function () { ], }); - // const axiosPostStub = sinon.stub(axios, 'post').throws(new Error('Users remote error')); + // const axiosPostStub = sinon.stub(axios, 'post').throws(new Error('Users remote error'))); await novuClient.trigger({ name: template.triggers[0].identifier, @@ -3223,10 +3227,215 @@ describe(`Trigger event - /v1/events/trigger (POST)`, function () { tenant, actor, }; - console.log('request111', JSON.stringify(request, null, 2)); return (await novuClient.trigger(request)).result; } + + describe('Trigger Event v2 workflow - /v1/events/trigger (POST)', function () { + afterEach(async () => { + await messageRepository.deleteMany({ + _environmentId: session.environment._id, + }); + }); + + it('should skip step based on skip', async function () { + const workflowBody: CreateWorkflowDto = { + name: 'Test Skip Workflow', + workflowId: 'test-skip-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + body: 'Hello {{subscriber.lastName}}, Welcome!', + skip: { + '==': [{ var: 'payload.shouldSkip' }, true], + }, + }, + }, + ], + }; + + const response = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(response.status).to.equal(201); + const workflow: WorkflowResponseDto = response.body.data; + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + shouldSkip: true, + }, + }); + await session.awaitRunningJobs(workflow._id); + const skippedMessages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(skippedMessages.length).to.equal(0); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + shouldSkip: false, + }, + }); + await session.awaitRunningJobs(workflow._id); + const notSkippedMessages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(notSkippedMessages.length).to.equal(1); + }); + }); + + it('should handle complex skip logic with subscriber data', async function () { + const workflowBody: CreateWorkflowDto = { + name: 'Test Complex Skip Logic', + workflowId: 'test-complex-skip-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + body: 'Hello {{subscriber.lastName}}, Welcome!', + skip: { + and: [ + { + or: [ + { '==': [{ var: 'subscriber.firstName' }, 'John'] }, + { '==': [{ var: 'subscriber.data.role' }, 'admin'] }, + ], + }, + { + and: [ + { '>=': [{ var: 'payload.userScore' }, 100] }, + { '==': [{ var: 'subscriber.lastName' }, 'Doe'] }, + ], + }, + ], + }, + }, + }, + ], + }; + + const response = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(response.status).to.equal(201); + const workflow: WorkflowResponseDto = response.body.data; + + // Should skip - matches all conditions + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Doe', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 150, + }, + }); + await session.awaitRunningJobs(workflow._id); + const skippedMessages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(skippedMessages.length).to.equal(0); + + // Should not skip - doesn't match lastName condition + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Smith', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 150, + }, + }); + await session.awaitRunningJobs(workflow._id); + const notSkippedMessages1 = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(notSkippedMessages1.length).to.equal(1); + + // Should not skip - doesn't match score condition + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Doe', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 50, + }, + }); + await session.awaitRunningJobs(workflow._id); + const notSkippedMessages2 = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + }); + expect(notSkippedMessages2.length).to.equal(1); + }); + + it('should exit execution if skip condition execution throws an error', async function () { + const workflowBody: CreateWorkflowDto = { + name: 'Test Complex Skip Logic', + workflowId: 'test-complex-skip-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + body: 'Hello {{subscriber.lastName}}, Welcome!', + skip: { invalidOp: [1, 2] }, // INVALID OPERATOR + }, + }, + ], + }; + + const response = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(response.status).to.equal(201); + const workflow: WorkflowResponseDto = response.body.data; + + subscriber = await subscriberService.createSubscriber({ + firstName: 'John', + lastName: 'Doe', + data: { role: 'admin' }, + }); + + await novuClient.trigger({ + name: workflow.workflowId, + to: [subscriber.subscriberId], + payload: { + userScore: 150, + }, + }); + await session.awaitRunningJobs(workflow._id); + const executionDetails = await executionDetailsRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + channel: ChannelTypeEnum.IN_APP, + status: ExecutionDetailsStatusEnum.FAILED, + }); + + expect(executionDetails?.raw).to.contain('Failed to evaluate rule'); + expect(executionDetails?.raw).to.contain('Unrecognized operation invalidOp'); + }); }); async function createTemplate(session, channelType) { diff --git a/apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts b/apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts new file mode 100644 index 00000000000..4c58f03920b --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/query-parser.service.spec.ts @@ -0,0 +1,384 @@ +import { RulesLogic, AdditionalOperation } from 'json-logic-js'; +import { expect } from 'chai'; + +import { evaluateRules } from './query-parser.service'; + +describe('QueryParserService', () => { + describe('Smoke Tests', () => { + it('should evaluate a simple equality rule', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 42] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should evaluate a complex nested rule', () => { + const rule: RulesLogic = { + and: [ + { '=': [{ var: 'value' }, 42] }, + { beginsWith: [{ var: 'text' }, 'hello'] }, + { notBetween: [{ var: 'number' }, [1, 5]] }, + ], + }; + const data = { value: 42, text: 'hello world', number: 10 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + describe('Error Handling', () => { + it('should handle invalid data types gracefully', () => { + const rule: RulesLogic = { beginsWith: [{ var: 'text' }, 123] }; + const data = { text: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should throw error when safe mode is disabled', () => { + const rule: RulesLogic = { invalid: 'operator' }; + const data = { text: 'hello' }; + expect(() => evaluateRules(rule, data, false)).to.throw('Failed to evaluate rule'); + }); + + it('should return false and error when safe mode is enabled', () => { + const rule: RulesLogic = { invalid: 'operator' }; + const data = { text: 'hello' }; + const { result, error } = evaluateRules(rule, data, true); + expect(error).to.not.be.undefined; + expect(result).to.be.false; + }); + }); + }); + + describe('Custom Operators', () => { + describe('= operator', () => { + it('should return true when values are equal', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 42] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when strings are equal', () => { + const rule: RulesLogic = { '=': [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when comparing number and string (type coercion)', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, '42'] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when values are not equal', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 42] }; + const data = { value: 43 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when types are different and values cannot be coerced', () => { + const rule: RulesLogic = { '=': [{ var: 'value' }, 'not a number'] }; + const data = { value: 42 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('beginsWith operator', () => { + it('should return true when string begins with given value', () => { + const rule: RulesLogic = { beginsWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string does not begin with given value', () => { + const rule: RulesLogic = { beginsWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('endsWith operator', () => { + it('should return true when string ends with given value', () => { + const rule: RulesLogic = { endsWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string does not end with given value', () => { + const rule: RulesLogic = { endsWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('contains operator', () => { + it('should return true when string contains given value', () => { + const rule: RulesLogic = { contains: [{ var: 'text' }, 'llo wo'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string does not contain given value', () => { + const rule: RulesLogic = { contains: [{ var: 'text' }, 'xyz'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('doesNotContain operator', () => { + it('should return true when string does not contain given value', () => { + const rule: RulesLogic = { doesNotContain: [{ var: 'text' }, 'xyz'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string contains given value', () => { + const rule: RulesLogic = { doesNotContain: [{ var: 'text' }, 'llo'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('doesNotBeginWith operator', () => { + it('should return true when string does not begin with given value', () => { + const rule: RulesLogic = { doesNotBeginWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string begins with given value', () => { + const rule: RulesLogic = { doesNotBeginWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('doesNotEndWith operator', () => { + it('should return true when string does not end with given value', () => { + const rule: RulesLogic = { doesNotEndWith: [{ var: 'text' }, 'hello'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when string ends with given value', () => { + const rule: RulesLogic = { doesNotEndWith: [{ var: 'text' }, 'world'] }; + const data = { text: 'hello world' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('null operator', () => { + it('should return true when value is null', () => { + const rule: RulesLogic = { null: [{ var: 'value' }] }; + const data = { value: null }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when value is not null', () => { + const rule: RulesLogic = { null: [{ var: 'value' }] }; + const data = { value: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('notNull operator', () => { + it('should return true when value is not null', () => { + const rule: RulesLogic = { notNull: [{ var: 'value' }] }; + const data = { value: 'hello' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when value is null', () => { + const rule: RulesLogic = { notNull: [{ var: 'value' }] }; + const data = { value: null }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('notIn operator', () => { + it('should return true when value is not in array', () => { + const rule: RulesLogic = { notIn: [{ var: 'value' }, ['a', 'b', 'c']] }; + const data = { value: 'd' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when value is in array', () => { + const rule: RulesLogic = { notIn: [{ var: 'value' }, ['a', 'b', 'c']] }; + const data = { value: 'b' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when ruleValue is not an array', () => { + const rule: RulesLogic = { notIn: [{ var: 'value' }, 'not an array'] }; + const data = { value: 'b' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('between operator', () => { + it('should return true when number is between min and max', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when number equals min', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 5 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when number equals max', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 10 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when number is less than min', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 4 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when number is greater than max', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 11 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when value is not a number', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5, 10]] }; + const data = { value: 'not a number' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when range is not valid', () => { + const rule: RulesLogic = { between: [{ var: 'value' }, [5]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + + describe('notBetween operator', () => { + it('should return true when number is less than min', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 4 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return true when number is greater than max', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 11 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.true; + }); + + it('should return false when number is between min and max', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when number equals min', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 5 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when number equals max', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 10 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when value is not a number', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5, 10]] }; + const data = { value: 'not a number' }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + + it('should return false when range is not valid', () => { + const rule: RulesLogic = { notBetween: [{ var: 'value' }, [5]] }; + const data = { value: 7 }; + const { result, error } = evaluateRules(rule, data); + expect(error).to.be.undefined; + expect(result).to.be.false; + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..35ac695bb2d --- /dev/null +++ b/apps/api/src/app/shared/services/query-parser/query-parser.service.ts @@ -0,0 +1,146 @@ +import jsonLogic, { AdditionalOperation, RulesLogic } from 'json-logic-js'; + +type RangeValidation = + | { + isValid: true; + min: number; + max: number; + } + | { + isValid: false; + }; + +type StringValidation = + | { + isValid: true; + input: string; + value: string; + } + | { + isValid: false; + }; + +function validateStringInput(dataInput: unknown, ruleValue: unknown): StringValidation { + if (typeof dataInput !== 'string' || typeof ruleValue !== 'string') { + return { isValid: false }; + } + + return { isValid: true, input: dataInput, value: ruleValue }; +} + +function validateRangeInput(dataInput: unknown, ruleValue: unknown): RangeValidation { + if (!Array.isArray(ruleValue) || ruleValue.length !== 2) { + return { isValid: false }; + } + + if (typeof dataInput !== 'number') { + return { isValid: false }; + } + + const [min, max] = ruleValue; + const valid = typeof min === 'number' && typeof max === 'number'; + + return { isValid: valid, min, max }; +} + +function createStringOperator(evaluator: (input: string, value: string) => boolean) { + return (dataInput: unknown, ruleValue: unknown): boolean => { + const validation = validateStringInput(dataInput, ruleValue); + if (!validation.isValid) return false; + + return evaluator(validation.input, validation.value); + }; +} + +const initializeCustomOperators = (): void => { + jsonLogic.add_operation('=', (dataInput: unknown, ruleValue: unknown): boolean => { + const result = jsonLogic.apply({ '==': [dataInput, ruleValue] }, {}); + + return typeof result === 'boolean' ? result : false; + }); + + jsonLogic.add_operation( + 'beginsWith', + createStringOperator((input, value) => input.startsWith(value)) + ); + + jsonLogic.add_operation( + 'endsWith', + createStringOperator((input, value) => input.endsWith(value)) + ); + + jsonLogic.add_operation( + 'contains', + createStringOperator((input, value) => input.includes(value)) + ); + + jsonLogic.add_operation( + 'doesNotContain', + createStringOperator((input, value) => !input.includes(value)) + ); + + jsonLogic.add_operation( + 'doesNotBeginWith', + createStringOperator((input, value) => !input.startsWith(value)) + ); + + jsonLogic.add_operation( + 'doesNotEndWith', + createStringOperator((input, value) => !input.endsWith(value)) + ); + + jsonLogic.add_operation('null', (dataInput: unknown): boolean => dataInput === null); + + jsonLogic.add_operation('notNull', (dataInput: unknown): boolean => dataInput !== null); + + jsonLogic.add_operation( + 'notIn', + (dataInput: unknown, ruleValue: unknown[]): boolean => Array.isArray(ruleValue) && !ruleValue.includes(dataInput) + ); + + jsonLogic.add_operation('between', (dataInput, ruleValue) => { + const validation = validateRangeInput(dataInput, ruleValue); + + if (!validation.isValid) { + return false; + } + + return dataInput >= validation.min && dataInput <= validation.max; + }); + + jsonLogic.add_operation('notBetween', (dataInput, ruleValue) => { + const validation = validateRangeInput(dataInput, ruleValue); + + if (!validation.isValid) { + return false; + } + + return dataInput < validation.min || dataInput > validation.max; + }); +}; + +initializeCustomOperators(); + +export function evaluateRules( + rule: RulesLogic, + data: unknown, + safe = false +): { result: boolean; error: string | undefined } { + try { + return { result: jsonLogic.apply(rule, data), error: undefined }; + } catch (error) { + if (safe) { + return { result: false, error }; + } + + throw new Error(`Failed to evaluate rule: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +export function isValidRule(rule: RulesLogic): boolean { + try { + return jsonLogic.is_logic(rule); + } catch { + return false; + } +} diff --git a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts index a90e225ad89..4855b30e463 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const ChatStepControlZodSchema = z .object({ + skip: skipControl.schema, body: z.string(), }) .strict(); @@ -18,6 +20,7 @@ export const chatStepUiSchema: UiSchema = { body: { component: UiComponentEnum.CHAT_BODY, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts index 855e35cbb83..cd3976caa1f 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts @@ -8,9 +8,11 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const DelayTimeControlZodSchema = z .object({ + skip: skipControl.schema, type: z.enum(['regular']).default('regular'), amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum), @@ -24,6 +26,7 @@ export type DelayTimeControlType = z.infer; export const delayUiSchema: UiSchema = { group: UiSchemaGroupEnum.DELAY, properties: { + skip: skipControl.uiSchema.properties.skip, amount: { component: UiComponentEnum.DELAY_AMOUNT, placeholder: null, diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts index a9784660f0b..b235ca49d9f 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts @@ -8,9 +8,11 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; const DigestRegularControlZodSchema = z .object({ + skip: skipControl.schema, amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), digestKey: z.string().optional(), @@ -74,5 +76,6 @@ export const digestUiSchema: UiSchema = { component: UiComponentEnum.DIGEST_CRON, placeholder: null, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 9e5a372a161..41d3065de7d 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -2,9 +2,11 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@no import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import { skipControl } from './skip-control.schema'; export const EmailStepControlZodSchema = z .object({ + skip: skipControl.schema, body: z.string().optional().default(''), subject: z.string().optional().default(''), }) @@ -23,5 +25,6 @@ export const emailStepUiSchema: UiSchema = { subject: { component: UiComponentEnum.TEXT_INLINE_LABEL, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts index 90f713db176..fdd9da2533a 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; const redirectZodSchema = z .object({ @@ -22,6 +23,7 @@ const actionZodSchema = z export const InAppControlZodSchema = z .object({ + skip: skipControl.schema, subject: z.string().optional(), body: z.string(), avatar: z.string().optional(), @@ -76,5 +78,6 @@ export const inAppUiSchema: UiSchema = { component: UiComponentEnum.URL_TEXT_BOX, placeholder: redirectPlaceholder, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts index 7622bb3e428..c10e9f165d7 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const PushStepControlZodSchema = z .object({ + skip: skipControl.schema, subject: z.string(), body: z.string(), }) @@ -22,6 +24,7 @@ export const pushStepUiSchema: UiSchema = { body: { component: UiComponentEnum.PUSH_BODY, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts new file mode 100644 index 00000000000..286bc032e3f --- /dev/null +++ b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts @@ -0,0 +1,18 @@ +import { UiSchemaGroupEnum, UiSchema, UiComponentEnum } from '@novu/shared'; +import { z } from 'zod'; + +export const skipZodSchema = z.object({}).catchall(z.unknown()).optional(); + +export const skipStepUiSchema = { + group: UiSchemaGroupEnum.SKIP, + properties: { + skip: { + component: UiComponentEnum.QUERY_EDITOR, + }, + }, +} satisfies UiSchema; + +export const skipControl = { + uiSchema: skipStepUiSchema, + schema: skipZodSchema, +}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts index 58ad049f599..cefee696214 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { skipControl } from './skip-control.schema'; export const SmsStepControlZodSchema = z .object({ + skip: skipControl.schema, body: z.string(), }) .strict(); @@ -18,6 +20,7 @@ export const smsStepUiSchema: UiSchema = { body: { component: UiComponentEnum.SMS_BODY, }, + skip: skipControl.uiSchema.properties.skip, }, }; diff --git a/packages/shared/src/dto/workflows/step.dto.ts b/packages/shared/src/dto/workflows/step.dto.ts index 236a2909439..f2f99bd9706 100644 --- a/packages/shared/src/dto/workflows/step.dto.ts +++ b/packages/shared/src/dto/workflows/step.dto.ts @@ -62,6 +62,7 @@ export enum UiSchemaGroupEnum { SMS = 'SMS', CHAT = 'CHAT', PUSH = 'PUSH', + SKIP = 'SKIP', } export enum UiComponentEnum { @@ -84,6 +85,7 @@ export enum UiComponentEnum { CHAT_BODY = 'CHAT_BODY', PUSH_BODY = 'PUSH_BODY', PUSH_SUBJECT = 'PUSH_SUBJECT', + QUERY_EDITOR = 'QUERY_EDITOR', } export class UiSchemaProperty { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192656f3944..9bd339fcb8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,6 +489,9 @@ importers: ioredis: specifier: 5.3.2 version: 5.3.2 + json-logic-js: + specifier: ^2.0.5 + version: 2.0.5 json-schema-to-ts: specifier: ^3.0.0 version: 3.1.0 @@ -614,6 +617,9 @@ importers: '@types/express': specifier: 4.17.17 version: 4.17.17 + '@types/json-logic-js': + specifier: ^2.0.8 + version: 2.0.8 '@types/mocha': specifier: ^10.0.8 version: 10.0.8 @@ -17275,6 +17281,9 @@ packages: '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/json-logic-js@2.0.8': + resolution: {integrity: sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -25705,6 +25714,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-logic-js@2.0.5: + resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==} + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -57403,6 +57415,8 @@ snapshots: '@types/tough-cookie': 4.0.2 parse5: 7.1.2 + '@types/json-logic-js@2.0.8': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -70081,6 +70095,8 @@ snapshots: json-buffer@3.0.1: {} + json-logic-js@2.0.5: {} + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} From 89d2ec440d44641c3ee3e184e98c1e4e4b48ace9 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 15:34:09 +0000 Subject: [PATCH 08/10] fix: import --- .../src/pages/integrations/components/provider-icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/pages/integrations/components/provider-icon.tsx b/apps/dashboard/src/pages/integrations/components/provider-icon.tsx index 7f77835ed05..481b54a991e 100644 --- a/apps/dashboard/src/pages/integrations/components/provider-icon.tsx +++ b/apps/dashboard/src/pages/integrations/components/provider-icon.tsx @@ -1,4 +1,4 @@ -import { cn } from '@/lib/utils'; +import { cn } from '../../../utils/ui'; interface ProviderIconProps { providerId: string; From 92b532ea737a7e729d52c5e3500cacca6682a82f Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Mon, 16 Dec 2024 16:39:38 +0100 Subject: [PATCH 09/10] fix(dashboard): wait for save when switching preview tab (#7299) --- .../steps/email/email-editor.tsx | 9 +- .../steps/email/email-tabs.tsx | 85 ++++++------------- .../steps/in-app/in-app-editor.tsx | 4 +- .../steps/in-app/in-app-tabs.tsx | 74 +++++----------- .../steps/other-steps-tabs.tsx | 74 +++++----------- .../workflow-editor/steps/template-tabs.tsx | 62 ++++++++++++++ 6 files changed, 138 insertions(+), 170 deletions(-) create mode 100644 apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx index 46000ffec5e..88f42ff12d4 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-editor.tsx @@ -2,12 +2,17 @@ import { Separator } from '@/components/primitives/separator'; import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { EmailPreviewHeader } from '@/components/workflow-editor/steps/email/email-preview'; import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; -import { type UiSchema } from '@novu/shared'; +import { UiSchemaGroupEnum, type UiSchema } from '@novu/shared'; type EmailEditorProps = { uiSchema: UiSchema }; export const EmailEditor = (props: EmailEditorProps) => { const { uiSchema } = props; - const { body, subject } = uiSchema?.properties ?? {}; + + if (uiSchema.group !== UiSchemaGroupEnum.EMAIL) { + return null; + } + + const { body, subject } = uiSchema.properties ?? {}; return (
    diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx index 0d77cfc58e4..92f6408fc92 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx @@ -1,76 +1,41 @@ -import { Cross2Icon } from '@radix-ui/react-icons'; import { useFormContext } from 'react-hook-form'; -import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; - -import { Notification5Fill } from '@/components/icons'; -import { Button } from '@/components/primitives/button'; -import { Separator } from '@/components/primitives/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; -import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { WorkflowOriginEnum } from '@novu/shared'; import { EmailEditor } from '@/components/workflow-editor/steps/email/email-editor'; import { EmailEditorPreview } from '@/components/workflow-editor/steps/email/email-editor-preview'; import { CustomStepControls } from '../controls/custom-step-controls'; import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; -import { WorkflowOriginEnum } from '@novu/shared'; +import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { useState } from 'react'; -const tabsContentClassName = 'h-full w-full overflow-y-auto data-[state=inactive]:hidden'; - export const EmailTabs = (props: StepEditorProps) => { const { workflow, step } = props; const { dataSchema, uiSchema } = step.controls; const form = useFormContext(); - const navigate = useNavigate(); const [tabsValue, setTabsValue] = useState('editor'); - return ( - -
    -
    - - Configure Template -
    - - - - Editor - - - - Preview - - + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && ( + + + + )} + + ); - -
    - - - {workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema && } - {workflow.origin === WorkflowOriginEnum.EXTERNAL && ( - - - - )} - - - {tabsValue === 'preview' && ( - - )} - - -
    + const previewContent = ; + + return ( + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx index 17656dbc99c..7c549ca8166 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx @@ -12,8 +12,8 @@ const redirectKey = 'redirect'; const primaryActionKey = 'primaryAction'; const secondaryActionKey = 'secondaryAction'; -export const InAppEditor = ({ uiSchema }: { uiSchema?: UiSchema }) => { - if (!uiSchema || uiSchema?.group !== UiSchemaGroupEnum.IN_APP) { +export const InAppEditor = ({ uiSchema }: { uiSchema: UiSchema }) => { + if (uiSchema.group !== UiSchemaGroupEnum.IN_APP) { return null; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx index 3b1e6ef329a..a2a4480c42d 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx @@ -1,69 +1,41 @@ -import { Cross2Icon } from '@radix-ui/react-icons'; import { useFormContext } from 'react-hook-form'; -import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; - -import { Notification5Fill } from '@/components/icons'; -import { Button } from '@/components/primitives/button'; -import { Separator } from '@/components/primitives/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { InAppEditor } from '@/components/workflow-editor/steps/in-app/in-app-editor'; import { InAppEditorPreview } from '@/components/workflow-editor/steps/in-app/in-app-editor-preview'; import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { InAppTabsSection } from '@/components/workflow-editor/steps/in-app/in-app-tabs-section'; - -const tabsContentClassName = 'h-full w-full overflow-y-auto'; +import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; +import { WorkflowOriginEnum } from '@/utils/enums'; +import { useState } from 'react'; export const InAppTabs = (props: StepEditorProps) => { const { workflow, step } = props; const { dataSchema, uiSchema } = step.controls; const form = useFormContext(); - const navigate = useNavigate(); + const [tabsValue, setTabsValue] = useState('editor'); - return ( - -
    -
    - - Configure Template -
    - - - - Editor - - - - Preview - - + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; - -
    - - - + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && ( - - - - - -
    + )} + + ); + + const previewContent = ; + + return ( + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx index d76fd26408d..6aeb61af3d6 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/other-steps-tabs.tsx @@ -1,62 +1,26 @@ -/** - * This component is used as a placeholder for the other step configuration until the actual configuration is implemented. - */ -import { Cross2Icon } from '@radix-ui/react-icons'; -import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; - -import { Notification5Fill } from '@/components/icons'; -import { Button } from '@/components/primitives/button'; -import { Separator } from '@/components/primitives/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; -import { CustomStepControls } from '@/components/workflow-editor/steps/controls/custom-step-controls'; -import type { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; - -const tabsContentClassName = 'h-full w-full px-3 py-3.5 overflow-y-auto'; +import { useState } from 'react'; +import { CustomStepControls } from './controls/custom-step-controls'; +import { TemplateTabs } from './template-tabs'; +import type { StepEditorProps } from './configure-step-template-form'; export const OtherStepTabs = ({ workflow, step }: StepEditorProps) => { const { dataSchema } = step.controls; - const navigate = useNavigate(); + const [tabsValue, setTabsValue] = useState('editor'); - return ( - -
    -
    - - Configure Template -
    - - - - Editor - - - - Preview - - + const editorContent = ( +
    + +
    + ); + + const previewContent = null; - -
    - - -
    - -
    -
    - -
    + return ( + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx new file mode 100644 index 00000000000..3f3b31fdea3 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx @@ -0,0 +1,62 @@ +import { Cross2Icon } from '@radix-ui/react-icons'; +import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; + +import { Notification5Fill } from '@/components/icons'; +import { Button } from '@/components/primitives/button'; +import { Separator } from '@/components/primitives/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; + +interface TemplateTabsProps { + editorContent: React.ReactNode; + previewContent?: React.ReactNode; + tabsValue: string; + onTabChange: (tab: string) => void; +} + +export const TemplateTabs = ({ editorContent, previewContent, tabsValue, onTabChange }: TemplateTabsProps) => { + const navigate = useNavigate(); + + return ( + +
    +
    + + Configure Template +
    + + + + Editor + + + + Preview + + + + +
    + + + {editorContent} + + + {previewContent} + + +
    + ); +}; From b072e00323ccaf6d78737debb29200d9727979da Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Dec 2024 16:14:09 +0000 Subject: [PATCH 10/10] fix: revert file --- apps/dashboard/src/pages/activity-feed.tsx | 1 + .../src/pages/integrations/components/integration-card.tsx | 5 ++++- .../src/pages/integrations/utils/is-demo-integration.ts | 3 --- 3 files changed, 5 insertions(+), 4 deletions(-) delete mode 100644 apps/dashboard/src/pages/integrations/utils/is-demo-integration.ts diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index d2e99a3bda7..71f27e4f516 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -16,6 +16,7 @@ export function ActivityFeed() { // For arrays, check if they have any items if (Array.isArray(value)) return value.length > 0; + // For other values, check if they exist return !!value; }); diff --git a/apps/dashboard/src/pages/integrations/components/integration-card.tsx b/apps/dashboard/src/pages/integrations/components/integration-card.tsx index 953495011a5..35c3de2355d 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-card.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-card.tsx @@ -7,7 +7,6 @@ import { useNavigate } from 'react-router-dom'; import { ROUTES } from '@/utils/routes'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { ProviderIcon } from './provider-icon'; -import { isDemoIntegration } from '../utils/is-demo-integration'; import { cn } from '../../../utils/ui'; interface IntegrationCardProps { @@ -112,3 +111,7 @@ export function IntegrationCard({ integration, provider, environment, onRowClick
    ); } + +export function isDemoIntegration(providerId: string) { + return providerId === 'novu-email' || providerId === 'novu-sms'; +} diff --git a/apps/dashboard/src/pages/integrations/utils/is-demo-integration.ts b/apps/dashboard/src/pages/integrations/utils/is-demo-integration.ts deleted file mode 100644 index a0c7f540414..00000000000 --- a/apps/dashboard/src/pages/integrations/utils/is-demo-integration.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isDemoIntegration(providerId: string) { - return providerId === 'novu-email' || providerId === 'novu-sms'; -}