diff --git a/.source b/.source index d034791c4e3..998124fc5c8 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit d034791c4e39516d4a9e4161e632fa7dcef5bb39 +Subproject commit 998124fc5c815f759f366b1c47c0a6e29511571b diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index d136972233e..a10ede1969e 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -89,5 +89,7 @@ PLAIN_IDENTITY_VERIFICATION_SECRET_KEY='PLAIN_IDENTITY_VERIFICATION_SECRET_KEY' PLAIN_CARDS_HMAC_SECRET_KEY='PLAIN_CARDS_HMAC_SECRET_KEY' NOVU_INTERNAL_SECRET_KEY= +NOVU_SECRET_KEY='NOVU_SECRET_KEY' + # expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). Eg: 60, "2 days", "10h", "7d" SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME='15 days' 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 a087fb6336f..1511069c890 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 @@ -334,7 +334,17 @@ describe('EmailOutputRendererUsecase', () => { }); describe('conditional block transformation (showIfKey)', () => { - it('should render content when showIfKey condition is true', async () => { + describe('truthy conditions', () => { + const truthyValues = [ + { value: true, desc: 'boolean true' }, + { value: 1, desc: 'number 1' }, + { value: 'true', desc: 'string "true"' }, + { value: 'TRUE', desc: 'string "TRUE"' }, + { value: 'yes', desc: 'string "yes"' }, + { value: {}, desc: 'empty object' }, + { value: [], desc: 'empty array' }, + ]; + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ @@ -371,27 +381,40 @@ describe('EmailOutputRendererUsecase', () => { ], }; - const renderCommand = { - controlValues: { - subject: 'Conditional Test', - body: JSON.stringify(mockTipTapNode), - }, - fullPayloadForRender: { - ...mockFullPayload, - payload: { - isPremium: true, - }, - }, - }; + truthyValues.forEach(({ value, desc }) => { + it(`should render content when showIfKey is ${desc}`, async () => { + const renderCommand = { + controlValues: { + subject: 'Conditional Test', + body: JSON.stringify(mockTipTapNode), + }, + fullPayloadForRender: { + ...mockFullPayload, + payload: { + isPremium: value, + }, + }, + }; - const result = await emailOutputRendererUsecase.execute(renderCommand); + const result = await emailOutputRendererUsecase.execute(renderCommand); - expect(result.body).to.include('Before condition'); - expect(result.body).to.include('Premium content'); - expect(result.body).to.include('After condition'); + expect(result.body).to.include('Before condition'); + expect(result.body).to.include('Premium content'); + expect(result.body).to.include('After condition'); + }); + }); }); - it('should not render content when showIfKey condition is false', async () => { + describe('falsy conditions', () => { + const falsyValues = [ + { value: false, desc: 'boolean false' }, + { value: 0, desc: 'number 0' }, + { value: '', desc: 'empty string' }, + { value: null, desc: 'null' }, + { value: undefined, desc: 'undefined' }, + { value: 'UNDEFINED', desc: 'string "UNDEFINED"' }, + ]; + const mockTipTapNode: MailyJSONContent = { type: 'doc', content: [ @@ -423,34 +446,33 @@ describe('EmailOutputRendererUsecase', () => { type: 'text', text: 'After condition', }, - { - type: 'text', - text: 'After condition 2', - }, ], }, ], }; - const renderCommand = { - controlValues: { - subject: 'Conditional Test', - body: JSON.stringify(mockTipTapNode), - }, - fullPayloadForRender: { - ...mockFullPayload, - payload: { - isPremium: false, - }, - }, - }; + falsyValues.forEach(({ value, desc }) => { + it(`should not render content when showIfKey is ${desc}`, async () => { + const renderCommand = { + controlValues: { + subject: 'Conditional Test', + body: JSON.stringify(mockTipTapNode), + }, + fullPayloadForRender: { + ...mockFullPayload, + payload: { + isPremium: value, + }, + }, + }; - const result = await emailOutputRendererUsecase.execute(renderCommand); + const result = await emailOutputRendererUsecase.execute(renderCommand); - expect(result.body).to.include('Before condition'); - expect(result.body).to.not.include('Premium content'); - expect(result.body).to.include('After condition'); - expect(result.body).to.include('After condition 2'); + expect(result.body).to.include('Before condition'); + expect(result.body).to.not.include('Premium content'); + expect(result.body).to.include('After condition'); + }); + }); }); it('should handle nested conditional blocks correctly', async () => { 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 70ddc167e0c..2e3d6602657 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 @@ -246,12 +246,15 @@ export class EmailOutputRendererUsecase { }); } - private stringToBoolean(value: unknown): boolean { - if (typeof value === 'string') { - return value.toLowerCase() === 'true'; - } + private stringToBoolean(value: string): boolean { + const normalized = value.toLowerCase().trim(); + if (normalized === 'false' || normalized === 'null' || normalized === 'undefined') return false; - return false; + try { + return Boolean(JSON.parse(normalized)); + } catch { + return Boolean(normalized); + } } private isVariableNode( 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 2f63a2967d4..d2b84b00296 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 @@ -20,6 +20,15 @@ type StringValidation = isValid: false; }; +type BooleanValidation = + | { + isValid: true; + input: boolean; + } + | { + isValid: false; + }; + function validateStringInput(dataInput: unknown, ruleValue: unknown): StringValidation { if (typeof dataInput !== 'string' || typeof ruleValue !== 'string') { return { isValid: false }; @@ -43,6 +52,45 @@ function validateRangeInput(dataInput: unknown, ruleValue: unknown): RangeValida return { isValid: valid, min, max }; } +function validateBooleanInput(dataInput: unknown): BooleanValidation { + if (typeof dataInput !== 'boolean' && dataInput !== 'true' && dataInput !== 'false') { + return { isValid: false }; + } + + return { isValid: true, input: typeof dataInput === 'boolean' ? dataInput : dataInput === 'true' }; +} + +function validateComparison( + a: unknown, + b: unknown +): { isValid: true; a: number | string | boolean; b: number | string | boolean } | { isValid: false } { + // handle boolean values and string representations of booleans + const booleanA = validateBooleanInput(a); + const booleanB = validateBooleanInput(b); + if (booleanA.isValid && booleanB.isValid) { + return { isValid: true, a: booleanA.input, b: booleanB.input }; + } + + // try to convert to numbers if possible + const numA = Number(a); + const numB = Number(b); + if (!Number.isNaN(numA) && !Number.isNaN(numB)) { + return { isValid: true, a: numA, b: numB }; + } + + // handle dates + if (typeof a === 'string' && typeof b === 'string') { + const dateA = new Date(a); + const dateB = new Date(b); + + if (!Number.isNaN(dateA.getTime()) && !Number.isNaN(dateB.getTime())) { + return { isValid: true, a: dateA.getTime(), b: dateB.getTime() }; + } + } + + return { isValid: false }; +} + function createStringOperator(evaluator: (input: string, value: string) => boolean) { return (dataInput: unknown, ruleValue: unknown): boolean => { const validation = validateStringInput(dataInput, ruleValue); @@ -117,6 +165,69 @@ const initializeCustomOperators = (): void => { return dataInput < validation.min || dataInput > validation.max; }); + + jsonLogic.rm_operation('<'); + jsonLogic.add_operation('<', (a: unknown, b: unknown) => { + const validation = validateComparison(a, b); + if (!validation.isValid) return false; + + return validation.a < validation.b; + }); + + jsonLogic.rm_operation('>'); + jsonLogic.add_operation('>', (a: unknown, b: unknown) => { + const validation = validateComparison(a, b); + if (!validation.isValid) return false; + + return validation.a > validation.b; + }); + + jsonLogic.rm_operation('<='); + jsonLogic.add_operation('<=', (first: unknown, second: unknown, third?: unknown) => { + // handle three argument case (typically used in between operations) + if (third !== undefined) { + const validation1 = validateComparison(first, second); + const validation2 = validateComparison(second, third); + if (!validation1.isValid || !validation2.isValid) return false; + + return validation1.a <= validation1.b && validation1.b <= validation2.b; + } + + const validation = validateComparison(first, second); + if (!validation.isValid) return false; + + return validation.a <= validation.b; + }); + + jsonLogic.rm_operation('>='); + jsonLogic.add_operation('>=', (a: unknown, b: unknown) => { + const validation = validateComparison(a, b); + if (!validation.isValid) return false; + + return validation.a >= validation.b; + }); + + jsonLogic.rm_operation('=='); + jsonLogic.add_operation('==', (a: unknown, b: unknown) => { + const validation = validateComparison(a, b); + if (!validation.isValid) { + // fall back to strict equality for other types + return a === b; + } + + return validation.a === validation.b; + }); + + jsonLogic.rm_operation('!='); + jsonLogic.add_operation('!=', (a: unknown, b: unknown) => { + const validation = validateComparison(a, b); + if (!validation.isValid) { + // fall back to strict inequality for other types + return a !== b; + } + + return validation.a !== validation.b; + }); }; initializeCustomOperators(); diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts index 6b621f9e4bf..ccbb4f62d2f 100644 --- a/apps/api/src/app/shared/shared.module.ts +++ b/apps/api/src/app/shared/shared.module.ts @@ -115,12 +115,7 @@ const PROVIDERS = [ ]; const IMPORTS = [ - QueuesModule.forRoot([ - JobTopicNameEnum.EXECUTION_LOG, - JobTopicNameEnum.WEB_SOCKETS, - JobTopicNameEnum.WORKFLOW, - JobTopicNameEnum.INBOUND_PARSE_MAIL, - ]), + QueuesModule.forRoot([JobTopicNameEnum.WEB_SOCKETS, JobTopicNameEnum.WORKFLOW, JobTopicNameEnum.INBOUND_PARSE_MAIL]), LoggerModule.forRoot( createNestLoggingModuleOptions({ serviceName: packageJson.name, diff --git a/apps/api/src/app/support/usecases/plain-cards.usecase.ts b/apps/api/src/app/support/usecases/plain-cards.usecase.ts index 4592ba09f99..0eb5fd1056b 100644 --- a/apps/api/src/app/support/usecases/plain-cards.usecase.ts +++ b/apps/api/src/app/support/usecases/plain-cards.usecase.ts @@ -35,8 +35,7 @@ export class PlainCardsUsecase { private userRepository: UserRepository ) {} async fetchCustomerDetails(command: PlainCardsCommand) { - const key = process.env.NOVU_REGION === 'eu-west-2' ? 'customer-details-eu' : 'customer-details-us'; - + const key = `customer-details-${process.env.NOVU_REGION}`; if (!command?.customer?.externalId) { return { data: {}, @@ -51,7 +50,7 @@ export class PlainCardsUsecase { }, { componentText: { - text: 'This user is not yet registered on Novu', + text: 'This user is not yet registered in this region', }, }, ], @@ -61,6 +60,29 @@ export class PlainCardsUsecase { } const organizations = await this.organizationRepository.findUserActiveOrganizations(command?.customer?.externalId); + if (!organizations) { + return { + data: {}, + cards: [ + { + key, + components: [ + { + componentSpacer: { + spacerSize: 'S', + }, + }, + { + componentText: { + text: 'This user is not yet registered in this region', + }, + }, + ], + }, + ], + }; + } + const sessions = await this.userRepository.findUserSessions(command?.customer?.externalId); return { 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 7ff98a4f61f..a177e31b120 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 @@ -117,12 +117,10 @@ export class BuildStepIssuesUsecase { issues.controls = issues.controls || {}; issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => { - const message = error.message - ? error.message[0].toUpperCase() + error.message.slice(1).split(' line:')[0] - : ''; + const message = error.message ? error.message.split(' line:')[0] : ''; return { - message: `${message} variable: ${error.output}`, + message: `Variable ${error.output} ${message}`.trim(), issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE, variableName: error.output, }; @@ -271,7 +269,7 @@ export class BuildStepIssuesUsecase { error.message?.includes('mailto') && error.message?.includes('https') ) { - return `Invalid URL format. Must be a valid absolute URL, path starting with /, or {{variable}}`; + return `Invalid URL. Must be a valid full URL, path starting with /, or {{variable}}`; } return error.message || 'Invalid value'; diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index a9215e772e0..81cb9386eae 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -165,7 +165,7 @@ function extractProps(template: any): { valid: boolean; props: string[]; error?: return { valid: false, props: [], - error: 'Invalid variable name containing whitespaces. Variables must follow the dot notation', + error: `contains whitespaces. Variables must follow the dot notation (e.g. payload.something)`, }; } @@ -187,7 +187,7 @@ function extractProps(template: any): { valid: boolean; props: string[]; error?: return { valid: false, props: [], - error: `Invalid variable name missing namespace. Variables must follow the dot notation (e.g. payload.${validProps[0]})`, + error: `missing namespace. Variables must follow the dot notation (e.g. payload.${validProps[0] === 'payload' ? 'something' : validProps[0]})`, }; } diff --git a/apps/api/src/app/workflows-v2/util/utils.ts b/apps/api/src/app/workflows-v2/util/utils.ts index 67dd673ec05..b6f57866ad3 100644 --- a/apps/api/src/app/workflows-v2/util/utils.ts +++ b/apps/api/src/app/workflows-v2/util/utils.ts @@ -248,7 +248,7 @@ export function mergeCommonObjectKeys( sourceValue as Record ); } else { - merged[key] = sourceValue ?? targetValue; + merged[key] = key in source ? sourceValue : targetValue; } return merged; diff --git a/apps/api/src/config/env.validators.ts b/apps/api/src/config/env.validators.ts index 2bb75a1a5bb..fa3e94dbcba 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -42,6 +42,7 @@ export const envValidators = { API_ROOT_URL: url(), NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER: str({ default: undefined }), SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME: str({ default: '15 days' }), + NOVU_REGION: str({ default: 'local' }), // Novu Cloud third party services ...(processEnv.IS_SELF_HOSTED !== 'true' && diff --git a/apps/dashboard/src/components/activity/activity-panel.tsx b/apps/dashboard/src/components/activity/activity-panel.tsx index 07f5032b316..714c52a1230 100644 --- a/apps/dashboard/src/components/activity/activity-panel.tsx +++ b/apps/dashboard/src/components/activity/activity-panel.tsx @@ -83,46 +83,42 @@ export function ActivityPanel({ transition={{ duration: 0.5, ease: 'easeOut' }} className="h-full" > -
-
- - - {activity.template?.name || 'Deleted workflow'} - -
- - -
- - Logs -
- - {isMerged && ( -
- { - e.stopPropagation(); - e.preventDefault(); - - if (activity._digestedNotificationId) { - onActivitySelect(activity._digestedNotificationId); - } - }} - description="Remaining execution has been merged to an active Digest of an existing workflow execution." - /> -
+
+ > + + {activity.template?.name || 'Deleted workflow'}
+ + +
+ + Logs +
+ + {isMerged && ( +
+ { + e.stopPropagation(); + e.preventDefault(); + + if (activity._digestedNotificationId) { + onActivitySelect(activity._digestedNotificationId); + } + }} + description="Remaining execution has been merged to an active Digest of an existing workflow execution." + /> +
+ )} + ); } diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index c33db4e0036..f7afd1ca8b4 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -80,7 +80,7 @@ export function ActivityTable({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} - className="flex min-h-full min-w-[800px] flex-1 flex-col" + className="flex min-h-full flex-1 flex-col" > - + + {value} + + ); + + const wrappedChildren = isDeleted ? ( + + {childrenComponent} + Resource was deleted. + + ) : ( + childrenComponent + ); + return (
{label} @@ -31,7 +49,7 @@ export function OverviewItem({ className="text-foreground-600 mr-0 size-3 gap-0 p-0 opacity-0 transition-opacity group-hover:opacity-100" > )} - {children || {value}} + {wrappedChildren}
); diff --git a/apps/dashboard/src/components/create-environment-button.tsx b/apps/dashboard/src/components/create-environment-button.tsx index 6e4c41b0219..8e84f1d749d 100644 --- a/apps/dashboard/src/components/create-environment-button.tsx +++ b/apps/dashboard/src/components/create-environment-button.tsx @@ -22,20 +22,16 @@ 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 { 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 { 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'; const ENVIRONMENT_COLORS = [ '#FF6B6B', // Vibrant Coral @@ -74,16 +70,8 @@ export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) => const { environments = [] } = useFetchEnvironments({ organizationId: currentOrganization?._id }); const [isOpen, setIsOpen] = useState(false); const { mutateAsync, isPending } = useCreateEnvironment(); - const { subscription } = useFetchSubscription(); - const navigate = useNavigate(); const track = useTelemetry(); - const isPaidTier = - subscription?.apiServiceLevel === ApiServiceLevelEnum.BUSINESS || - subscription?.apiServiceLevel === ApiServiceLevelEnum.ENTERPRISE; - const isTrialActive = subscription?.trial?.isActive; - const canCreateEnvironment = isPaidTier && !isTrialActive; - const form = useForm({ resolver: zodResolver(createEnvironmentSchema), defaultValues: { @@ -114,115 +102,87 @@ export const CreateEnvironmentButton = (props: CreateEnvironmentButtonProps) => }; const handleClick = () => { - track(TelemetryEvent.CREATE_ENVIRONMENT_CLICK, { - createAllowed: !!canCreateEnvironment, - }); - - if (!canCreateEnvironment) { - navigate(ROUTES.SETTINGS_BILLING); - return; - } + track(TelemetryEvent.CREATE_ENVIRONMENT_CLICK); 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. - - )} - /> - - -
- - - + + e.preventDefault()}> + + Create environment +
+ + Create a new environment to manage your notifications.{' '} + Learn more + +
+
+ + +
+ - Create environment - -
-
- )} + ( + + Name + + { + field.onChange(e); + }} + /> + + + + )} + /> + ( + + Color + + + + Will be used to identify the environment in the UI. + + )} + /> + + + + + + + +
); }; diff --git a/apps/dashboard/src/components/environments-free-state.tsx b/apps/dashboard/src/components/environments-free-state.tsx new file mode 100644 index 00000000000..2dd5f1e98bc --- /dev/null +++ b/apps/dashboard/src/components/environments-free-state.tsx @@ -0,0 +1,334 @@ +import { useAuth } from '@/context/auth/hooks'; +import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; +import { ROUTES } from '@/utils/routes'; +import { RiBookMarkedLine, RiSparkling2Line } from 'react-icons/ri'; +import { Link, useNavigate } from 'react-router-dom'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; +import { Badge } from './primitives/badge'; +import { Button } from './primitives/button'; +import { LinkButton } from './primitives/button-link'; +import { CopyButton } from './primitives/copy-button'; +import { EnvironmentBranchIcon } from './primitives/environment-branch-icon'; +import { Separator } from './primitives/separator'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './primitives/table'; +import TruncatedText from './truncated-text'; + +export function FreeTierState() { + const track = useTelemetry(); + const navigate = useNavigate(); + const { currentOrganization } = useAuth(); + const { environments = [] } = useFetchEnvironments({ + organizationId: currentOrganization?._id, + }); + const { currentEnvironment } = useEnvironment(); + + return ( +
+
+
+
+
+ +
+

Manage Your Environments

+

+ Create additional environments to test, stage, or experiment without affecting your live systems. +

+
+ YOUR ENVIRONMENTS +
+
+ + + Name + Identifier + + + + {environments.map((environment) => ( + + +
+ +
+ {environment.name} + {environment._id === currentEnvironment?._id && ( + + Current + + )} +
+
+
+ +
+ + {environment.identifier} + + +
+
+
+ ))} +
+
+
+ + +
+ + + + How does this help? + + +
+ + + ); +} + +function EmptyStateSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/environments-list.tsx b/apps/dashboard/src/components/environments-list.tsx index ae55b1257c0..45febe8a8b1 100644 --- a/apps/dashboard/src/components/environments-list.tsx +++ b/apps/dashboard/src/components/environments-list.tsx @@ -1,5 +1,4 @@ -import { useAuth } from '@/context/auth/hooks'; -import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; +import { useEnvironment } from '@/context/environment/hooks'; import { useDeleteEnvironment } from '@/hooks/use-environments'; import { cn } from '@/utils/ui'; import { EnvironmentEnum, IEnvironment, PROTECTED_ENVIRONMENTS } from '@novu/shared'; @@ -45,11 +44,7 @@ const EnvironmentRowSkeleton = () => ( ); -export function EnvironmentsList() { - const { currentOrganization } = useAuth(); - const { environments = [], areEnvironmentsInitialLoading } = useFetchEnvironments({ - organizationId: currentOrganization?._id, - }); +export function EnvironmentsList({ environments, isLoading }: { environments: IEnvironment[]; isLoading: boolean }) { const { currentEnvironment } = useEnvironment(); const [editEnvironment, setEditEnvironment] = useState(); const [deleteEnvironment, setDeleteEnvironment] = useState(); @@ -85,7 +80,7 @@ export function EnvironmentsList() { - {areEnvironmentsInitialLoading + {isLoading ? Array.from({ length: 3 }).map((_, i) => ) : environments.map((environment) => ( diff --git a/apps/dashboard/src/components/in-app-action-dropdown.tsx b/apps/dashboard/src/components/in-app-action-dropdown.tsx index 7f65c9f5bc9..7076a562472 100644 --- a/apps/dashboard/src/components/in-app-action-dropdown.tsx +++ b/apps/dashboard/src/components/in-app-action-dropdown.tsx @@ -26,7 +26,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { RiEdit2Line, RiExpandUpDownLine, RiForbid2Line } from 'react-icons/ri'; import { CompactButton } from './primitives/button-compact'; import { ControlInput } from './primitives/control-input'; -import { InputRoot, InputWrapper } from './primitives/input'; +import { InputRoot } from './primitives/input'; const primaryActionKey = 'primaryAction'; const secondaryActionKey = 'secondaryAction'; @@ -58,27 +58,28 @@ export const InAppActionDropdown = ({ onMenuItemClick }: { onMenuItemClick?: () size="2xs" className="h-6 border-[1px] border-dashed shadow-none ring-0" trailingIcon={RiForbid2Line} + tabIndex={-1} > No action )} {primaryAction && ( - - )} {secondaryAction && ( - - )} - + - + Actions @@ -162,8 +163,11 @@ export const InAppActionDropdown = ({ onMenuItemClick }: { onMenuItemClick?: () ); }; -const ConfigureActionPopover = (props: ComponentProps & { fields: { actionKey: string } }) => { +const ConfigureActionPopover = ( + props: ComponentProps & { title: string; fields: { actionKey: string } } +) => { const { + title, fields: { actionKey }, ...rest } = props; @@ -177,7 +181,7 @@ const ConfigureActionPopover = (props: ComponentProps & {
- Customize button + {title}
& {
- - - + @@ -211,7 +213,6 @@ const ConfigureActionPopover = (props: ComponentProps & { Redirect URL void; variables: LiquidVariable[]; placeholder?: string; autoFocus?: boolean; - size?: 'default' | 'lg'; + size?: 'md' | 'sm'; id?: string; multiline?: boolean; indentWithTab?: boolean; @@ -33,11 +34,12 @@ export function ControlInput({ value, onChange, variables, + className, placeholder, autoFocus, - size = 'default', id, multiline = false, + size = 'sm', indentWithTab, }: ControlInputProps) { const viewRef = useRef(null); @@ -86,12 +88,13 @@ export function ControlInput({ ); return ( -
+
-
-
-
-
- CONFIGURE VARIABLE -
+
+
+
+ CONFIGURE VARIABLE
-
-
+
+
+
+ + +
+ + handleNameChange(e.target.value)} className="h-7 text-sm" /> +
+
+
+ + + +
+ + handleDefaultValueChange(e.target.value)} + className="h-7 text-sm" + /> +
+
+
+ + + +
+ + + + + + + + +
+ +
+ + + No filters found + {filteredFilters.length > 0 && ( + + {filteredFilters.map((filter) => ( + { + handleFilterToggle(filter.value); + setSearchQuery(''); + setIsCommandOpen(false); + const sampleValue = getDefaultSampleValue(filter.value); + if (sampleValue) { + setPreviewValue(sampleValue); + } + }} + > + + + ))} + + )} + +
+
+
+
+
+
+ + + + {filters.length > 0 && ( -
- - handleNameChange(e.target.value)} className="h-7 text-sm" /> +
+ +
+ )} + {filters.length > 0 && showTestValue && (
- handleDefaultValueChange(e.target.value)} + value={previewValue} + onChange={(e) => setPreviewValue(e.target.value)} + placeholder='Enter value (e.g. "text" or [1,2,3] or {"key":"value"})' className="h-7 text-sm" />
-
- - - -
- - - - - - - -
- -
- - - No filters found - {filteredFilters.length > 0 && ( - - {filteredFilters.map((filter) => ( - { - handleFilterToggle(filter.value); - setSearchQuery(''); - setIsCommandOpen(false); - const sampleValue = getDefaultSampleValue(filter.value); - if (sampleValue) { - setPreviewValue(sampleValue); - } - }} - > - - - ))} - - )} - -
-
-
+ {previewValue && ( +
+
- + )} + )} - - - {filters.length > 0 && ( - - -
- - -
-
-
- )} - - {filters.length > 0 && showTestValue && ( - - -
- setPreviewValue(e.target.value)} - placeholder='Enter value (e.g. "text" or [1,2,3] or {"key":"value"})' - className="h-7 text-sm" - /> -
-
- {previewValue && ( -
- -
- )} -
- )} - + + +
+ + +
+
+
+ {showRawLiquid && ( -
- - +
+ handleRawLiquidChange(e.target.value)} + className="h-7 text-sm" + />
- {showRawLiquid && ( - - -
- handleRawLiquidChange(e.target.value)} - className="h-7 text-sm" - /> -
-
-
- )} -
- + )} +
+ -
- -
+
+
diff --git a/apps/dashboard/src/components/primitives/editor.tsx b/apps/dashboard/src/components/primitives/editor.tsx index 89deab88e4f..09537afd26e 100644 --- a/apps/dashboard/src/components/primitives/editor.tsx +++ b/apps/dashboard/src/components/primitives/editor.tsx @@ -1,21 +1,26 @@ +import React, { useCallback, useMemo } from 'react'; +import { flushSync } from 'react-dom'; +import { cva } from 'class-variance-authority'; import { autocompleteFooter, autocompleteHeader, functionIcon } from '@/components/primitives/constants'; import { useDataRef } from '@/hooks/use-data-ref'; import { tags as t } from '@lezer/highlight'; import createTheme from '@uiw/codemirror-themes'; -import { EditorView, ReactCodeMirrorProps, useCodeMirror } from '@uiw/react-codemirror'; -import { cva, VariantProps } from 'class-variance-authority'; -import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { flushSync } from 'react-dom'; +import { + default as CodeMirror, + EditorView, + ReactCodeMirrorProps, + type ReactCodeMirrorRef, +} from '@uiw/react-codemirror'; -const editorVariants = cva('h-full w-full flex-1 [&_.cm-focused]:outline-none', { +const variants = cva('h-full w-full flex-1 [&_.cm-focused]:outline-none', { variants: { size: { - default: 'text-xs [&_.cm-editor]:py-1', - lg: 'text-base [&_.cm-editor]:py-1', + md: 'text-base', + sm: 'text-xs', }, }, defaultVariants: { - size: 'default', + size: 'sm', }, }); @@ -110,6 +115,8 @@ const baseTheme = (options: { multiline?: boolean }) => }, 'div.cm-content': { padding: 0, + whiteSpace: 'preserve nowrap', + width: '1px', // Any width value would do to make the editor work exactly like an input when more text than its width is added }, 'div.cm-gutters': { backgroundColor: 'transparent', @@ -118,30 +125,28 @@ const baseTheme = (options: { multiline?: boolean }) => }, }); -type EditorProps = { +export type EditorProps = { value: string; multiline?: boolean; placeholder?: string; className?: string; - indentWithTab?: boolean; height?: string; onChange?: (value: string) => void; fontFamily?: 'inherit'; -} & ReactCodeMirrorProps & - VariantProps; + size?: 'sm' | 'md'; +} & ReactCodeMirrorProps; -export const Editor = React.forwardRef<{ focus: () => void; blur: () => void }, EditorProps>( +export const Editor = React.forwardRef( ( { value, placeholder, className, height, - size, multiline = false, fontFamily, onChange, - indentWithTab, + size = 'sm', extensions: extensionsProp, basicSetup: basicSetupProp, ...restCodeMirrorProps @@ -149,12 +154,11 @@ export const Editor = React.forwardRef<{ focus: () => void; blur: () => void }, ref ) => { const onChangeRef = useDataRef(onChange); - const editorRef = useRef(null); - const [shouldFocus, setShouldFocus] = useState(false); const extensions = useMemo( () => [...(extensionsProp ?? []), baseTheme({ multiline })], [extensionsProp, multiline] ); + const basicSetup = useMemo( () => ({ lineNumbers: false, @@ -196,41 +200,22 @@ export const Editor = React.forwardRef<{ focus: () => void; blur: () => void }, [onChangeRef] ); - const { setContainer, view } = useCodeMirror({ - extensions, - height, - placeholder, - basicSetup, - container: editorRef.current, - indentWithTab, - value, - onChange: onChangeCallback, - theme, - ...restCodeMirrorProps, - }); - - useImperativeHandle( - ref, - () => ({ - focus: () => setShouldFocus(true), - blur: () => setShouldFocus(false), - }), - [] + return ( + { + console.log('onBlur'); + }} + {...restCodeMirrorProps} + /> ); - - useEffect(() => { - if (editorRef.current) { - setContainer(editorRef.current); - } - }, [setContainer]); - - useLayoutEffect(() => { - if (view && shouldFocus) { - view.focus(); - setShouldFocus(false); - } - }, [shouldFocus, view]); - - return
; } ); diff --git a/apps/dashboard/src/components/primitives/form/avatar-picker.tsx b/apps/dashboard/src/components/primitives/form/avatar-picker.tsx index 8a2b62db602..176a38b3026 100644 --- a/apps/dashboard/src/components/primitives/form/avatar-picker.tsx +++ b/apps/dashboard/src/components/primitives/form/avatar-picker.tsx @@ -1,5 +1,5 @@ import { forwardRef, useMemo, useState } from 'react'; -import { RiEdit2Line, RiErrorWarningFill, RiImageEditFill } from 'react-icons/ri'; +import { RiEdit2Line, RiImageEditFill } from 'react-icons/ri'; import { Avatar, AvatarImage } from '@/components/primitives/avatar'; import { Button } from '@/components/primitives/button'; @@ -9,112 +9,104 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives import { Separator } from '@/components/primitives/separator'; import TextSeparator from '@/components/primitives/text-separator'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; -import { completions } from '@/utils/liquid-autocomplete'; import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; -import { autocompletion } from '@codemirror/autocomplete'; -import { Editor } from '../editor'; -import { InputRoot, InputWrapper } from '../input'; +import { InputRoot } from '../input'; import { useFormField } from './form-context'; +import { ControlInput } from '../control-input'; -const predefinedAvatars = [ - `${window.location.origin}/images/avatar.svg`, - `${window.location.origin}/images/building.svg`, - `${window.location.origin}/images/info.svg`, - `${window.location.origin}/images/speaker.svg`, - `${window.location.origin}/images/confetti.svg`, - `${window.location.origin}/images/novu.svg`, - `${window.location.origin}/images/info-2.svg`, - `${window.location.origin}/images/bell.svg`, - `${window.location.origin}/images/error.svg`, - `${window.location.origin}/images/warning.svg`, - `${window.location.origin}/images/question.svg`, - `${window.location.origin}/images/error-warning.svg`, -]; +const DEFAULT_AVATARS = Object.freeze([ + `/images/avatar.svg`, + `/images/building.svg`, + `/images/info.svg`, + `/images/speaker.svg`, + `/images/confetti.svg`, + `/images/novu.svg`, + `/images/info-2.svg`, + `/images/bell.svg`, + `/images/error.svg`, + `/images/warning.svg`, + `/images/question.svg`, + `/images/error-warning.svg`, +]); type AvatarPickerProps = { name: string; value: string; - onChange?: (value: string) => void; + onChange: (value: string) => void; onPick?: (value: string) => void; }; -export const AvatarPicker = forwardRef( - ({ name, value, onChange, onPick }, ref) => { - const { step } = useWorkflow(); - const variables = useMemo(() => (step ? parseStepVariablesToLiquidVariables(step.variables) : []), [step]); - const [isOpen, setIsOpen] = useState(false); - const { error } = useFormField(); - const extensions = useMemo(() => [autocompletion({ override: [completions(variables)] })], [variables]); +export const AvatarPicker = forwardRef(({ name, value, onChange, onPick }) => { + const { step } = useWorkflow(); + const variables = useMemo(() => (step ? parseStepVariablesToLiquidVariables(step.variables) : []), [step]); + const [isOpen, setIsOpen] = useState(false); + const { error } = useFormField(); - const handlePredefinedAvatarClick = (url: string) => { - onPick?.(url); - setIsOpen(false); - }; + const handlePredefinedAvatarClick = (url: string) => { + onPick?.(url); + setIsOpen(false); + }; - return ( -
- - - - - -
-
-
- Customize avatar -
- -
- - - - - - - -
+ return ( +
+ + + + + +
+
+
+ Select avatar
- -
- {predefinedAvatars.map((url, index) => ( -
+ +
+ {DEFAULT_AVATARS.map((path) => { + const url = `${window.location.origin}${path}`; + return ( + - ))} -
+ ); + })}
- - -
- ); - } -); +
+ + +
+ ); +}); AvatarPicker.displayName = 'AvatarPicker'; diff --git a/apps/dashboard/src/components/primitives/hint.tsx b/apps/dashboard/src/components/primitives/hint.tsx index dc6b1999c08..376f62c3b38 100644 --- a/apps/dashboard/src/components/primitives/hint.tsx +++ b/apps/dashboard/src/components/primitives/hint.tsx @@ -10,7 +10,7 @@ const HINT_ICON_NAME = 'HintIcon'; export const hintVariants = tv({ slots: { root: 'group flex items-center gap-1 text-paragraph-xs text-text-sub', - icon: 'size-4 shrink-0 text-text-soft', + icon: 'size-4 shrink-0 text-text-soft self-start', }, variants: { disabled: { diff --git a/apps/dashboard/src/components/primitives/input.tsx b/apps/dashboard/src/components/primitives/input.tsx index 3940eaf82f6..e1c508a6e2b 100644 --- a/apps/dashboard/src/components/primitives/input.tsx +++ b/apps/dashboard/src/components/primitives/input.tsx @@ -18,6 +18,7 @@ export const inputVariants = tv({ slots: { root: [ // base + 'ring-stroke-soft', 'group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong shadow-xs', 'transition duration-200 ease-out', 'divide-x divide-stroke-soft', @@ -29,6 +30,7 @@ export const inputVariants = tv({ 'hover:shadow-none', // focus 'has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong', + 'focus-within:shadow-button-important-focus focus-within:before:ring-stroke-strong', // disabled 'has-[input:disabled]:shadow-none', ], @@ -128,6 +130,7 @@ export const inputVariants = tv({ 'hover:before:ring-error-base hover:[&:not(&:has(input:focus)):has(>:only-child)]:before:ring-error-base', // focus 'has-[input:focus]:shadow-button-error-focus has-[input:focus]:before:ring-error-base', + 'focus-within:shadow-button-error-focus focus-within:before:ring-error-base', ], }, false: { diff --git a/apps/dashboard/src/components/primitives/select.tsx b/apps/dashboard/src/components/primitives/select.tsx index 8d31d59d445..a0bc6083568 100644 --- a/apps/dashboard/src/components/primitives/select.tsx +++ b/apps/dashboard/src/components/primitives/select.tsx @@ -127,7 +127,7 @@ const SelectItem = React.forwardRef< - {children} + {children} )); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 4179ff453fb..b2e658aee90 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -1,10 +1,9 @@ import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { Badge } from '@/components/primitives/badge'; 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 { @@ -39,7 +38,6 @@ 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(); @@ -93,22 +91,21 @@ export const SideNavigation = () => { - - - Integration Store - API Keys - {isEnvironmentManagementEnabled && ( - - - Environments - - )} + + + Environments + + New + + + + + Integration Store + diff --git a/apps/dashboard/src/components/workflow-editor/schema.ts b/apps/dashboard/src/components/workflow-editor/schema.ts index bb686aeb905..41875eebd86 100644 --- a/apps/dashboard/src/components/workflow-editor/schema.ts +++ b/apps/dashboard/src/components/workflow-editor/schema.ts @@ -1,6 +1,5 @@ import * as z from 'zod'; import { type JSONSchemaDefinition, ChannelTypeEnum } from '@novu/shared'; -import type { ZodValue } from '@/utils/schema'; export const MAX_TAG_ELEMENTS = 16; export const MAX_TAG_LENGTH = 32; @@ -24,12 +23,10 @@ export const workflowSchema = z.object({ description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(), }); -export const buildStepSchema = (controlsSchema?: ZodValue) => - z.object({ - name: z.string().min(1).max(MAX_NAME_LENGTH), - stepId: z.string(), - ...(controlsSchema ? { controlValues: controlsSchema } : {}), - }); +export const stepSchema = z.object({ + name: z.string().min(1).max(MAX_NAME_LENGTH), + stepId: z.string(), +}); export const buildDynamicFormSchema = ({ to, diff --git a/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx b/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx index ef822744906..a52eaca0a93 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx @@ -6,7 +6,7 @@ import { FormControl, FormField, FormItem, FormMessage } from '@/components/prim import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; import { capitalize } from '@/utils/string'; -import { InputRoot, InputWrapper } from '../../../primitives/input'; +import { InputRoot } from '../../../primitives/input'; const bodyKey = 'body'; @@ -23,16 +23,15 @@ export const BaseBody = () => { - - - + {`You can use variables by typing {{ select from the list or create a new one.`} diff --git a/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx b/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx index 3a7f4ff5c6e..b4d979f6894 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx @@ -6,7 +6,7 @@ import { FormControl, FormField, FormItem, FormMessage } from '@/components/prim import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; import { capitalize } from '@/utils/string'; -import { InputRoot, InputWrapper } from '../../../primitives/input'; +import { InputRoot } from '../../../primitives/input'; const subjectKey = 'subject'; @@ -23,17 +23,15 @@ export const BaseSubject = () => { - - - + diff --git a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx index b4795dcc3a0..9de77622a45 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx @@ -13,7 +13,7 @@ export const ChatEditor = (props: ChatEditorProps) => {
Chat template editor
-
+
{getComponentByType({ component: body.component })}
diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx index 847f41e0001..6cdbf118ac0 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx @@ -21,8 +21,11 @@ import { import { Link, useNavigate } from 'react-router-dom'; import { parseJsonLogic } from 'react-querybuilder/parseJsonLogic'; import { RQBJsonLogic } from 'react-querybuilder'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { ConfirmationModal } from '@/components/confirmation-modal'; +import { stepSchema } from '@/components/workflow-editor/schema'; import { PageMeta } from '@/components/page-meta'; import { Button } from '@/components/primitives/button'; import { CopyButton } from '@/components/primitives/copy-button'; @@ -146,15 +149,17 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { [step, registerInlineControlValues] ); - const form = useForm({ + const form = useForm>({ defaultValues, shouldFocusError: false, + resolver: zodResolver(stepSchema), }); const { onBlur, saveForm } = useFormAutosave({ previousData: defaultValues, form, isReadOnly, + shouldClientValidate: true, save: (data) => { // transform form fields to step update dto const updateStepData: Partial = { @@ -179,6 +184,7 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { Object.values(currentErrors).forEach((controlValues) => { Object.keys(controlValues).forEach((key) => { if (!stepIssues[`${key}`]) { + // @ts-expect-error form.clearErrors(`controlValues.${key}`); } }); @@ -186,6 +192,7 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { // Set new errors from stepIssues Object.entries(stepIssues).forEach(([key, value]) => { + // @ts-expect-error form.setError(`controlValues.${key}`, { message: value }); }); }, [form, step]); diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx index bdcfec57ed1..b6e11fd3b23 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/text-widget.tsx @@ -56,7 +56,7 @@ export function TextWidget(props: WidgetProps) { value={field.value} onChange={field.onChange} variables={variables} - size="default" + size="sm" /> diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx index ccc4d5b4edb..4bc4178f145 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-key.tsx @@ -40,7 +40,7 @@ export const DigestKey = () => { value={field.value} onChange={field.onChange} variables={variables} - size="default" + size="md" /> diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-subject.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-subject.tsx index 0f6e95e6545..ccab0fd2e45 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-subject.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-subject.tsx @@ -22,7 +22,7 @@ export const EmailSubject = () => { { - - - + {`Type {{ for variables, or wrap text in ** for bold.`} 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 6ecf8455eb3..4ffcd374e33 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 @@ -41,9 +41,9 @@ export const InAppEditor = ({ uiSchema }: { uiSchema: UiSchema }) => { component: disableOutputSanitization.component, })}
-
+
{(avatar || subject) && ( -
+
{avatar && getComponentByType({ component: avatar.component })} {subject && getComponentByType({ component: subject.component })}
diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-redirect.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-redirect.tsx index f7454f0d6a7..ec46be88d1a 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-redirect.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-redirect.tsx @@ -21,7 +21,6 @@ export const InAppRedirect = () => { { - - - + diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx index 6b0db9f645d..97d12093580 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx @@ -15,7 +15,7 @@ export const PushEditor = (props: PushEditorProps) => {
Push template editor
-
+
{getComponentByType({ component: subject.component })} {getComponentByType({ component: body.component })}
diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx index 4f7b81f5e9e..4074d75dc4d 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx @@ -17,7 +17,7 @@ export const SmsEditor = (props: SmsEditorProps) => { SMS template editor
-
+
{getComponentByType({ component: body.component })}
diff --git a/apps/dashboard/src/components/workflow-editor/url-input.tsx b/apps/dashboard/src/components/workflow-editor/url-input.tsx index 8d2d6221356..525bd318561 100644 --- a/apps/dashboard/src/components/workflow-editor/url-input.tsx +++ b/apps/dashboard/src/components/workflow-editor/url-input.tsx @@ -2,14 +2,13 @@ import { useFormContext } from 'react-hook-form'; import { ControlInput } from '@/components/primitives/control-input'; import { FormControl, FormField, FormItem, FormMessagePure } from '@/components/primitives/form/form'; -import { Input, InputProps, InputRoot, InputWrapper } from '@/components/primitives/input'; +import { InputProps, InputRoot } from '@/components/primitives/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context'; import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables'; type URLInputProps = Omit & { options: string[]; - asEditor?: boolean; withHint?: boolean; fields: { urlKey: string; @@ -20,7 +19,6 @@ type URLInputProps = Omit & { export const URLInput = ({ options, - asEditor = false, placeholder, fields: { urlKey, targetKey }, withHint = true, @@ -36,58 +34,52 @@ export const URLInput = ({
- ( - - {asEditor ? ( - - - - - - ) : ( - - )} - - )} - /> - ( - - - - - - )} - /> + onChange={field.onChange} + variables={variables} + /> + + )} + /> + ( + + + + + + )} + /> +
diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 998d7cb0d0a..bfacdf403b5 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -42,7 +42,7 @@ export function ActivityFeed() { />
- + - + { track(TelemetryEvent.ENVIRONMENTS_PAGE_VIEWED); @@ -17,12 +31,16 @@ export function EnvironmentsPage() { <> Environments}> -
-
- + {canAccessEnvironments ? ( +
+
+ +
+
- -
+ ) : ( + + )} ); diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index 495322002dd..ae02e7db394 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -5,11 +5,10 @@ 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 { StepTypeEnum } from '@novu/shared'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { @@ -44,7 +43,6 @@ export const WorkflowsPage = () => { const { environmentSlug } = useParams(); const track = useTelemetry(); const navigate = useNavigate(); - const isTemplateStoreEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_TEMPLATE_STORE_ENABLED); const [searchParams, setSearchParams] = useSearchParams({ orderDirection: 'desc', orderBy: 'updatedAt', @@ -101,8 +99,7 @@ export const WorkflowsPage = () => { const hasActiveFilters = searchParams.get('query') && searchParams.get('query') !== null; - const shouldShowStartWith = - isTemplateStoreEnabled && workflowsData && workflowsData.totalCount < 5 && !hasActiveFilters; + const shouldShowStartWith = workflowsData && workflowsData.totalCount < 5 && !hasActiveFilters; useEffect(() => { track(TelemetryEvent.WORKFLOWS_PAGE_VISIT); @@ -133,84 +130,76 @@ export const WorkflowsPage = () => { name="query" render={({ field }) => ( - + )} /> - {isTemplateStoreEnabled ? ( - - - - - - - - - - - -
{ - track(TelemetryEvent.CREATE_WORKFLOW_CLICK); - navigate(buildRoute(ROUTES.WORKFLOWS_CREATE, { environmentSlug: environmentSlug || '' })); - }} - > -
- - Blank Workflow -
-
-
- { - navigate( - buildRoute(ROUTES.TEMPLATE_STORE, { - environmentSlug: environmentSlug || '', - }) + '?source=create-workflow-dropdown' - ); + + + + + + + + + + + +
{ + track(TelemetryEvent.CREATE_WORKFLOW_CLICK); + navigate(buildRoute(ROUTES.WORKFLOWS_CREATE, { environmentSlug: environmentSlug || '' })); }} > - - View Workflow Gallery - - - - - - ) : ( - - )} +
+ + Blank Workflow +
+
+
+ { + navigate( + buildRoute(ROUTES.TEMPLATE_STORE, { + environmentSlug: environmentSlug || '', + }) + '?source=create-workflow-dropdown' + ); + }} + > + + View Workflow Gallery + +
+
+
+
{shouldShowStartWith && (
diff --git a/apps/dashboard/src/utils/telemetry.ts b/apps/dashboard/src/utils/telemetry.ts index c1dfc3c7c9a..95e1dc69d5a 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -47,4 +47,5 @@ export enum TelemetryEvent { TEMPLATE_WORKFLOW_CLICK = 'Template Workflow Click', ENVIRONMENTS_PAGE_VIEWED = 'Environments Page Viewed', CREATE_ENVIRONMENT_CLICK = 'Create Environment Click', + UPGRADE_TO_BUSINESS_TIER_CLICK = 'Upgrade to Business Tier Click', } diff --git a/apps/worker/src/app/workflow/services/execution-log.worker.spec.ts b/apps/worker/src/app/workflow/services/execution-log.worker.spec.ts deleted file mode 100644 index 0b8a094c86b..00000000000 --- a/apps/worker/src/app/workflow/services/execution-log.worker.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { expect } from 'chai'; -import { setTimeout } from 'timers/promises'; - -import { - TriggerEvent, - ExecutionLogQueueService, - CreateExecutionDetails, - WorkflowInMemoryProviderService, -} from '@novu/application-generic'; - -import { ExecutionLogWorker } from './execution-log.worker'; - -import { WorkflowModule } from '../workflow.module'; - -let executionLogQueueService: ExecutionLogQueueService; -let executionLogWorker: ExecutionLogWorker; - -describe('ExecutionLog Worker', () => { - before(async () => { - process.env.IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; - process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; - - const moduleRef = await Test.createTestingModule({ - imports: [WorkflowModule], - }).compile(); - - const createExecutionDetails = moduleRef.get(CreateExecutionDetails); - const workflowInMemoryProviderService = moduleRef.get( - WorkflowInMemoryProviderService - ); - - executionLogWorker = new ExecutionLogWorker(createExecutionDetails, workflowInMemoryProviderService); - - executionLogQueueService = new ExecutionLogQueueService(workflowInMemoryProviderService); - await executionLogQueueService.queue.obliterate(); - }); - - after(async () => { - await executionLogQueueService.queue.drain(); - await executionLogWorker.gracefulShutdown(); - }); - - it('should be initialised properly', async () => { - expect(executionLogWorker).to.be.ok; - expect(await executionLogWorker.bullMqService.getStatus()).to.deep.equal({ - queueIsPaused: undefined, - queueName: undefined, - workerName: 'execution-logs', - workerIsPaused: false, - workerIsRunning: true, - }); - expect(executionLogWorker.worker.opts).to.deep.include({ - concurrency: 200, - lockDuration: 90000, - }); - }); - - it('should be able to automatically pull a job from the queue', async () => { - const existingJobs = await executionLogQueueService.queue.getJobs(); - expect(existingJobs.length).to.equal(0); - - const jobId = 'execution-logs-queue-job-id'; - const _environmentId = 'execution-logs-queue-environment-id'; - const _organizationId = 'execution-logs-queue-organization-id'; - const _userId = 'execution-logs-queue-user-id'; - const jobData = { - _id: jobId, - test: 'execution-logs-queue-job-data', - _environmentId, - _organizationId, - _userId, - } as any; - - await executionLogQueueService.add({ name: jobId, data: jobData, groupId: _organizationId }); - - expect(await executionLogQueueService.queue.getActiveCount()).to.equal(1); - expect(await executionLogQueueService.queue.getWaitingCount()).to.equal(0); - - // When we arrive to pull the job it has been already pulled by the worker - const nextJob = await executionLogWorker.worker.getNextJob(jobId); - expect(nextJob).to.equal(undefined); - - await setTimeout(100); - - // No jobs left in queue - const queueJobs = await executionLogQueueService.queue.getJobs(); - expect(queueJobs.length).to.equal(0); - }); - - it('should pause the worker', async () => { - const isPaused = await executionLogWorker.worker.isPaused(); - expect(isPaused).to.equal(false); - - const runningStatus = await executionLogWorker.bullMqService.getStatus(); - expect(runningStatus).to.deep.equal({ - queueIsPaused: undefined, - queueName: undefined, - workerName: 'execution-logs', - workerIsPaused: false, - workerIsRunning: true, - }); - - await executionLogWorker.pause(); - - const isNowPaused = await executionLogWorker.worker.isPaused(); - expect(isNowPaused).to.equal(true); - - const runningStatusChanged = await executionLogWorker.bullMqService.getStatus(); - expect(runningStatusChanged).to.deep.equal({ - queueIsPaused: undefined, - queueName: undefined, - workerName: 'execution-logs', - workerIsPaused: true, - workerIsRunning: true, - }); - }); - - it('should resume the worker', async () => { - await executionLogWorker.pause(); - - const isPaused = await executionLogWorker.worker.isPaused(); - expect(isPaused).to.equal(true); - - const runningStatus = await executionLogWorker.bullMqService.getStatus(); - expect(runningStatus).to.deep.equal({ - queueIsPaused: undefined, - queueName: undefined, - workerName: 'execution-logs', - workerIsPaused: true, - workerIsRunning: true, - }); - - await executionLogWorker.resume(); - - const isNowPaused = await executionLogWorker.worker.isPaused(); - expect(isNowPaused).to.equal(false); - - const runningStatusChanged = await executionLogWorker.bullMqService.getStatus(); - expect(runningStatusChanged).to.deep.equal({ - queueIsPaused: undefined, - queueName: undefined, - workerName: 'execution-logs', - workerIsPaused: false, - workerIsRunning: true, - }); - }); -}); diff --git a/apps/worker/src/app/workflow/services/execution-log.worker.ts b/apps/worker/src/app/workflow/services/execution-log.worker.ts deleted file mode 100644 index 1021c7f5c0c..00000000000 --- a/apps/worker/src/app/workflow/services/execution-log.worker.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - getExecutionLogWorkerOptions, - PinoLogger, - storage, - Store, - ExecutionLogWorkerService, - WorkerOptions, - WorkerProcessor, - CreateExecutionDetails, - BullMqService, - WorkflowInMemoryProviderService, - IExecutionLogJobDataDto, -} from '@novu/application-generic'; -import { ObservabilityBackgroundTransactionEnum } from '@novu/shared'; - -const nr = require('newrelic'); - -const LOG_CONTEXT = 'ExecutionLogWorker'; - -@Injectable() -export class ExecutionLogWorker extends ExecutionLogWorkerService { - constructor( - private createExecutionDetails: CreateExecutionDetails, - public workflowInMemoryProviderService: WorkflowInMemoryProviderService - ) { - super(new BullMqService(workflowInMemoryProviderService)); - this.initWorker(this.getWorkerProcessor(), this.getWorkerOptions()); - } - - private getWorkerOptions(): WorkerOptions { - return getExecutionLogWorkerOptions(); - } - - private getWorkerProcessor(): WorkerProcessor { - return async ({ data }: { data: IExecutionLogJobDataDto }) => { - return await new Promise((resolve, reject) => { - const _this = this; - - Logger.verbose(`Job ${data.jobId} is being inserted into execution details collection`, LOG_CONTEXT); - - nr.startBackgroundTransaction( - ObservabilityBackgroundTransactionEnum.EXECUTION_LOG_QUEUE, - 'Trigger Engine', - function processTask() { - const transaction = nr.getTransaction(); - - storage.run(new Store(PinoLogger.root), () => { - _this.createExecutionDetails - .execute(data) - .then(resolve) - .catch((e) => { - reject(e); - }) - .finally(() => { - transaction.end(); - }); - }); - } - ); - }); - }; - } -} diff --git a/apps/worker/src/app/workflow/services/index.ts b/apps/worker/src/app/workflow/services/index.ts index 400282e4d8d..55a39c4c557 100644 --- a/apps/worker/src/app/workflow/services/index.ts +++ b/apps/worker/src/app/workflow/services/index.ts @@ -1,4 +1,4 @@ export * from './active-jobs-metric.service'; export * from './standard.worker'; +export * from './subscriber-process.worker'; export * from './workflow.worker'; -export * from './execution-log.worker'; diff --git a/apps/worker/src/config/worker-init.config.ts b/apps/worker/src/config/worker-init.config.ts index 8e8ae2ca20e..a2f1576ebba 100644 --- a/apps/worker/src/config/worker-init.config.ts +++ b/apps/worker/src/config/worker-init.config.ts @@ -2,14 +2,13 @@ import { Provider } from '@nestjs/common'; import { JobTopicNameEnum } from '@novu/shared'; -import { ExecutionLogWorker, StandardWorker, WorkflowWorker } from '../app/workflow/services'; +import { StandardWorker, WorkflowWorker } from '../app/workflow/services'; import { SubscriberProcessWorker } from '../app/workflow/services/subscriber-process.worker'; import { InboundParseWorker } from '../app/workflow/workers/inbound-parse.worker.service'; type WorkerClass = | typeof StandardWorker | typeof WorkflowWorker - | typeof ExecutionLogWorker | typeof SubscriberProcessWorker | typeof InboundParseWorker; @@ -20,39 +19,15 @@ type WorkerDepTree = Partial>; export const WORKER_MAPPING: WorkerDepTree = { [JobTopicNameEnum.STANDARD]: { workerClass: StandardWorker, - queueDependencies: [ - JobTopicNameEnum.EXECUTION_LOG, - JobTopicNameEnum.WEB_SOCKETS, - JobTopicNameEnum.STANDARD, - JobTopicNameEnum.PROCESS_SUBSCRIBER, - ], + queueDependencies: [JobTopicNameEnum.WEB_SOCKETS, JobTopicNameEnum.STANDARD, JobTopicNameEnum.PROCESS_SUBSCRIBER], }, [JobTopicNameEnum.WORKFLOW]: { workerClass: WorkflowWorker, - queueDependencies: [ - JobTopicNameEnum.EXECUTION_LOG, - JobTopicNameEnum.PROCESS_SUBSCRIBER, - JobTopicNameEnum.STANDARD, - JobTopicNameEnum.WEB_SOCKETS, - ], - }, - [JobTopicNameEnum.EXECUTION_LOG]: { - workerClass: ExecutionLogWorker, - queueDependencies: [ - JobTopicNameEnum.EXECUTION_LOG, - JobTopicNameEnum.STANDARD, - JobTopicNameEnum.WEB_SOCKETS, - JobTopicNameEnum.PROCESS_SUBSCRIBER, - ], + queueDependencies: [JobTopicNameEnum.PROCESS_SUBSCRIBER, JobTopicNameEnum.STANDARD, JobTopicNameEnum.WEB_SOCKETS], }, [JobTopicNameEnum.PROCESS_SUBSCRIBER]: { workerClass: SubscriberProcessWorker, - queueDependencies: [ - JobTopicNameEnum.EXECUTION_LOG, - JobTopicNameEnum.STANDARD, - JobTopicNameEnum.WEB_SOCKETS, - JobTopicNameEnum.PROCESS_SUBSCRIBER, - ], + queueDependencies: [JobTopicNameEnum.STANDARD, JobTopicNameEnum.WEB_SOCKETS, JobTopicNameEnum.PROCESS_SUBSCRIBER], }, [JobTopicNameEnum.INBOUND_PARSE_MAIL]: { workerClass: InboundParseWorker, @@ -88,7 +63,7 @@ export const UNIQUE_WORKER_DEPENDENCIES = [...new Set(WORKER_DEPENDENCIES)]; export const ACTIVE_WORKERS: Provider[] | any[] = []; if (!workersToProcess.length) { - ACTIVE_WORKERS.push(StandardWorker, WorkflowWorker, ExecutionLogWorker, SubscriberProcessWorker, InboundParseWorker); + ACTIVE_WORKERS.push(StandardWorker, WorkflowWorker, SubscriberProcessWorker, InboundParseWorker); } else { workersToProcess.forEach((queue) => { const workerClass = WORKER_MAPPING[queue]?.workerClass; diff --git a/libs/application-generic/src/config/workers.ts b/libs/application-generic/src/config/workers.ts index 66cb915f06f..3278691f6e3 100644 --- a/libs/application-generic/src/config/workers.ts +++ b/libs/application-generic/src/config/workers.ts @@ -4,7 +4,6 @@ enum WorkerEnum { STANDARD = 'StandardWorker', WEB_SOCKET = 'WebSocketWorker', WORKFLOW = 'WorkflowWorker', - EXECUTION_LOG = 'ExecutionLogWorker', } interface IWorkerConfig { @@ -12,8 +11,6 @@ interface IWorkerConfig { lockDuration: number; } -type WorkersConfig = Record; - const getDefaultConcurrency = () => process.env.WORKER_DEFAULT_CONCURRENCY ? Number(process.env.WORKER_DEFAULT_CONCURRENCY) @@ -46,10 +43,6 @@ const getWorkerConfig = (worker: WorkerEnum): IWorkerConfig => { concurrency: getDefaultConcurrency() ?? 200, lockDuration: getDefaultLockDuration() ?? 90000, }, - [WorkerEnum.EXECUTION_LOG]: { - concurrency: getDefaultConcurrency() ?? 200, - lockDuration: getDefaultLockDuration() ?? 90000, - }, }; return workersConfig[worker]; @@ -69,6 +62,3 @@ export const getWebSocketWorkerOptions = () => export const getWorkflowWorkerOptions = () => getWorkerConfig(WorkerEnum.WORKFLOW); - -export const getExecutionLogWorkerOptions = () => - getWorkerConfig(WorkerEnum.EXECUTION_LOG); diff --git a/libs/application-generic/src/dtos/execution-log-job.dto.ts b/libs/application-generic/src/dtos/execution-log-job.dto.ts deleted file mode 100644 index 2ab42d59a9a..00000000000 --- a/libs/application-generic/src/dtos/execution-log-job.dto.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - ExecutionDetailsSourceEnum, - ExecutionDetailsStatusEnum, - StepTypeEnum, -} from '@novu/shared'; -import { EmailEventStatusEnum, SmsEventStatusEnum } from '@novu/stateless'; - -import { - IBulkJobParams, - IJobParams, -} from '../services/queues/queue-base.service'; - -export interface IExecutionLogJobDataDto { - environmentId: string; - - organizationId: string; - - subscriberId: string; - - jobId?: string; - - notificationId: string; - - notificationTemplateId?: string; - - messageId?: string; - - providerId?: string; - - transactionId: string; - - channel?: StepTypeEnum; - - detail: string; - - source: ExecutionDetailsSourceEnum; - - status: ExecutionDetailsStatusEnum; - - isTest: boolean; - - isRetry: boolean; - - raw?: string | null; - - _subscriberId?: string; - - _id?: string; - - createdAt?: Date; - - webhookStatus?: EmailEventStatusEnum | SmsEventStatusEnum; -} - -export interface IExecutionLogJobDto extends IJobParams { - data?: IExecutionLogJobDataDto; -} - -export interface IExecutionLogBulkJobDto extends IBulkJobParams { - data: IExecutionLogJobDataDto; -} diff --git a/libs/application-generic/src/dtos/index.ts b/libs/application-generic/src/dtos/index.ts index 0a72aad3602..c88117f8874 100644 --- a/libs/application-generic/src/dtos/index.ts +++ b/libs/application-generic/src/dtos/index.ts @@ -3,4 +3,3 @@ export * from './process-subscriber-job.dto'; export * from './standard-job.dto'; export * from './web-sockets-job.dto'; export * from './workflow-job.dto'; -export * from './execution-log-job.dto'; diff --git a/libs/application-generic/src/modules/queues.module.ts b/libs/application-generic/src/modules/queues.module.ts index 8cfcf7e28e5..5c63c7d4928 100644 --- a/libs/application-generic/src/modules/queues.module.ts +++ b/libs/application-generic/src/modules/queues.module.ts @@ -17,7 +17,6 @@ import { import { ReadinessService, WorkflowInMemoryProviderService } from '../services'; import { ActiveJobsMetricQueueService, - ExecutionLogQueueService, InboundParseQueueService, StandardQueueService, SubscriberProcessQueueService, @@ -96,10 +95,6 @@ export class QueuesModule implements OnApplicationShutdown { SubscriberProcessQueueHealthIndicator, ); break; - case JobTopicNameEnum.EXECUTION_LOG: - tokenList.push(ExecutionLogQueueService); - DYNAMIC_PROVIDERS.push(ExecutionLogQueueService); - break; case JobTopicNameEnum.ACTIVE_JOBS_METRIC: healthIndicators.push(ActiveJobsMetricQueueServiceHealthIndicator); tokenList.push(ActiveJobsMetricQueueService); diff --git a/libs/application-generic/src/services/queues/execution-log-queue.service.spec.ts b/libs/application-generic/src/services/queues/execution-log-queue.service.spec.ts deleted file mode 100644 index 7ff5cb43c22..00000000000 --- a/libs/application-generic/src/services/queues/execution-log-queue.service.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { ExecutionLogQueueService } from './execution-log-queue.service'; -import { BullMqService } from '../bull-mq'; -import { WorkflowInMemoryProviderService } from '../in-memory-provider'; - -let executionLogQueueService: ExecutionLogQueueService; - -describe('Execution Log Queue service', () => { - describe('General', () => { - beforeAll(async () => { - executionLogQueueService = new ExecutionLogQueueService( - new WorkflowInMemoryProviderService(), - ); - await executionLogQueueService.queue.drain(); - }); - - beforeEach(async () => { - await executionLogQueueService.queue.drain(); - }); - - afterEach(async () => { - await executionLogQueueService.queue.drain(); - }); - - afterAll(async () => { - await executionLogQueueService.gracefulShutdown(); - }); - - it('should be initialised properly', async () => { - expect(executionLogQueueService).toBeDefined(); - expect(Object.keys(executionLogQueueService)).toEqual( - expect.arrayContaining([ - 'topic', - 'DEFAULT_ATTEMPTS', - 'instance', - 'queue', - ]), - ); - expect(executionLogQueueService.DEFAULT_ATTEMPTS).toEqual(3); - expect(executionLogQueueService.topic).toEqual('execution-logs'); - expect(await executionLogQueueService.getStatus()).toEqual({ - queueIsPaused: false, - queueName: 'execution-logs', - workerName: undefined, - workerIsPaused: undefined, - workerIsRunning: undefined, - }); - expect(await executionLogQueueService.isPaused()).toEqual(false); - expect(executionLogQueueService.queue).toMatchObject( - expect.objectContaining({ - _events: {}, - _eventsCount: 0, - _maxListeners: undefined, - name: 'execution-logs', - jobsOpts: { - removeOnComplete: true, - }, - }), - ); - expect(executionLogQueueService.queue.opts.prefix).toEqual('bull'); - }); - }); - - describe('Cluster mode', () => { - beforeAll(async () => { - process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'true'; - - executionLogQueueService = new ExecutionLogQueueService( - new WorkflowInMemoryProviderService(), - ); - await executionLogQueueService.queue.obliterate(); - }); - - afterAll(async () => { - await executionLogQueueService.gracefulShutdown(); - process.env.IS_IN_MEMORY_CLUSTER_MODE_ENABLED = 'false'; - }); - - it('should have prefix in cluster mode', async () => { - expect(executionLogQueueService.queue.opts.prefix).toEqual( - '{execution-logs}', - ); - }); - }); -}); diff --git a/libs/application-generic/src/services/queues/execution-log-queue.service.ts b/libs/application-generic/src/services/queues/execution-log-queue.service.ts deleted file mode 100644 index 412b19df237..00000000000 --- a/libs/application-generic/src/services/queues/execution-log-queue.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; -import { JobTopicNameEnum } from '@novu/shared'; - -import { QueueBaseService } from './queue-base.service'; -import { BullMqService } from '../bull-mq'; -import { WorkflowInMemoryProviderService } from '../in-memory-provider'; -import { - IProcessSubscriberBulkJobDto, - IProcessSubscriberJobDto, -} from '../../dtos'; -import { - IExecutionLogBulkJobDto, - IExecutionLogJobDto, -} from '../../dtos/execution-log-job.dto'; - -const LOG_CONTEXT = 'ExecutionLogQueueService'; - -@Injectable() -export class ExecutionLogQueueService extends QueueBaseService { - constructor( - public workflowInMemoryProviderService: WorkflowInMemoryProviderService, - ) { - super( - JobTopicNameEnum.EXECUTION_LOG, - new BullMqService(workflowInMemoryProviderService), - ); - - Logger.log(`Creating queue ${this.topic}`, LOG_CONTEXT); - - this.createQueue(); - } - - public async add(data: IExecutionLogJobDto) { - return await super.add(data); - } - - public async addBulk(data: IExecutionLogBulkJobDto[]) { - return await super.addBulk(data); - } -} diff --git a/libs/application-generic/src/services/queues/index.ts b/libs/application-generic/src/services/queues/index.ts index f7d82ab9e0d..34c1d9535a4 100644 --- a/libs/application-generic/src/services/queues/index.ts +++ b/libs/application-generic/src/services/queues/index.ts @@ -1,20 +1,7 @@ -import { QueueBaseService } from './queue-base.service'; - -import { ActiveJobsMetricQueueService } from './active-jobs-metric-queue.service'; -import { InboundParseQueueService } from './inbound-parse-queue.service'; -import { StandardQueueService } from './standard-queue.service'; -import { WebSocketsQueueService } from './web-sockets-queue.service'; -import { WorkflowQueueService } from './workflow-queue.service'; -import { SubscriberProcessQueueService } from './subscriber-process-queue.service'; -import { ExecutionLogQueueService } from './execution-log-queue.service'; - -export { - QueueBaseService, - ActiveJobsMetricQueueService, - InboundParseQueueService, - StandardQueueService, - WebSocketsQueueService, - WorkflowQueueService, - SubscriberProcessQueueService, - ExecutionLogQueueService, -}; +export { QueueBaseService } from './queue-base.service'; +export { ActiveJobsMetricQueueService } from './active-jobs-metric-queue.service'; +export { InboundParseQueueService } from './inbound-parse-queue.service'; +export { StandardQueueService } from './standard-queue.service'; +export { WebSocketsQueueService } from './web-sockets-queue.service'; +export { WorkflowQueueService } from './workflow-queue.service'; +export { SubscriberProcessQueueService } from './subscriber-process-queue.service'; diff --git a/libs/application-generic/src/services/workers/execution-log-worker.service.ts b/libs/application-generic/src/services/workers/execution-log-worker.service.ts deleted file mode 100644 index a2c77527a47..00000000000 --- a/libs/application-generic/src/services/workers/execution-log-worker.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { JobTopicNameEnum } from '@novu/shared'; - -import { WorkerBaseService } from './index'; -import { BullMqService } from '../bull-mq'; - -const LOG_CONTEXT = 'ExecutionLogWorkerService'; - -export class ExecutionLogWorkerService extends WorkerBaseService { - constructor(public bullMqService: BullMqService) { - super(JobTopicNameEnum.EXECUTION_LOG, bullMqService); - } -} diff --git a/libs/application-generic/src/services/workers/index.ts b/libs/application-generic/src/services/workers/index.ts index 63a19599279..8801a0e9abd 100644 --- a/libs/application-generic/src/services/workers/index.ts +++ b/libs/application-generic/src/services/workers/index.ts @@ -1,24 +1,10 @@ -import { - WorkerBaseService, - WorkerOptions, - WorkerProcessor, -} from './worker-base.service'; - -import { ActiveJobsMetricWorkerService } from './active-jobs-metric-worker.service'; -import { StandardWorkerService } from './standard-worker.service'; -import { SubscriberProcessWorkerService } from './subscriber-process-worker.service'; -import { WebSocketsWorkerService } from './web-sockets-worker.service'; -import { WorkflowWorkerService } from './workflow-worker.service'; -import { ExecutionLogWorkerService } from './execution-log-worker.service'; - export { - ActiveJobsMetricWorkerService, - StandardWorkerService, - SubscriberProcessWorkerService, - WebSocketsWorkerService, WorkerBaseService, WorkerOptions, WorkerProcessor, - WorkflowWorkerService, - ExecutionLogWorkerService, -}; +} from './worker-base.service'; +export { ActiveJobsMetricWorkerService } from './active-jobs-metric-worker.service'; +export { StandardWorkerService } from './standard-worker.service'; +export { SubscriberProcessWorkerService } from './subscriber-process-worker.service'; +export { WebSocketsWorkerService } from './web-sockets-worker.service'; +export { WorkflowWorkerService } from './workflow-worker.service'; diff --git a/libs/application-generic/src/usecases/execution-log-route/execution-log-route.usecase.ts b/libs/application-generic/src/usecases/execution-log-route/execution-log-route.usecase.ts index f3f68597171..027668b365c 100644 --- a/libs/application-generic/src/usecases/execution-log-route/execution-log-route.usecase.ts +++ b/libs/application-generic/src/usecases/execution-log-route/execution-log-route.usecase.ts @@ -1,59 +1,19 @@ -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ExecutionLogRouteCommand } from './execution-log-route.command'; import { CreateExecutionDetails, CreateExecutionDetailsCommand, } from '../create-execution-details'; -import { ExecutionLogQueueService } from '../../services'; -import { GetFeatureFlagCommand, GetFeatureFlag } from '../get-feature-flag'; -import { FeatureFlagsKeysEnum } from '../../services/types'; - -const LOG_CONTEXT = 'ExecutionLogRoute'; +// TODO: This usecase is not needed anymore. It can be replaces with a direct invocation of CreateExecutionDetails @Injectable() export class ExecutionLogRoute { - constructor( - private createExecutionDetails: CreateExecutionDetails, - @Inject(forwardRef(() => ExecutionLogQueueService)) - private executionLogQueueService: ExecutionLogQueueService, - private getFeatureFlag: GetFeatureFlag, - ) {} + constructor(private createExecutionDetails: CreateExecutionDetails) {} async execute(command: ExecutionLogRouteCommand) { - const isEnabled = await this.getFeatureFlag.execute( - GetFeatureFlagCommand.create({ - key: FeatureFlagsKeysEnum.IS_API_EXECUTION_LOG_QUEUE_ENABLED, - environmentId: command.environmentId, - organizationId: command.organizationId, - userId: command.userId, - }), + await this.createExecutionDetails.execute( + CreateExecutionDetailsCommand.create(command), ); - - switch (isEnabled) { - case true: { - const metadata = - CreateExecutionDetailsCommand.getExecutionLogMetadata(); - - await this.executionLogQueueService.add({ - name: metadata._id, - data: CreateExecutionDetailsCommand.create({ - ...metadata, - ...command, - }), - groupId: command.organizationId, - }); - break; - } - case false: { - await this.createExecutionDetails.execute( - CreateExecutionDetailsCommand.create(command), - ); - break; - } - default: { - Logger.warn('Execution log queue feature flag is not set', LOG_CONTEXT); - } - } } } diff --git a/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts b/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts index b38c04afa8f..1de1e62b8cd 100644 --- a/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts +++ b/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts @@ -12,18 +12,15 @@ import { import { SelectIntegration } from './select-integration.usecase'; import { SelectIntegrationCommand } from './select-integration.command'; -import { GetDecryptedIntegrations } from '../get-decrypted-integrations'; import { ConditionsFilter } from '../conditions-filter'; import { CompileTemplate } from '../compile-template'; import { - ExecutionLogQueueService, FeatureFlagsService, WorkflowInMemoryProviderService, } from '../../services'; import { ExecutionLogRoute } from '../execution-log-route'; import { CreateExecutionDetails } from '../create-execution-details'; import { GetFeatureFlag } from '../get-feature-flag'; -import { NormalizeVariables } from '../normalize-variables'; const testIntegration: IntegrationEntity = { _environmentId: 'env-test-123', @@ -108,8 +105,6 @@ describe('select integration', function () { new EnvironmentRepository(), new ExecutionLogRoute( new CreateExecutionDetails(new ExecutionDetailsRepository()), - new ExecutionLogQueueService(new WorkflowInMemoryProviderService()), - new GetFeatureFlag(new FeatureFlagsService()), ), new CompileTemplate(), ); diff --git a/libs/testing/src/jobs.service.ts b/libs/testing/src/jobs.service.ts index 02c5803e5e1..a25e4bbf6fa 100644 --- a/libs/testing/src/jobs.service.ts +++ b/libs/testing/src/jobs.service.ts @@ -4,20 +4,16 @@ import { JobTopicNameEnum, StepTypeEnum } from '@novu/shared'; import { TestingQueueService } from './testing-queue.service'; -const LOG_CONTEXT = 'TestingJobsService'; - export class JobsService { private jobRepository = new JobRepository(); public standardQueue: Queue; public workflowQueue: Queue; public subscriberProcessQueue: Queue; - public executionLogQueue: Queue; constructor(private isClusterMode?: boolean) { this.workflowQueue = new TestingQueueService(JobTopicNameEnum.WORKFLOW).queue; this.standardQueue = new TestingQueueService(JobTopicNameEnum.STANDARD).queue; this.subscriberProcessQueue = new TestingQueueService(JobTopicNameEnum.PROCESS_SUBSCRIBER).queue; - this.executionLogQueue = new TestingQueueService(JobTopicNameEnum.EXECUTION_LOG).queue; } public async queueGet(jobTopicName: JobTopicNameEnum, getter: 'getDelayed') { @@ -120,8 +116,6 @@ export class JobsService { activeStandardJobsCount, subscriberProcessQueueWaitingCount, subscriberProcessQueueActiveCount, - executionLogQueueWaitingCount, - executionLogQueueActiveCount, ] = await Promise.all([ this.workflowQueue.getActiveCount(), this.workflowQueue.getWaitingCount(), @@ -131,9 +125,6 @@ export class JobsService { this.subscriberProcessQueue.getWaitingCount(), this.subscriberProcessQueue.getActiveCount(), - - this.executionLogQueue.getWaitingCount(), - this.executionLogQueue.getActiveCount(), ]); const totalCount = @@ -142,9 +133,7 @@ export class JobsService { waitingStandardJobsCount + activeStandardJobsCount + subscriberProcessQueueWaitingCount + - subscriberProcessQueueActiveCount + - executionLogQueueWaitingCount + - executionLogQueueActiveCount; + subscriberProcessQueueActiveCount; return { totalCount, @@ -154,8 +143,6 @@ export class JobsService { activeStandardJobsCount, subscriberProcessQueueWaitingCount, subscriberProcessQueueActiveCount, - executionLogQueueWaitingCount, - executionLogQueueActiveCount, }; } } diff --git a/packages/shared/src/config/job-queue.ts b/packages/shared/src/config/job-queue.ts index 3816ae69c29..92c82955c12 100644 --- a/packages/shared/src/config/job-queue.ts +++ b/packages/shared/src/config/job-queue.ts @@ -5,7 +5,6 @@ * IN STALLED JOBS IN THE QUEUE. */ export enum JobTopicNameEnum { - EXECUTION_LOG = 'execution-logs', ACTIVE_JOBS_METRIC = 'metric-active-jobs', INBOUND_PARSE_MAIL = 'inbound-parse-mail', STANDARD = 'standard', @@ -18,7 +17,6 @@ export enum ObservabilityBackgroundTransactionEnum { JOB_PROCESSING_QUEUE = 'job-processing-queue', SUBSCRIBER_PROCESSING_QUEUE = 'subscriber-processing-queue', TRIGGER_HANDLER_QUEUE = 'trigger-handler-queue', - EXECUTION_LOG_QUEUE = 'execution-log-queue', WS_SOCKET_QUEUE = 'ws_socket_queue', WS_SOCKET_SOCKET_CONNECTION = 'ws_socket_handle_connection', WS_SOCKET_HANDLE_DISCONNECT = 'ws_socket_handle_disconnect', diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 3241708574c..b93644cd174 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -24,14 +24,12 @@ export enum SystemCriticalFlagsEnum { } export enum FeatureFlagsKeysEnum { - IS_API_EXECUTION_LOG_QUEUE_ENABLED = 'IS_API_EXECUTION_LOG_QUEUE_ENABLED', IS_API_IDEMPOTENCY_ENABLED = 'IS_API_IDEMPOTENCY_ENABLED', IS_API_RATE_LIMITING_DRY_RUN_ENABLED = 'IS_API_RATE_LIMITING_DRY_RUN_ENABLED', IS_API_RATE_LIMITING_ENABLED = 'IS_API_RATE_LIMITING_ENABLED', IS_AI_TEMPLATE_STORE_ENABLED = 'IS_AI_TEMPLATE_STORE_ENABLED', IS_CONTROLS_AUTOCOMPLETE_ENABLED = 'IS_CONTROLS_AUTOCOMPLETE_ENABLED', IS_EMAIL_INLINE_CSS_DISABLED = 'IS_EMAIL_INLINE_CSS_DISABLED', - IS_ENVIRONMENT_MANAGEMENT_ENABLED = 'IS_ENVIRONMENT_MANAGEMENT_ENABLED', IS_EVENT_QUOTA_LIMITING_ENABLED = 'IS_EVENT_QUOTA_LIMITING_ENABLED', IS_HUBSPOT_ONBOARDING_ENABLED = 'IS_HUBSPOT_ONBOARDING_ENABLED', IS_INTEGRATION_INVALIDATION_DISABLED = 'IS_INTEGRATION_INVALIDATION_DISABLED', @@ -46,7 +44,6 @@ export enum FeatureFlagsKeysEnum { IS_USAGE_ALERTS_ENABLED = 'IS_USAGE_ALERTS_ENABLED', IS_USE_MERGED_DIGEST_ID_ENABLED = 'IS_USE_MERGED_DIGEST_ID_ENABLED', IS_V2_ENABLED = 'IS_V2_ENABLED', - IS_V2_TEMPLATE_STORE_ENABLED = 'IS_V2_TEMPLATE_STORE_ENABLED', IS_STEP_CONDITIONS_ENABLED = 'IS_STEP_CONDITIONS_ENABLED', IS_WORKFLOW_NODE_PREVIEW_ENABLED = 'IS_WORKFLOW_NODE_PREVIEW_ENABLED', }