From a8b7216ccba6c4d680624c579d3cc65e6cd98271 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 15 Mar 2026 11:15:45 +0200 Subject: [PATCH 01/15] feat(dashboard): Dashboard mobile responsiveness (#10285) Co-authored-by: Cursor Agent --- apps/dashboard/src/components/auth-layout.tsx | 2 +- .../src/components/auth/auth-card.tsx | 6 +- .../components/auth/create-organization.tsx | 8 +-- .../src/components/auth/inbox-playground.tsx | 12 ++-- .../components/auth/inbox-preview-content.tsx | 4 +- .../components/auth/questionnaire-form.tsx | 8 +-- .../src/components/dashboard-layout.tsx | 14 +++- .../src/components/full-page-layout.tsx | 2 + .../header-navigation/header-navigation.tsx | 34 ++++++--- .../src/components/mobile-desktop-prompt.tsx | 72 +++++++++++++++++++ .../mobile-side-navigation.tsx | 36 ++++++++++ .../components/usecase-playground-header.tsx | 10 +-- .../components/welcome/progress-section.tsx | 16 ++--- .../src/components/welcome/resources-list.tsx | 2 +- apps/dashboard/src/hooks/use-is-mobile.ts | 28 ++++++++ apps/dashboard/src/pages/inbox-embed-page.tsx | 55 +++++++++++++- .../src/pages/inbox-embed-success-page.tsx | 4 +- apps/dashboard/src/pages/welcome-page.tsx | 2 +- apps/dashboard/src/pages/workflows.tsx | 4 +- 19 files changed, 270 insertions(+), 49 deletions(-) create mode 100644 apps/dashboard/src/components/mobile-desktop-prompt.tsx create mode 100644 apps/dashboard/src/components/side-navigation/mobile-side-navigation.tsx create mode 100644 apps/dashboard/src/hooks/use-is-mobile.ts diff --git a/apps/dashboard/src/components/auth-layout.tsx b/apps/dashboard/src/components/auth-layout.tsx index 3f00819d601..c0a560217b6 100644 --- a/apps/dashboard/src/components/auth-layout.tsx +++ b/apps/dashboard/src/components/auth-layout.tsx @@ -3,7 +3,7 @@ import { Toaster } from './primitives/sonner'; export const AuthLayout = ({ children }: { children: ReactNode }) => { return ( -
+
{children}
diff --git a/apps/dashboard/src/components/auth/auth-card.tsx b/apps/dashboard/src/components/auth/auth-card.tsx index cf68b55a40a..f77f44fb115 100644 --- a/apps/dashboard/src/components/auth/auth-card.tsx +++ b/apps/dashboard/src/components/auth/auth-card.tsx @@ -2,5 +2,9 @@ import { cn } from '../../utils/ui'; import { Card } from '../primitives/card'; export function AuthCard({ children, className }: { children: React.ReactNode; className?: string }) { - return {children}; + return ( + + {children} + + ); } diff --git a/apps/dashboard/src/components/auth/create-organization.tsx b/apps/dashboard/src/components/auth/create-organization.tsx index c184ebc5112..dcfbf6e79b4 100644 --- a/apps/dashboard/src/components/auth/create-organization.tsx +++ b/apps/dashboard/src/components/auth/create-organization.tsx @@ -54,8 +54,8 @@ interface IllustrationProps { // Small Components function FormContainer({ children }: FormContainerProps) { return ( -
-
{children}
+
+
{children}
); } @@ -118,7 +118,7 @@ function Illustration({ src, alt, className }: IllustrationProps) { function IllustrationSection() { return ( -
+
); @@ -126,7 +126,7 @@ function IllustrationSection() { function MainContent() { return ( -
+
diff --git a/apps/dashboard/src/components/auth/inbox-playground.tsx b/apps/dashboard/src/components/auth/inbox-playground.tsx index b320988e82a..e5f6663efb6 100644 --- a/apps/dashboard/src/components/auth/inbox-playground.tsx +++ b/apps/dashboard/src/components/auth/inbox-playground.tsx @@ -136,9 +136,8 @@ export function InboxPlayground({ appId, subscriberId }: { appId: string; subscr backgroundRepeat: 'no-repeat', }} > -
- {/* App Name Section - Show immediately */} -
+
+
{organization?.name ? `${organization.name} App` : 'ACME App'} @@ -146,10 +145,9 @@ export function InboxPlayground({ appId, subscriberId }: { appId: string; subscr
- {/* Inbox Preview Section - Show with optimized loading */} -
-
-
+
+
+
diff --git a/apps/dashboard/src/components/auth/inbox-preview-content.tsx b/apps/dashboard/src/components/auth/inbox-preview-content.tsx index 2c6bb1db619..46d5677a2b6 100644 --- a/apps/dashboard/src/components/auth/inbox-preview-content.tsx +++ b/apps/dashboard/src/components/auth/inbox-preview-content.tsx @@ -52,7 +52,7 @@ export function InboxPreviewContent() { backgroundColor: 'white', }, inboxContent: { - maxHeight: '460px', + maxHeight: '100%', }, notificationListContainer: { minHeight: '100%', @@ -69,7 +69,7 @@ export function InboxPreviewContent() { }; return ( -
+
diff --git a/apps/dashboard/src/components/auth/questionnaire-form.tsx b/apps/dashboard/src/components/auth/questionnaire-form.tsx index 65d71916f75..8007b50df0f 100644 --- a/apps/dashboard/src/components/auth/questionnaire-form.tsx +++ b/apps/dashboard/src/components/auth/questionnaire-form.tsx @@ -83,9 +83,9 @@ export function QuestionnaireForm() { return ( <> -
+
-
+
@@ -100,7 +100,7 @@ export function QuestionnaireForm() {
- +
@@ -227,7 +227,7 @@ export function QuestionnaireForm() {
-
+
create-org-illustration
diff --git a/apps/dashboard/src/components/dashboard-layout.tsx b/apps/dashboard/src/components/dashboard-layout.tsx index e54d11b6180..f384fda398a 100644 --- a/apps/dashboard/src/components/dashboard-layout.tsx +++ b/apps/dashboard/src/components/dashboard-layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { HeaderNavigation } from '@/components/header-navigation/header-navigation'; +import { MobileDesktopPrompt } from '@/components/mobile-desktop-prompt'; // @ts-ignore import { SideNavigation } from '@/components/side-navigation/side-navigation'; @@ -16,12 +17,21 @@ export const DashboardLayout = ({ }) => { return (
- {showSideNavigation && } + {showSideNavigation && ( +
+ +
+ )}
- +
{children}
+
); }; diff --git a/apps/dashboard/src/components/full-page-layout.tsx b/apps/dashboard/src/components/full-page-layout.tsx index 0ced3eaf84a..7c4e44b8a8e 100644 --- a/apps/dashboard/src/components/full-page-layout.tsx +++ b/apps/dashboard/src/components/full-page-layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { HeaderNavigation } from '@/components/header-navigation/header-navigation'; +import { MobileDesktopPrompt } from '@/components/mobile-desktop-prompt'; export const FullPageLayout = ({ children, @@ -15,6 +16,7 @@ export const FullPageLayout = ({
{children}
+
); }; diff --git a/apps/dashboard/src/components/header-navigation/header-navigation.tsx b/apps/dashboard/src/components/header-navigation/header-navigation.tsx index 138a6282270..f60ef798d37 100644 --- a/apps/dashboard/src/components/header-navigation/header-navigation.tsx +++ b/apps/dashboard/src/components/header-navigation/header-navigation.tsx @@ -3,6 +3,7 @@ import { HTMLAttributes, ReactNode } from 'react'; import { RiSearchLine } from 'react-icons/ri'; import { useCommandPalette } from '@/components/command-palette/hooks/use-command-palette'; import { InboxButton } from '@/components/inbox-button'; +import { MobileSideNavigation } from '@/components/side-navigation/mobile-side-navigation'; import { UserProfile } from '@/components/user-profile'; import { RegionSelector } from '@/context/region'; import { cn } from '@/utils/ui'; @@ -18,10 +19,11 @@ import { PublishButton } from './publish-button'; type HeaderNavigationProps = HTMLAttributes & { startItems?: ReactNode; hideBridgeUrl?: boolean; + showMobileNav?: boolean; }; export const HeaderNavigation = (props: HeaderNavigationProps) => { - const { startItems, hideBridgeUrl = false, className, ...rest } = props; + const { startItems, hideBridgeUrl = false, showMobileNav = false, className, ...rest } = props; const { currentEnvironment } = useEnvironment(); const has = useHasPermission(); const canPublish = has({ permission: PermissionsEnum.ENVIRONMENT_WRITE }); @@ -35,25 +37,41 @@ export const HeaderNavigation = (props: HeaderNavigationProps) => { )} {...rest} > - {startItems} +
+ {showMobileNav && } + {startItems} +
- {currentEnvironment?.type === EnvironmentTypeEnum.DEV && canPublish && } - {!hideBridgeUrl ? : null} - {!(IS_SELF_HOSTED && IS_ENTERPRISE) && } + + + {currentEnvironment?.type === EnvironmentTypeEnum.DEV && canPublish && } + {!hideBridgeUrl ? : null} + {!(IS_SELF_HOSTED && IS_ENTERPRISE) && } +
-
- +
+ + +
diff --git a/apps/dashboard/src/components/mobile-desktop-prompt.tsx b/apps/dashboard/src/components/mobile-desktop-prompt.tsx new file mode 100644 index 00000000000..722b4337df5 --- /dev/null +++ b/apps/dashboard/src/components/mobile-desktop-prompt.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { RiCloseLine, RiComputerLine, RiArrowRightLine } from 'react-icons/ri'; +import { LogoCircle } from '@/components/icons/logo-circle'; +import { cn } from '@/utils/ui'; + +const MOBILE_PROMPT_DISMISSED_KEY = 'novu-mobile-prompt-dismissed'; + +export function MobileDesktopPrompt() { + const [isDismissed, setIsDismissed] = useState(() => { + try { + return sessionStorage.getItem(MOBILE_PROMPT_DISMISSED_KEY) === 'true'; + } catch { + return false; + } + }); + + const handleDismiss = () => { + setIsDismissed(true); + try { + sessionStorage.setItem(MOBILE_PROMPT_DISMISSED_KEY, 'true'); + } catch {} + }; + + if (isDismissed) return null; + + return ( +
+
+ + +
+
+
+ +
+ Novu +
+ +
+

Best on desktop

+

+ Novu's dashboard is designed for desktop screens. Switch to your computer for the full experience with + workflow editing, code integration, and more. +

+
+ +
+
+ +
+
+

Open on your computer

+

dashboard.novu.co

+
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/side-navigation/mobile-side-navigation.tsx b/apps/dashboard/src/components/side-navigation/mobile-side-navigation.tsx new file mode 100644 index 00000000000..89084652523 --- /dev/null +++ b/apps/dashboard/src/components/side-navigation/mobile-side-navigation.tsx @@ -0,0 +1,36 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { useEffect, useState } from 'react'; +import { RiMenuLine } from 'react-icons/ri'; +import { useLocation } from 'react-router-dom'; +import { Sheet, SheetContent, SheetTitle } from '@/components/primitives/sheet'; +import { SideNavigation } from './side-navigation'; + +export function MobileSideNavigation() { + const [isOpen, setIsOpen] = useState(false); + const { pathname } = useLocation(); + + useEffect(() => { + setIsOpen(false); + }, [pathname]); + + return ( + <> + + + + + + Navigation + + + + + + ); +} diff --git a/apps/dashboard/src/components/usecase-playground-header.tsx b/apps/dashboard/src/components/usecase-playground-header.tsx index c2c74e2c08e..2294b9d4204 100644 --- a/apps/dashboard/src/components/usecase-playground-header.tsx +++ b/apps/dashboard/src/components/usecase-playground-header.tsx @@ -48,8 +48,8 @@ export function UsecasePlaygroundHeader({ const skipButtonText = getSkipButtonText(); return ( -
-
+
+
{showBackButton && ( )} -
-

{title}

-

{description}

+
+

{title}

+

{description}

diff --git a/apps/dashboard/src/components/welcome/progress-section.tsx b/apps/dashboard/src/components/welcome/progress-section.tsx index 83577562ede..da9a3abe450 100644 --- a/apps/dashboard/src/components/welcome/progress-section.tsx +++ b/apps/dashboard/src/components/welcome/progress-section.tsx @@ -29,13 +29,13 @@ export function ProgressSection({ isNewHomePageEnabled }: { isNewHomePageEnabled {isNewHomePageEnabled ? : } {steps.map((step, index) => ( @@ -44,7 +44,7 @@ export function ProgressSection({ isNewHomePageEnabled }: { isNewHomePageEnabled {!isNewHomePageEnabled && ( - + )} @@ -74,7 +74,7 @@ function StepItem({ step, environmentSlug }: StepItemProps) { }; return ( - +
@@ -115,22 +115,22 @@ function WelcomeHeader() { return (
You're doing great work! 💪 -
+
Set up Novu to send notifications your users will love. - + Streamline all your customer messaging in one tool and delight them at every touchpoint.
- +

Get started with our setup guide.

diff --git a/apps/dashboard/src/components/welcome/resources-list.tsx b/apps/dashboard/src/components/welcome/resources-list.tsx index 9e9bf217bdf..7fda775a602 100644 --- a/apps/dashboard/src/components/welcome/resources-list.tsx +++ b/apps/dashboard/src/components/welcome/resources-list.tsx @@ -74,7 +74,7 @@ export function ResourcesList({ resources, title, icon }: ResourcesListProps) { {resources.map((resource, index) => ( handleResourceClick(resource)}> - + { + if (typeof window === 'undefined') return false; + + return window.innerWidth < MOBILE_BREAKPOINT; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + + const handleChange = (e: MediaQueryListEvent) => { + setIsMobile(e.matches); + }; + + setIsMobile(mediaQuery.matches); + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + return isMobile; +} diff --git a/apps/dashboard/src/pages/inbox-embed-page.tsx b/apps/dashboard/src/pages/inbox-embed-page.tsx index 587c05de281..31b13d095f3 100644 --- a/apps/dashboard/src/pages/inbox-embed-page.tsx +++ b/apps/dashboard/src/pages/inbox-embed-page.tsx @@ -1,8 +1,12 @@ import { ChannelTypeEnum } from '@novu/shared'; import { useEffect, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { RiComputerLine, RiArrowRightSLine } from 'react-icons/ri'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { AnimatedPage } from '@/components/onboarding/animated-page'; +import { useIsMobile } from '@/hooks/use-is-mobile'; import { AuthCard } from '../components/auth/auth-card'; +import { LogoCircle } from '../components/icons/logo-circle'; +import { Button } from '../components/primitives/button'; import { UsecasePlaygroundHeader } from '../components/usecase-playground-header'; import { InboxEmbed } from '../components/welcome/inbox-embed'; import { useEnvironment } from '../context/environment/hooks'; @@ -11,8 +15,53 @@ import { useTelemetry } from '../hooks/use-telemetry'; import { ROUTES } from '../utils/routes'; import { TelemetryEvent } from '../utils/telemetry'; +function MobileEmbedSkip() { + const navigate = useNavigate(); + const telemetry = useTelemetry(); + + const handleGoToDashboard = () => { + telemetry(TelemetryEvent.SKIP_ONBOARDING_CLICKED, { skippedFrom: 'mobile-embed-skip' }); + navigate(ROUTES.WELCOME); + }; + + return ( + + +
+
+ +
+ +
+

Continue on desktop

+

+ Embedding the Inbox component requires a code editor and development environment. Open Novu on your + computer to complete this step. +

+
+ +
+
+ +
+
+

Open on your computer

+

Complete the Inbox integration

+
+
+ + +
+
+
+ ); +} + export function InboxEmbedPage() { const telemetry = useTelemetry(); + const isMobile = useIsMobile(); const { environments } = useEnvironment(); const [searchParams] = useSearchParams(); const environmentHint = searchParams.get('environmentId'); @@ -44,6 +93,10 @@ export function InboxEmbedPage() { telemetry(TelemetryEvent.INBOX_EMBED_PAGE_VIEWED); }, [telemetry]); + if (isMobile) { + return ; + } + return ( diff --git a/apps/dashboard/src/pages/inbox-embed-success-page.tsx b/apps/dashboard/src/pages/inbox-embed-success-page.tsx index d51c9522759..6aac61740dc 100644 --- a/apps/dashboard/src/pages/inbox-embed-success-page.tsx +++ b/apps/dashboard/src/pages/inbox-embed-success-page.tsx @@ -20,8 +20,8 @@ export function InboxEmbedSuccessPage() { } return ( - - + +
Onboarding succcess hint to look for inbox diff --git a/apps/dashboard/src/pages/welcome-page.tsx b/apps/dashboard/src/pages/welcome-page.tsx index 9ddb7b1d871..8524705cb09 100644 --- a/apps/dashboard/src/pages/welcome-page.tsx +++ b/apps/dashboard/src/pages/welcome-page.tsx @@ -92,7 +92,7 @@ export function WelcomePage(): ReactElement { <> - + diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index c1d58ac1587..5ea7a269663 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -203,8 +203,8 @@ export const WorkflowsPage = () => { Workflows}>
-
-
+
+
Date: Sun, 15 Mar 2026 11:24:07 +0200 Subject: [PATCH 02/15] fix(dashboard): hide request body for HTTP methods without body support (#10282) Co-authored-by: Cursor Agent Co-authored-by: George Djabarov Co-authored-by: George Djabarov --- .../steps/http-request/curl-display.tsx | 4 ++-- .../steps/http-request/curl-utils.ts | 8 +++++++- .../steps/http-request/http-request-editor.tsx | 17 ++++++++++++----- .../steps/http-request/use-copy-prompt.tsx | 5 ++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-display.tsx b/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-display.tsx index 4df885f4eab..28e1ec187ad 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-display.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-display.tsx @@ -1,5 +1,5 @@ import { cn } from '@/utils/ui'; -import { type KeyValuePair, NOVU_SIGNATURE_HEADER_KEY } from './curl-utils'; +import { canMethodHaveBody, type KeyValuePair, NOVU_SIGNATURE_HEADER_KEY } from './curl-utils'; type CurlDisplayProps = { url: string; @@ -17,7 +17,7 @@ export function CurlDisplay({ url, method, headers, body, className, novuSignatu const hasNovuSignature = headerEntries.some(([k]) => k.toLowerCase() === NOVU_SIGNATURE_HEADER_KEY); - const canHaveBody = method !== 'GET' && method !== 'DELETE'; + const canHaveBody = canMethodHaveBody(method); let bodyObj: Record | null = null; if (canHaveBody && body) { diff --git a/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-utils.ts b/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-utils.ts index 87b9eff992d..5eb5d1b7e69 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-utils.ts +++ b/apps/dashboard/src/components/workflow-editor/steps/http-request/curl-utils.ts @@ -2,6 +2,12 @@ export type KeyValuePair = { key: string; value: string }; export const NOVU_SIGNATURE_HEADER_KEY = 'novu-signature'; +const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH']); + +export function canMethodHaveBody(method: string): boolean { + return METHODS_WITH_BODY.has(method.toUpperCase()); +} + export function buildRawCurlString( url: string, method: string, @@ -21,7 +27,7 @@ export function buildRawCurlString( const headerArgs = headerEntries.map(([k, v]) => `--header '${k}: ${v}'`).join(' \\\n'); - const canHaveBody = method !== 'GET' && method !== 'DELETE'; + const canHaveBody = canMethodHaveBody(method); let bodyObj: Record | null = null; if (canHaveBody) { diff --git a/apps/dashboard/src/components/workflow-editor/steps/http-request/http-request-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/http-request/http-request-editor.tsx index 73fdf25603e..a79cb81f6bc 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/http-request/http-request-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/http-request/http-request-editor.tsx @@ -1,8 +1,10 @@ import { EnvironmentTypeEnum, type UiSchema, UiSchemaGroupEnum } from '@novu/shared'; +import { useFormContext } from 'react-hook-form'; import { SidebarContent } from '@/components/side-navigation/sidebar'; import { TabsSection } from '@/components/workflow-editor/steps/tabs-section'; import { useEnvironment } from '@/context/environment/hooks'; import { StepEditorUnavailable } from '../step-editor-unavailable'; +import { canMethodHaveBody } from './curl-utils'; import { KeyValuePairList } from './key-value-pair-list'; import { RequestEndpoint } from './request-endpoint'; import { ResponseBodySchema } from './response-body-schema'; @@ -13,6 +15,9 @@ type HttpRequestEditorProps = { export function HttpRequestEditor({ uiSchema }: HttpRequestEditorProps) { const { currentEnvironment } = useEnvironment(); + const { watch } = useFormContext(); + const method = watch('method'); + const hasBody = canMethodHaveBody(method); if (uiSchema.group !== UiSchemaGroupEnum.HTTP_REQUEST) { return null; @@ -33,11 +38,13 @@ export function HttpRequestEditor({ uiSchema }: HttpRequestEditorProps) { tooltip="Custom HTTP headers to include with the request" /> - + {hasBody && ( + + )}

💡 Tip: diff --git a/apps/dashboard/src/components/workflow-editor/steps/http-request/use-copy-prompt.tsx b/apps/dashboard/src/components/workflow-editor/steps/http-request/use-copy-prompt.tsx index fe23f0bec5b..e313adab01f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/http-request/use-copy-prompt.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/http-request/use-copy-prompt.tsx @@ -2,8 +2,7 @@ import { useCallback } from 'react'; import { ToastClose, ToastIcon } from '@/components/primitives/sonner'; import { showErrorToast, showToast } from '@/components/primitives/sonner-helpers'; import { useStepEditor } from '../context/step-editor-context'; - -type KeyValuePair = { key: string; value: string }; +import { canMethodHaveBody, type KeyValuePair } from './curl-utils'; function buildLlmPrompt( url: string, @@ -22,7 +21,7 @@ function buildLlmPrompt( '\n novu-signature: t=,v1=' : ' novu-signature: t=,v1='; - const canHaveBody = method !== 'GET' && method !== 'DELETE'; + const canHaveBody = canMethodHaveBody(method); const bodyObject = canHaveBody && activeBody.length > 0 ? Object.fromEntries(activeBody.map(({ key, value }) => [key, value])) : null; From 58f79a895c59c6bd765a84b98a0f60bdbe709239 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:29:05 +0200 Subject: [PATCH 03/15] fix(root): resolve high jws vulnerability (#10288) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- package.json | 3 ++- pnpm-lock.yaml | 30 +++++++----------------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index d9663f83e80..bcf81b9f7e6 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,8 @@ "basic-ftp@<5.2.0": "5.2.0", "axios@>=1.0.0 <1.13.5": "^1.13.5", "seroval@<1.4.1": "^1.4.1", - "h3@<=1.15.4": "^1.15.5" + "h3@<=1.15.4": "^1.15.5", + "jws@>=4.0.0 <4.0.1": "^4.0.1" }, "onlyBuiltDependencies": [ "@clerk/shared", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d506e9acd0..8168b4f08e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,7 @@ overrides: axios@>=1.0.0 <1.13.5: ^1.13.5 seroval@<1.4.1: ^1.4.1 h3@<=1.15.4: ^1.15.5 + jws@>=4.0.0 <4.0.1: ^4.0.1 importers: @@ -20942,9 +20943,6 @@ packages: jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} - jwa@2.0.0: - resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -20955,9 +20953,6 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - jws@4.0.0: - resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} - jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -49200,7 +49195,7 @@ snapshots: gaxios: 4.3.3(encoding@0.1.13) gcp-metadata: 4.3.1(encoding@0.1.13) gtoken: 5.3.2(encoding@0.1.13) - jws: 4.0.0 + jws: 4.0.1 lru-cache: 6.0.0 transitivePeerDependencies: - encoding @@ -49215,7 +49210,7 @@ snapshots: gaxios: 5.1.3(encoding@0.1.13) gcp-metadata: 5.3.0(encoding@0.1.13) gtoken: 6.1.2(encoding@0.1.13) - jws: 4.0.0 + jws: 4.0.1 lru-cache: 6.0.0 transitivePeerDependencies: - encoding @@ -49228,7 +49223,7 @@ snapshots: gaxios: 6.1.1(encoding@0.1.13) gcp-metadata: 6.1.0(encoding@0.1.13) gtoken: 7.1.0(encoding@0.1.13) - jws: 4.0.0 + jws: 4.0.1 transitivePeerDependencies: - encoding - supports-color @@ -49332,7 +49327,7 @@ snapshots: dependencies: gaxios: 4.3.3(encoding@0.1.13) google-p12-pem: 3.1.4 - jws: 4.0.0 + jws: 4.0.1 transitivePeerDependencies: - encoding - supports-color @@ -49341,7 +49336,7 @@ snapshots: dependencies: gaxios: 5.1.3(encoding@0.1.13) google-p12-pem: 4.0.1 - jws: 4.0.0 + jws: 4.0.1 transitivePeerDependencies: - encoding - supports-color @@ -49349,7 +49344,7 @@ snapshots: gtoken@7.1.0(encoding@0.1.13): dependencies: gaxios: 6.1.1(encoding@0.1.13) - jws: 4.0.0 + jws: 4.0.1 transitivePeerDependencies: - encoding - supports-color @@ -51822,12 +51817,6 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jwa@2.0.0: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -51850,11 +51839,6 @@ snapshots: jwa: 1.4.1 safe-buffer: 5.2.1 - jws@4.0.0: - dependencies: - jwa: 2.0.0 - safe-buffer: 5.2.1 - jws@4.0.1: dependencies: jwa: 2.0.1 From e89da371409b1e325e10437d3507a7a109352189 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:32:53 +0200 Subject: [PATCH 04/15] fix(api-service,dashboard): fix 4 production Sentry errors across API and dashboard (#10292) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- .../get-active-integrations-status.usecase.ts | 3 ++- apps/dashboard/src/components/variable/utils.ts | 2 +- apps/dashboard/src/components/variable/variable-list.tsx | 2 +- .../compile-email-template/compile-email-template.usecase.ts | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.usecase.ts b/apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.usecase.ts index 0bf12459c7a..6d0f2bdbd5b 100644 --- a/apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.usecase.ts +++ b/apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.usecase.ts @@ -122,7 +122,8 @@ export class GetActiveIntegrationsStatus { stepType === StepTypeEnum.DELAY || stepType === StepTypeEnum.DIGEST || stepType === StepTypeEnum.TRIGGER || - stepType === StepTypeEnum.CUSTOM; + stepType === StepTypeEnum.CUSTOM || + !activeChannelsStatus[stepType]; const isStepWithPrimaryIntegration = stepType === StepTypeEnum.EMAIL || stepType === StepTypeEnum.SMS; if (stepType && !skipStep) { const { hasActiveIntegrations } = activeChannelsStatus[stepType]; diff --git a/apps/dashboard/src/components/variable/utils.ts b/apps/dashboard/src/components/variable/utils.ts index 522100b3414..34a02bd642f 100644 --- a/apps/dashboard/src/components/variable/utils.ts +++ b/apps/dashboard/src/components/variable/utils.ts @@ -2,7 +2,7 @@ import { getFilters } from './constants'; import { FilterWithParam } from './types'; function escapeString(str: string): string { - return str.replace(/'/g, "\\'"); + return String(str).replace(/'/g, "\\'"); } export function formatParamValue(param: string, type?: string) { diff --git a/apps/dashboard/src/components/variable/variable-list.tsx b/apps/dashboard/src/components/variable/variable-list.tsx index 18a5712e318..a8cd199fd4d 100644 --- a/apps/dashboard/src/components/variable/variable-list.tsx +++ b/apps/dashboard/src/components/variable/variable-list.tsx @@ -88,7 +88,7 @@ export const VariableList = React.forwardRef { - if (hoveredOptionIndex !== -1) { + if (hoveredOptionIndex !== -1 && hoveredOptionIndex < options.length) { onSelect(options[hoveredOptionIndex].value ?? ''); setHoveredOptionIndex(-1); } diff --git a/libs/application-generic/src/usecases/compile-email-template/compile-email-template.usecase.ts b/libs/application-generic/src/usecases/compile-email-template/compile-email-template.usecase.ts index 381d9795c0a..c80329dc919 100644 --- a/libs/application-generic/src/usecases/compile-email-template/compile-email-template.usecase.ts +++ b/libs/application-generic/src/usecases/compile-email-template/compile-email-template.usecase.ts @@ -91,6 +91,7 @@ export class CompileEmailTemplate extends CompileTemplateBase { if (isEditorMode) { for (const block of content as IEmailBlock[]) { + if (typeof block !== 'object' || block === null) continue; block.content = await this.renderContent(block.content, payload, i18nInstance); block.url = await this.renderContent(block.url || '', payload, i18nInstance); } From ebb516cf6f778a092df8229f754ce809c77d9cc1 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:46:52 +0200 Subject: [PATCH 05/15] fix(root): resolve high validator vulnerability (#10290) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- package.json | 3 ++- pnpm-lock.yaml | 19 +++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index bcf81b9f7e6..6370dc61edc 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,8 @@ "axios@>=1.0.0 <1.13.5": "^1.13.5", "seroval@<1.4.1": "^1.4.1", "h3@<=1.15.4": "^1.15.5", - "jws@>=4.0.0 <4.0.1": "^4.0.1" + "jws@>=4.0.0 <4.0.1": "^4.0.1", + "validator@<13.15.22": "^13.15.22" }, "onlyBuiltDependencies": [ "@clerk/shared", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8168b4f08e5..e71e26f5014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,7 @@ overrides: seroval@<1.4.1: ^1.4.1 h3@<=1.15.4: ^1.15.5 jws@>=4.0.0 <4.0.1: ^4.0.1 + validator@<13.15.22: ^13.15.22 importers: @@ -27345,12 +27346,8 @@ packages: validate.js@0.13.1: resolution: {integrity: sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==} - validator@13.11.0: - resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} - engines: {node: '>= 0.10'} - - validator@13.12.0: - resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} engines: {node: '>= 0.10'} value-or-promise@1.0.12: @@ -43650,7 +43647,7 @@ snapshots: '@verdaccio/core': 7.0.0-next-7.15 debug: 4.3.4(supports-color@8.1.1) lodash: 4.17.21 - validator: 13.11.0 + validator: 13.15.26 transitivePeerDependencies: - supports-color @@ -45699,7 +45696,7 @@ snapshots: dependencies: '@types/validator': 13.12.1 libphonenumber-js: 1.11.19 - validator: 13.12.0 + validator: 13.15.26 class-variance-authority@0.7.1: dependencies: @@ -59669,9 +59666,7 @@ snapshots: validate.js@0.13.1: {} - validator@13.11.0: {} - - validator@13.12.0: {} + validator@13.15.26: {} value-or-promise@1.0.12: optional: true @@ -59739,7 +59734,7 @@ snapshots: mv: 2.1.1 pkginfo: 0.4.1 semver: 7.6.2 - validator: 13.12.0 + validator: 13.15.26 verdaccio-audit: 12.0.0-next-7.15(encoding@0.1.13) verdaccio-htpasswd: 12.0.0-next-7.15 transitivePeerDependencies: From be7b486e1b3aea0d0a4db405a28f02b7464ba94f Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:13:49 +0200 Subject: [PATCH 06/15] fix(root): resolve high immutable vulnerability (#10293) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- package.json | 1 + pnpm-lock.yaml | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6370dc61edc..2d452a1a21b 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "axios@>=1.0.0 <1.13.5": "^1.13.5", "seroval@<1.4.1": "^1.4.1", "h3@<=1.15.4": "^1.15.5", + "immutable@>=4.0.0 <4.3.8": "^4.3.8", "jws@>=4.0.0 <4.0.1": "^4.0.1", "validator@<13.15.22": "^13.15.22" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71e26f5014..3d5b789c582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,7 @@ overrides: axios@>=1.0.0 <1.13.5: ^1.13.5 seroval@<1.4.1: ^1.4.1 h3@<=1.15.4: ^1.15.5 + immutable@>=4.0.0 <4.3.8: ^4.3.8 jws@>=4.0.0 <4.0.1: ^4.0.1 validator@<13.15.22: ^13.15.22 @@ -19701,8 +19702,8 @@ packages: immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} - immutable@4.3.7: - resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} + immutable@4.3.8: + resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} import-cwd@3.0.0: resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} @@ -49909,7 +49910,7 @@ snapshots: immer@9.0.21: {} - immutable@4.3.7: + immutable@4.3.8: optional: true import-cwd@3.0.0: @@ -57036,7 +57037,7 @@ snapshots: sass@1.77.8: dependencies: chokidar: 3.6.0 - immutable: 4.3.7 + immutable: 4.3.8 source-map-js: 1.2.1 optional: true From 83d73f0bdd00b235eae1bc6215c2d820cd027c6b Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:16:29 +0200 Subject: [PATCH 07/15] fix(api-service,dashboard): resolve Sentry errors in topic creation, workflow checklist, and subscriber preferences (#10284) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- .../subscribers-v2/subscribers.controller.ts | 10 +++---- .../create-subscriptions.usecase.ts | 28 +++++++++++++---- .../upsert-topic/upsert-topic.usecase.ts | 30 +++++++++++++++---- .../workflow-editor/workflow-checklist.tsx | 8 ++--- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/apps/api/src/app/subscribers-v2/subscribers.controller.ts b/apps/api/src/app/subscribers-v2/subscribers.controller.ts index 315beaa4058..2ddb3a4142f 100644 --- a/apps/api/src/app/subscribers-v2/subscribers.controller.ts +++ b/apps/api/src/app/subscribers-v2/subscribers.controller.ts @@ -310,11 +310,11 @@ export class SubscribersController { ): Promise { const preferences = body.preferences.map((preference) => ({ workflowId: preference.workflowId, - email: preference.channels.email, - sms: preference.channels.sms, - in_app: preference.channels.in_app, - push: preference.channels.push, - chat: preference.channels.chat, + email: preference.channels?.email, + sms: preference.channels?.sms, + in_app: preference.channels?.in_app, + push: preference.channels?.push, + chat: preference.channels?.chat, })); return await this.bulkUpdatePreferencesUsecase.execute( diff --git a/apps/api/src/app/subscriptions/usecases/create-subscriptions/create-subscriptions.usecase.ts b/apps/api/src/app/subscriptions/usecases/create-subscriptions/create-subscriptions.usecase.ts index 6f03030c66f..76e775b6bde 100644 --- a/apps/api/src/app/subscriptions/usecases/create-subscriptions/create-subscriptions.usecase.ts +++ b/apps/api/src/app/subscriptions/usecases/create-subscriptions/create-subscriptions.usecase.ts @@ -302,12 +302,24 @@ export class CreateSubscriptionsUsecase { if (!topic) { this.validateTopicKey(command.topicKey); - topic = await this.topicRepository.createTopic({ - _environmentId: command.environmentId, - _organizationId: command.organizationId, - key: command.topicKey, - name: command.name, - }); + try { + topic = await this.topicRepository.createTopic({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + key: command.topicKey, + name: command.name, + }); + } catch (error: unknown) { + if (this.isDuplicateKeyError(error)) { + topic = await this.topicRepository.findTopicByKey( + command.topicKey, + command.organizationId, + command.environmentId + ); + } else { + throw error; + } + } } else if (command.name) { topic = await this.topicRepository.findOneAndUpdate( { @@ -338,6 +350,10 @@ export class CreateSubscriptionsUsecase { ); } + private isDuplicateKeyError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code: number }).code === 11000; + } + private async validateSubscriptionLimit( topic: TopicEntity, subscribers: SubscriberEntity[], diff --git a/apps/api/src/app/topics-v2/usecases/upsert-topic/upsert-topic.usecase.ts b/apps/api/src/app/topics-v2/usecases/upsert-topic/upsert-topic.usecase.ts index f209e3703cb..dbf431f6c3a 100644 --- a/apps/api/src/app/topics-v2/usecases/upsert-topic/upsert-topic.usecase.ts +++ b/apps/api/src/app/topics-v2/usecases/upsert-topic/upsert-topic.usecase.ts @@ -6,6 +6,8 @@ import { TopicResponseDto } from '../../dtos/topic-response.dto'; import { mapTopicEntityToDto } from '../list-topics/map-topic-entity-to.dto'; import { UpsertTopicCommand } from './upsert-topic.command'; +const DUPLICATE_KEY_ERROR_CODE = 11000; + @Injectable() export class UpsertTopicUseCase { constructor(private topicRepository: TopicRepository) {} @@ -20,12 +22,24 @@ export class UpsertTopicUseCase { if (!topic) { this.isValidTopicKey(command.key); - topic = await this.topicRepository.createTopic({ - _environmentId: command.environmentId, - _organizationId: command.organizationId, - key: command.key, - name: command.name, - }); + try { + topic = await this.topicRepository.createTopic({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + key: command.key, + name: command.name, + }); + } catch (error: unknown) { + if (this.isDuplicateKeyError(error)) { + topic = await this.topicRepository.findTopicByKey( + command.key, + command.organizationId, + command.environmentId + ); + } else { + throw error; + } + } } else { const updateBody: Record = {}; @@ -60,4 +74,8 @@ export class UpsertTopicUseCase { `Invalid topic key: "${key}". Topic keys must contain only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), underscores (_), colons (:), or be a valid email address.` ); } + + private isDuplicateKeyError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === DUPLICATE_KEY_ERROR_CODE; + } } diff --git a/apps/dashboard/src/components/workflow-editor/workflow-checklist.tsx b/apps/dashboard/src/components/workflow-editor/workflow-checklist.tsx index 88f288853fa..352dc5afae7 100644 --- a/apps/dashboard/src/components/workflow-editor/workflow-checklist.tsx +++ b/apps/dashboard/src/components/workflow-editor/workflow-checklist.tsx @@ -56,11 +56,11 @@ export function WorkflowChecklist({ steps, workflow }: WorkflowChecklistProps) { if (allItemsCompleted) { setIsOpen(false); - telemetry(TelemetryEvent.WORKFLOW_CHECKLIST_COMPLETED, { - workflowId: workflow?.workflowId, - }); + if (user && !user.unsafeMetadata?.workflowChecklistCompleted) { + telemetry(TelemetryEvent.WORKFLOW_CHECKLIST_COMPLETED, { + workflowId: workflow?.workflowId, + }); - if (user) { user.update({ unsafeMetadata: { ...user.unsafeMetadata, From a3f1096da718735b77184cb9036e445a1f741de9 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 15 Mar 2026 12:19:18 +0200 Subject: [PATCH 08/15] feat(dashboard): Table pages new tab support fixes NV-7238 (#10296) Co-authored-by: Cursor Agent --- .../src/components/contexts/context-row.tsx | 30 +++++---- .../components/subscribers/subscriber-row.tsx | 35 +++++++---- .../src/components/topics/topic-row.tsx | 33 ++++++---- .../dashboard/src/components/workflow-row.tsx | 63 ++++++++++++------- 4 files changed, 100 insertions(+), 61 deletions(-) diff --git a/apps/dashboard/src/components/contexts/context-row.tsx b/apps/dashboard/src/components/contexts/context-row.tsx index 5a1b7e0f98a..06fbc1c7109 100644 --- a/apps/dashboard/src/components/contexts/context-row.tsx +++ b/apps/dashboard/src/components/contexts/context-row.tsx @@ -22,30 +22,41 @@ import { formatDateSimple } from '@/utils/format-date'; import { Protect } from '@/utils/protect'; import { buildRoute, ROUTES } from '@/utils/routes'; import { cn } from '@/utils/ui'; -import { useContextsNavigate } from './hooks/use-contexts-navigate'; type ContextRowProps = { context: GetContextResponseDto; }; -type ContextTableCellProps = ComponentProps; +type ContextTableCellProps = ComponentProps & { + to?: string; +}; const ContextTableCell = (props: ContextTableCellProps) => { - const { children, className, ...rest } = props; + const { children, className, to, ...rest } = props; return ( + {to && ( + + Edit context + + )} {children} ); }; export const ContextRow = ({ context }: ContextRowProps) => { - const { navigateToEditContextPage } = useContextsNavigate(); const { currentEnvironment } = useEnvironment(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { deleteContext, isPending: isDeleting } = useDeleteContext(); + const contextLink = buildRoute(ROUTES.CONTEXTS_EDIT, { + environmentSlug: currentEnvironment?.slug ?? '', + type: context.type, + id: context.id, + }); + const stopPropagation = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -66,14 +77,11 @@ export const ContextRow = ({ context }: ContextRowProps) => { <> { - navigateToEditContextPage(context.type, context.id); - }} > - + {context.type} - +

{context.id}
{ />
- + {context.createdAt && ( {formatDateSimple(context.createdAt)} )} - + {context.updatedAt && ( {formatDateSimple(context.updatedAt)} )} diff --git a/apps/dashboard/src/components/subscribers/subscriber-row.tsx b/apps/dashboard/src/components/subscribers/subscriber-row.tsx index 65d62d75895..fe84a69be77 100644 --- a/apps/dashboard/src/components/subscribers/subscriber-row.tsx +++ b/apps/dashboard/src/components/subscribers/subscriber-row.tsx @@ -2,7 +2,7 @@ import { ISubscriberResponseDto, PermissionsEnum } from '@novu/shared'; import { useQueryClient } from '@tanstack/react-query'; import { ComponentProps, useState } from 'react'; import { RiDeleteBin2Line, RiFileCopyLine, RiMore2Fill, RiPulseFill } from 'react-icons/ri'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { ExternalToast } from 'sonner'; import { ConfirmationModal } from '@/components/confirmation-modal'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/primitives/avatar'; @@ -45,15 +45,21 @@ type SubscriberRowProps = { firstTwoSubscribersInternalIds: string[]; }; -type SubscriberLinkTableCellProps = ComponentProps; +type SubscriberLinkTableCellProps = ComponentProps & { + to?: string; +}; const SubscriberTableCell = (props: SubscriberLinkTableCellProps) => { - const { children, className, ...rest } = props; + const { children, className, to, ...rest } = props; return ( + {to && ( + + Edit subscriber + + )} {children} - Edit subscriber ); }; @@ -63,9 +69,15 @@ export const SubscriberRow = ({ subscriber, subscribersCount, firstTwoSubscriber const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const subscriberTitle = getSubscriberTitle(subscriber); const queryClient = useQueryClient(); - const { navigateToSubscribersFirstPage, navigateToEditSubscriberPage } = useSubscribersNavigate(); + const location = useLocation(); + const { navigateToSubscribersFirstPage } = useSubscribersNavigate(); const { handleNavigationAfterDelete } = useSubscribersUrlState(); + const subscriberLink = `${buildRoute(ROUTES.EDIT_SUBSCRIBER, { + environmentSlug: currentEnvironment?.slug ?? '', + subscriberId: encodeURIComponent(subscriber.subscriberId), + })}${location.search}`; + const { deleteSubscriber, isPending: isDeleteSubscriberPending } = useDeleteSubscriber({ onSuccess: () => { showToast({ @@ -136,11 +148,8 @@ export const SubscriberRow = ({ subscriber, subscribersCount, firstTwoSubscriber { - navigateToEditSubscriberPage(subscriber.subscriberId); - }} > - +
@@ -161,16 +170,16 @@ export const SubscriberRow = ({ subscriber, subscribersCount, firstTwoSubscriber
- + {subscriber.email || '-'} - {subscriber.phone || '-'} - + {subscriber.phone || '-'} + {formatDateSimple(subscriber.createdAt)} - + {formatDateSimple(subscriber.updatedAt)} diff --git a/apps/dashboard/src/components/topics/topic-row.tsx b/apps/dashboard/src/components/topics/topic-row.tsx index bca56dcc7b8..c30470fe6d5 100644 --- a/apps/dashboard/src/components/topics/topic-row.tsx +++ b/apps/dashboard/src/components/topics/topic-row.tsx @@ -2,7 +2,7 @@ import { PermissionsEnum } from '@novu/shared'; import { useQueryClient } from '@tanstack/react-query'; import { ComponentProps, useState } from 'react'; import { RiDeleteBin2Line, RiFileCopyLine, RiMore2Fill, RiPulseFill } from 'react-icons/ri'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { ConfirmationModal } from '@/components/confirmation-modal'; import { CompactButton } from '@/components/primitives/button-compact'; import { CopyButton } from '@/components/primitives/copy-button'; @@ -24,22 +24,27 @@ import { buildRoute, ROUTES } from '../../utils/routes'; import { cn } from '../../utils/ui'; import { showErrorToast } from '../primitives/sonner-helpers'; import { useDeleteTopic } from './hooks/use-delete-topic'; -import { useTopicsNavigate } from './hooks/use-topics-navigate'; import { Topic } from './types'; type TopicRowProps = { topic: Topic; }; -type TopicTableCellProps = ComponentProps; +type TopicTableCellProps = ComponentProps & { + to?: string; +}; const TopicTableCell = (props: TopicTableCellProps) => { - const { children, className, ...rest } = props; + const { children, className, to, ...rest } = props; return ( + {to && ( + + Edit topic + + )} {children} - Edit topic ); }; @@ -49,7 +54,12 @@ export const TopicRow = ({ topic }: TopicRowProps) => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { deleteTopic, isDeleting } = useDeleteTopic(); const queryClient = useQueryClient(); - const { navigateToEditTopicPage } = useTopicsNavigate(); + const [searchParams] = useSearchParams(); + + const topicLink = `${buildRoute(ROUTES.TOPICS_EDIT, { + topicKey: topic.key, + environmentSlug: currentEnvironment?.slug ?? '', + })}?${searchParams.toString()}`; const stopPropagation = (e: React.MouseEvent) => { e.stopPropagation(); @@ -76,16 +86,13 @@ export const TopicRow = ({ topic }: TopicRowProps) => { <> { - navigateToEditTopicPage(topic.key); - }} > - +
{topic.name}
- +
{topic.key}
{ />
- + {topic.createdAt && ( {formatDateSimple(topic.createdAt)} )} - + {topic.updatedAt && ( {formatDateSimple(topic.updatedAt)} )} diff --git a/apps/dashboard/src/components/workflow-row.tsx b/apps/dashboard/src/components/workflow-row.tsx index 15bd1156149..30ae58efc9c 100644 --- a/apps/dashboard/src/components/workflow-row.tsx +++ b/apps/dashboard/src/components/workflow-row.tsx @@ -22,7 +22,7 @@ import { RiTranslate2, } from 'react-icons/ri'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { type ExternalToast } from 'sonner'; import { PAUSE_MODAL_TITLE, PauseModalDescription } from '@/components/pause-workflow-dialog'; import { @@ -97,15 +97,27 @@ const toastOptions: ExternalToast = { }, }; -type WorkflowLinkTableCellProps = ComponentProps; +type WorkflowLinkTableCellProps = ComponentProps & { + to?: string; + isExternal?: boolean; +}; const WorkflowLinkTableCell = (props: WorkflowLinkTableCellProps) => { - const { children, className, ...rest } = props; + const { children, className, to, isExternal, ...rest } = props; return ( + {to && + (isExternal ? ( + + Edit workflow + + ) : ( + + Edit workflow + + ))} {children} - Edit workflow ); }; @@ -116,7 +128,6 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { const { currentEnvironment } = useEnvironment(); const { isUserLoaded } = useAuth(); const has = useHasPermission(); - const navigate = useNavigate(); const { safeSync, PromoteConfirmModal } = useSyncWorkflow(workflow); const isHttpLogsPageEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_HTTP_LOGS_PAGE_ENABLED, false); const isV0Workflow = workflow.origin === ResourceOriginEnum.NOVU_CLOUD_V1; @@ -227,20 +238,9 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { onPauseWorkflow(); }; - const handleRowClick = () => { - if (isV0Workflow && IS_SELF_HOSTED) { - return; - } - - if (isV0Workflow) { - document.location.href = workflowLink; - } else { - navigate(workflowLink); - } - }; + const shouldRenderLink = !(isV0Workflow && IS_SELF_HOSTED); const stopPropagation = (e: React.MouseEvent) => { - // don't propagate the click event to the row e.stopPropagation(); }; @@ -253,7 +253,6 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { {isV0Workflow && IS_SELF_HOSTED && ( @@ -278,7 +277,11 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { )} - + {workflow.origin === ResourceOriginEnum.EXTERNAL ? ( @@ -343,17 +346,25 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => {
- + - + - + - + {workflow.lastTriggeredAt ? ( {formatDateSimple(workflow.lastTriggeredAt)} @@ -362,7 +373,11 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { - )} - + {formatDateSimple(workflow.updatedAt)} From f074784c9ba229f79b831b418e3a2e5e3521a50d Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:58:53 +0200 Subject: [PATCH 09/15] feat(api-service): enhance mock data generation with HTTP request handling and schema support fixes NV-7239 (#10300) --- .../src/usecases/preview/preview.types.ts | 4 +- .../services/mock-data-generator.service.ts | 17 ++++++- .../services/payload-merger.service.ts | 50 +++++++++++++++++-- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/libs/application-generic/src/usecases/preview/preview.types.ts b/libs/application-generic/src/usecases/preview/preview.types.ts index 27030bee2f3..a3f5d8e2707 100644 --- a/libs/application-generic/src/usecases/preview/preview.types.ts +++ b/libs/application-generic/src/usecases/preview/preview.types.ts @@ -2,6 +2,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { JobStatusEnum, NotificationTemplateEntity } from '@novu/dal'; import { JSONSchemaDto } from '../../dtos/json-schema.dto'; import { StepResponseDto } from '../../dtos/workflow/step.response.dto'; +import { StepType } from '../../services'; export type PreviewContext = { stepData: StepResponseDto; @@ -45,8 +46,9 @@ export type ControlValueProcessingResult = { }; export type MockStepResultOptions = { - stepType: string; + stepType: StepType; workflow?: NotificationTemplateEntity; + responseBodySchema?: Record; }; export type FrameworkPreviousStepsOutputState = { diff --git a/libs/application-generic/src/usecases/preview/services/mock-data-generator.service.ts b/libs/application-generic/src/usecases/preview/services/mock-data-generator.service.ts index 8d991eacb2a..ff9715c08ec 100644 --- a/libs/application-generic/src/usecases/preview/services/mock-data-generator.service.ts +++ b/libs/application-generic/src/usecases/preview/services/mock-data-generator.service.ts @@ -18,7 +18,7 @@ export class MockDataGeneratorService { * with special handling for digest steps that include workflow payload data. */ generateMockStepResult(options: MockStepResultOptions): Record { - const { stepType, workflow } = options; + const { stepType, workflow, responseBodySchema } = options; if (!stepType) { return {}; @@ -29,6 +29,10 @@ export class MockDataGeneratorService { return this.generateDigestStepResult(workflow); } + if (stepType === 'http_request') { + return this.generateHttpRequestStepResult(responseBodySchema); + } + let resultSchema: unknown = null; if (stepType in channelStepSchemas) { @@ -56,6 +60,17 @@ export class MockDataGeneratorService { } } + private generateHttpRequestStepResult(responseBodySchema?: unknown): Record { + if (responseBodySchema && typeof responseBodySchema === 'object' && 'properties' in responseBodySchema) { + const properties = responseBodySchema.properties as Record; + if (Object.keys(properties).length > 0) { + return JsonSchemaMock.generate(responseBodySchema) as Record; + } + } + + return {}; + } + private generateDigestStepResult(workflow?: NotificationTemplateEntity): Record { try { let payloadMockData = {}; diff --git a/libs/application-generic/src/usecases/preview/services/payload-merger.service.ts b/libs/application-generic/src/usecases/preview/services/payload-merger.service.ts index e1f9dc1524e..7e2fa9b2260 100644 --- a/libs/application-generic/src/usecases/preview/services/payload-merger.service.ts +++ b/libs/application-generic/src/usecases/preview/services/payload-merger.service.ts @@ -1,7 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { NotificationTemplateEntity } from '@novu/dal'; +import { ControlValuesRepository, NotificationTemplateEntity } from '@novu/dal'; import { ContextResolved } from '@novu/framework/internal'; -import { ContextPayload, createMockObjectFromSchema, ResourceOriginEnum, UserSessionData } from '@novu/shared'; +import { + ContextPayload, + ControlValuesLevelEnum, + createMockObjectFromSchema, + ResourceOriginEnum, + StepTypeEnum, + UserSessionData, +} from '@novu/shared'; import { isPlainObject, pick } from 'es-toolkit'; import { keys, merge, mergeWith } from 'es-toolkit/compat'; import { PreviewPayloadDto } from '../../../dtos/workflow/preview-payload.dto'; @@ -15,7 +22,8 @@ import { MockDataGeneratorService } from './mock-data-generator.service'; export class PayloadMergerService { constructor( private readonly mockDataGenerator: MockDataGeneratorService, - private readonly buildStepDataUsecase: BuildStepDataUsecase + private readonly buildStepDataUsecase: BuildStepDataUsecase, + private readonly controlValuesRepository: ControlValuesRepository ) {} /** @@ -273,6 +281,8 @@ export class PayloadMergerService { const previousSteps = workflow.steps.slice(0, currentStepIndex); const userStepsData = (userPayloadExample?.steps as Record) || {}; + const httpControlValuesMap = await this.getHttpControlValuesMap(previousSteps, workflow); + for (const step of previousSteps) { const stepId = step.stepId || step._id; @@ -281,9 +291,16 @@ export class PayloadMergerService { stepsObject[stepId] = userStepsData[stepId]; } else { // Fall back to generating mock data + const stepControls = step._id ? httpControlValuesMap[step._id] : undefined; + const responseBodySchema = + step.template?.type === StepTypeEnum.HTTP_REQUEST + ? (stepControls?.responseBodySchema as Record | undefined) + : undefined; + const mockResult = this.mockDataGenerator.generateMockStepResult({ stepType: step.template?.type || '', workflow, + responseBodySchema, }); stepsObject[stepId] = mockResult; @@ -294,6 +311,33 @@ export class PayloadMergerService { return stepsObject; } + private async getHttpControlValuesMap( + previousSteps: NotificationTemplateEntity['steps'], + workflow: NotificationTemplateEntity + ): Promise>> { + const httpRequestStepIds = previousSteps + .filter((step) => step.template?.type === StepTypeEnum.HTTP_REQUEST && step._id) + .map((step) => step._id as string); + + const httpControlValuesMap: Record> = {}; + if (httpRequestStepIds.length > 0) { + const controlValues = await this.controlValuesRepository.findMany({ + _environmentId: workflow._environmentId, + _organizationId: workflow._organizationId, + _workflowId: workflow._id, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }); + + for (const cv of controlValues) { + if (cv._stepId && httpRequestStepIds.includes(cv._stepId)) { + httpControlValuesMap[cv._stepId] = cv.controls as Record; + } + } + } + + return httpControlValuesMap; + } + private async getStepData({ workflowIdOrInternalId, stepIdOrInternalId, From ccaf1ee5b2a0edbd1840d687a67dfd99d15a7ae7 Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:09:47 +0200 Subject: [PATCH 10/15] fix(api-service): decrypt inbound webhook integration credentials (#10301) Co-authored-by: Cursor Agent Co-authored-by: George Djabarov --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 3d5c2da9bab..75e8eeb78da 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 3d5c2da9bab4d95a0bc5694da0dda8aab8684f18 +Subproject commit 75e8eeb78da6d8b8b2403172666cca55029523c4 From d7e3c12f1919dec0603976966d5f6aec925ea92c Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:24:21 +0200 Subject: [PATCH 11/15] fix(root): resolve high tar-fs vulnerability (#10295) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2d452a1a21b..e69ec19367c 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "semver@>=7.0.0 <7.5.2": "^7.5.2", "systeminformation@<5.31.0": "^5.31.3", "tar": "7.5.11", - "tar-fs": ">=3.0.9", + "tar-fs": ">=3.1.1", "tough-cookie@<4.1.3": "^4.1.3", "trim-newlines@<3.0.1": "^3.0.1", "xml2js@<0.5.0": "^0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d5b789c582..6c9770cca45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ overrides: semver@>=7.0.0 <7.5.2: ^7.5.2 systeminformation@<5.31.0: ^5.31.3 tar: 7.5.11 - tar-fs: '>=3.0.9' + tar-fs: '>=3.1.1' tough-cookie@<4.1.3: ^4.1.3 trim-newlines@<3.0.1: ^3.0.1 xml2js@<0.5.0: ^0.5.0 @@ -26281,8 +26281,8 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} - tar-fs@3.0.9: - resolution: {integrity: sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==} + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} @@ -55652,7 +55652,7 @@ snapshots: node-abi: 3.65.0 npm-run-path: 3.1.0 pump: 3.0.0 - tar-fs: 3.0.9 + tar-fs: 3.1.2 transitivePeerDependencies: - bare-buffer optional: true @@ -58386,7 +58386,7 @@ snapshots: tapable@2.2.1: {} - tar-fs@3.0.9: + tar-fs@3.1.2: dependencies: pump: 3.0.0 tar-stream: 3.1.7 From c90a2dd5cfc5945a40f27dd60cb6b0b90a366c2a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 15 Mar 2026 14:41:50 +0200 Subject: [PATCH 12/15] fix(dashboard): Translation JSON line breaks fixes NV-7160 (#10303) Co-authored-by: Cursor Agent --- .../hooks/use-translation-editor.ts | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/translations/translation-drawer/hooks/use-translation-editor.ts b/apps/dashboard/src/components/translations/translation-drawer/hooks/use-translation-editor.ts index b80b5b35481..55e088c36e6 100644 --- a/apps/dashboard/src/components/translations/translation-drawer/hooks/use-translation-editor.ts +++ b/apps/dashboard/src/components/translations/translation-drawer/hooks/use-translation-editor.ts @@ -1,6 +1,54 @@ import { TranslationResponseDto } from '@novu/api/models/components'; import { useCallback, useEffect, useMemo, useState } from 'react'; +function escapeControlCharsInJsonStrings(jsonString: string): string { + let result = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < jsonString.length; i++) { + const char = jsonString[i]; + + if (escaped) { + result += char; + escaped = false; + continue; + } + + if (char === '\\' && inString) { + escaped = true; + result += char; + continue; + } + + if (char === '"') { + inString = !inString; + result += char; + continue; + } + + if (inString) { + const code = char.charCodeAt(0); + if (code < 0x20) { + if (char === '\n') { + result += '\\n'; + } else if (char === '\r') { + result += '\\r'; + } else if (char === '\t') { + result += '\\t'; + } else { + result += `\\u${code.toString(16).padStart(4, '0')}`; + } + continue; + } + } + + result += char; + } + + return result; +} + export function useTranslationEditor(selectedTranslation: TranslationResponseDto | undefined) { const [modifiedContentString, setModifiedContentString] = useState(null); const [modifiedContent, setModifiedContent] = useState | null>(null); @@ -17,16 +65,20 @@ export function useTranslationEditor(selectedTranslation: TranslationResponseDto }, [selectedTranslation?.locale]); const handleContentChange = useCallback((newContentString: string) => { - // Store the raw string content without any reformatting setModifiedContentString(newContentString); try { - // Only parse for validation, don't modify the content setModifiedContent(JSON.parse(newContentString)); setJsonError(null); } catch (error) { - setModifiedContent(null); - setJsonError(error instanceof Error ? error.message : 'Invalid JSON format'); + try { + const sanitized = escapeControlCharsInJsonStrings(newContentString); + setModifiedContent(JSON.parse(sanitized)); + setJsonError(null); + } catch { + setModifiedContent(null); + setJsonError(error instanceof Error ? error.message : 'Invalid JSON format'); + } } }, []); From edff6d776739094a75f5b50dae30b9db8cb93b3d Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 15 Mar 2026 14:47:14 +0200 Subject: [PATCH 13/15] feat(novu): Agent toolkit for novu (#10298) --- packages/agent-toolkit/README.md | 173 +++ packages/agent-toolkit/package.json | 143 ++ packages/agent-toolkit/src/ai-sdk/index.ts | 125 ++ .../src/ai-sdk/tool-converter.ts | 16 + packages/agent-toolkit/src/core/index.ts | 3 + packages/agent-toolkit/src/core/novu-tool.ts | 35 + .../agent-toolkit/src/core/novu-toolkit.ts | 43 + packages/agent-toolkit/src/core/types.ts | 23 + .../src/human-in-the-loop/index.ts | 97 ++ .../src/human-in-the-loop/types.ts | 56 + packages/agent-toolkit/src/index.ts | 3 + packages/agent-toolkit/src/langchain/index.ts | 89 ++ .../src/langchain/tool-converter.ts | 20 + packages/agent-toolkit/src/openai/index.ts | 155 ++ .../src/openai/tool-converter.ts | 22 + packages/agent-toolkit/src/tools/index.ts | 8 + .../agent-toolkit/src/tools/preferences.ts | 62 + .../src/tools/trigger-workflow.ts | 51 + .../src/tools/workflows-as-tools.ts | 117 ++ packages/agent-toolkit/tsconfig.json | 20 + packages/agent-toolkit/tsup.config.ts | 30 + playground/nextjs/.env.example | 6 + playground/nextjs/package.json | 45 +- .../src/app/agent-toolkit/app-sidenav.tsx | 79 + .../nextjs/src/app/agent-toolkit/layout.tsx | 19 + .../nextjs/src/app/agent-toolkit/page.tsx | 237 +++ .../src/app/api/agent-toolkit/chat/route.ts | 22 + .../src/app/api/agent-toolkit/lib/toolkit.ts | 98 ++ .../src/app/api/agent-toolkit/result/route.ts | 18 + .../app/api/agent-toolkit/webhook/route.ts | 44 + playground/nextjs/src/components/SideNav.tsx | 1 + .../src/components/ai-elements/agent.tsx | 142 ++ .../src/components/ai-elements/artifact.tsx | 149 ++ .../components/ai-elements/attachments.tsx | 427 ++++++ .../components/ai-elements/audio-player.tsx | 232 +++ .../src/components/ai-elements/canvas.tsx | 26 + .../ai-elements/chain-of-thought.tsx | 223 +++ .../src/components/ai-elements/checkpoint.tsx | 72 + .../src/components/ai-elements/code-block.tsx | 555 +++++++ .../src/components/ai-elements/commit.tsx | 449 ++++++ .../components/ai-elements/confirmation.tsx | 173 +++ .../src/components/ai-elements/connection.tsx | 28 + .../src/components/ai-elements/context.tsx | 410 +++++ .../src/components/ai-elements/controls.tsx | 19 + .../components/ai-elements/conversation.tsx | 167 ++ .../src/components/ai-elements/edge.tsx | 144 ++ .../ai-elements/environment-variables.tsx | 325 ++++ .../src/components/ai-elements/file-tree.tsx | 298 ++++ .../src/components/ai-elements/image.tsx | 25 + .../ai-elements/inline-citation.tsx | 294 ++++ .../components/ai-elements/jsx-preview.tsx | 250 +++ .../src/components/ai-elements/message.tsx | 359 +++++ .../components/ai-elements/mic-selector.tsx | 372 +++++ .../components/ai-elements/model-selector.tsx | 214 +++ .../src/components/ai-elements/node.tsx | 72 + .../components/ai-elements/open-in-chat.tsx | 367 +++++ .../components/ai-elements/package-info.tsx | 235 +++ .../src/components/ai-elements/panel.tsx | 16 + .../src/components/ai-elements/persona.tsx | 280 ++++ .../src/components/ai-elements/plan.tsx | 144 ++ .../components/ai-elements/prompt-input.tsx | 1341 +++++++++++++++++ .../src/components/ai-elements/queue.tsx | 275 ++++ .../src/components/ai-elements/reasoning.tsx | 229 +++ .../src/components/ai-elements/sandbox.tsx | 133 ++ .../components/ai-elements/schema-display.tsx | 473 ++++++ .../src/components/ai-elements/shimmer.tsx | 78 + .../src/components/ai-elements/snippet.tsx | 141 ++ .../src/components/ai-elements/sources.tsx | 78 + .../components/ai-elements/speech-input.tsx | 324 ++++ .../components/ai-elements/stack-trace.tsx | 531 +++++++ .../src/components/ai-elements/suggestion.tsx | 58 + .../src/components/ai-elements/task.tsx | 88 ++ .../src/components/ai-elements/terminal.tsx | 277 ++++ .../components/ai-elements/test-results.tsx | 497 ++++++ .../src/components/ai-elements/tool.tsx | 174 +++ .../src/components/ai-elements/toolbar.tsx | 17 + .../components/ai-elements/transcription.tsx | 126 ++ .../components/ai-elements/voice-selector.tsx | 525 +++++++ .../components/ai-elements/web-preview.tsx | 282 ++++ .../nextjs/src/components/ui/accordion.tsx | 55 + playground/nextjs/src/components/ui/alert.tsx | 59 + .../nextjs/src/components/ui/avatar.tsx | 50 + playground/nextjs/src/components/ui/badge.tsx | 36 + .../nextjs/src/components/ui/button-group.tsx | 83 + playground/nextjs/src/components/ui/card.tsx | 76 + .../nextjs/src/components/ui/carousel.tsx | 260 ++++ .../nextjs/src/components/ui/collapsible.tsx | 11 + .../nextjs/src/components/ui/command.tsx | 153 ++ .../nextjs/src/components/ui/dialog.tsx | 120 ++ .../nextjs/src/components/ui/hover-card.tsx | 29 + .../nextjs/src/components/ui/input-group.tsx | 168 +++ playground/nextjs/src/components/ui/input.tsx | 22 + .../nextjs/src/components/ui/progress.tsx | 26 + .../nextjs/src/components/ui/scroll-area.tsx | 46 + .../nextjs/src/components/ui/select.tsx | 157 ++ .../nextjs/src/components/ui/separator.tsx | 29 + .../nextjs/src/components/ui/spinner.tsx | 16 + playground/nextjs/src/components/ui/tabs.tsx | 55 + .../nextjs/src/components/ui/textarea.tsx | 22 + .../nextjs/src/components/ui/tooltip.tsx | 30 + playground/nextjs/tsconfig.json | 2 +- pnpm-lock.yaml | 1297 +++++++++++++++- 102 files changed, 16693 insertions(+), 83 deletions(-) create mode 100644 packages/agent-toolkit/README.md create mode 100644 packages/agent-toolkit/package.json create mode 100644 packages/agent-toolkit/src/ai-sdk/index.ts create mode 100644 packages/agent-toolkit/src/ai-sdk/tool-converter.ts create mode 100644 packages/agent-toolkit/src/core/index.ts create mode 100644 packages/agent-toolkit/src/core/novu-tool.ts create mode 100644 packages/agent-toolkit/src/core/novu-toolkit.ts create mode 100644 packages/agent-toolkit/src/core/types.ts create mode 100644 packages/agent-toolkit/src/human-in-the-loop/index.ts create mode 100644 packages/agent-toolkit/src/human-in-the-loop/types.ts create mode 100644 packages/agent-toolkit/src/index.ts create mode 100644 packages/agent-toolkit/src/langchain/index.ts create mode 100644 packages/agent-toolkit/src/langchain/tool-converter.ts create mode 100644 packages/agent-toolkit/src/openai/index.ts create mode 100644 packages/agent-toolkit/src/openai/tool-converter.ts create mode 100644 packages/agent-toolkit/src/tools/index.ts create mode 100644 packages/agent-toolkit/src/tools/preferences.ts create mode 100644 packages/agent-toolkit/src/tools/trigger-workflow.ts create mode 100644 packages/agent-toolkit/src/tools/workflows-as-tools.ts create mode 100644 packages/agent-toolkit/tsconfig.json create mode 100644 packages/agent-toolkit/tsup.config.ts create mode 100644 playground/nextjs/src/app/agent-toolkit/app-sidenav.tsx create mode 100644 playground/nextjs/src/app/agent-toolkit/layout.tsx create mode 100644 playground/nextjs/src/app/agent-toolkit/page.tsx create mode 100644 playground/nextjs/src/app/api/agent-toolkit/chat/route.ts create mode 100644 playground/nextjs/src/app/api/agent-toolkit/lib/toolkit.ts create mode 100644 playground/nextjs/src/app/api/agent-toolkit/result/route.ts create mode 100644 playground/nextjs/src/app/api/agent-toolkit/webhook/route.ts create mode 100644 playground/nextjs/src/components/ai-elements/agent.tsx create mode 100644 playground/nextjs/src/components/ai-elements/artifact.tsx create mode 100644 playground/nextjs/src/components/ai-elements/attachments.tsx create mode 100644 playground/nextjs/src/components/ai-elements/audio-player.tsx create mode 100644 playground/nextjs/src/components/ai-elements/canvas.tsx create mode 100644 playground/nextjs/src/components/ai-elements/chain-of-thought.tsx create mode 100644 playground/nextjs/src/components/ai-elements/checkpoint.tsx create mode 100644 playground/nextjs/src/components/ai-elements/code-block.tsx create mode 100644 playground/nextjs/src/components/ai-elements/commit.tsx create mode 100644 playground/nextjs/src/components/ai-elements/confirmation.tsx create mode 100644 playground/nextjs/src/components/ai-elements/connection.tsx create mode 100644 playground/nextjs/src/components/ai-elements/context.tsx create mode 100644 playground/nextjs/src/components/ai-elements/controls.tsx create mode 100644 playground/nextjs/src/components/ai-elements/conversation.tsx create mode 100644 playground/nextjs/src/components/ai-elements/edge.tsx create mode 100644 playground/nextjs/src/components/ai-elements/environment-variables.tsx create mode 100644 playground/nextjs/src/components/ai-elements/file-tree.tsx create mode 100644 playground/nextjs/src/components/ai-elements/image.tsx create mode 100644 playground/nextjs/src/components/ai-elements/inline-citation.tsx create mode 100644 playground/nextjs/src/components/ai-elements/jsx-preview.tsx create mode 100644 playground/nextjs/src/components/ai-elements/message.tsx create mode 100644 playground/nextjs/src/components/ai-elements/mic-selector.tsx create mode 100644 playground/nextjs/src/components/ai-elements/model-selector.tsx create mode 100644 playground/nextjs/src/components/ai-elements/node.tsx create mode 100644 playground/nextjs/src/components/ai-elements/open-in-chat.tsx create mode 100644 playground/nextjs/src/components/ai-elements/package-info.tsx create mode 100644 playground/nextjs/src/components/ai-elements/panel.tsx create mode 100644 playground/nextjs/src/components/ai-elements/persona.tsx create mode 100644 playground/nextjs/src/components/ai-elements/plan.tsx create mode 100644 playground/nextjs/src/components/ai-elements/prompt-input.tsx create mode 100644 playground/nextjs/src/components/ai-elements/queue.tsx create mode 100644 playground/nextjs/src/components/ai-elements/reasoning.tsx create mode 100644 playground/nextjs/src/components/ai-elements/sandbox.tsx create mode 100644 playground/nextjs/src/components/ai-elements/schema-display.tsx create mode 100644 playground/nextjs/src/components/ai-elements/shimmer.tsx create mode 100644 playground/nextjs/src/components/ai-elements/snippet.tsx create mode 100644 playground/nextjs/src/components/ai-elements/sources.tsx create mode 100644 playground/nextjs/src/components/ai-elements/speech-input.tsx create mode 100644 playground/nextjs/src/components/ai-elements/stack-trace.tsx create mode 100644 playground/nextjs/src/components/ai-elements/suggestion.tsx create mode 100644 playground/nextjs/src/components/ai-elements/task.tsx create mode 100644 playground/nextjs/src/components/ai-elements/terminal.tsx create mode 100644 playground/nextjs/src/components/ai-elements/test-results.tsx create mode 100644 playground/nextjs/src/components/ai-elements/tool.tsx create mode 100644 playground/nextjs/src/components/ai-elements/toolbar.tsx create mode 100644 playground/nextjs/src/components/ai-elements/transcription.tsx create mode 100644 playground/nextjs/src/components/ai-elements/voice-selector.tsx create mode 100644 playground/nextjs/src/components/ai-elements/web-preview.tsx create mode 100644 playground/nextjs/src/components/ui/accordion.tsx create mode 100644 playground/nextjs/src/components/ui/alert.tsx create mode 100644 playground/nextjs/src/components/ui/avatar.tsx create mode 100644 playground/nextjs/src/components/ui/badge.tsx create mode 100644 playground/nextjs/src/components/ui/button-group.tsx create mode 100644 playground/nextjs/src/components/ui/card.tsx create mode 100644 playground/nextjs/src/components/ui/carousel.tsx create mode 100644 playground/nextjs/src/components/ui/collapsible.tsx create mode 100644 playground/nextjs/src/components/ui/command.tsx create mode 100644 playground/nextjs/src/components/ui/dialog.tsx create mode 100644 playground/nextjs/src/components/ui/hover-card.tsx create mode 100644 playground/nextjs/src/components/ui/input-group.tsx create mode 100644 playground/nextjs/src/components/ui/input.tsx create mode 100644 playground/nextjs/src/components/ui/progress.tsx create mode 100644 playground/nextjs/src/components/ui/scroll-area.tsx create mode 100644 playground/nextjs/src/components/ui/select.tsx create mode 100644 playground/nextjs/src/components/ui/separator.tsx create mode 100644 playground/nextjs/src/components/ui/spinner.tsx create mode 100644 playground/nextjs/src/components/ui/tabs.tsx create mode 100644 playground/nextjs/src/components/ui/textarea.tsx create mode 100644 playground/nextjs/src/components/ui/tooltip.tsx diff --git a/packages/agent-toolkit/README.md b/packages/agent-toolkit/README.md new file mode 100644 index 00000000000..ee5c3500a63 --- /dev/null +++ b/packages/agent-toolkit/README.md @@ -0,0 +1,173 @@ +# @novu/agent-toolkit + +Expose [Novu](https://novu.co) notification workflows as tools for LLM agents. Works with **OpenAI**, **LangChain**, and **Vercel AI SDK**. + +The toolkit automatically discovers your Novu workflows and converts them into strongly-typed tools that an LLM can invoke, letting your AI agent send notifications, manage subscriber preferences, and trigger any workflow you've built in Novu. + +## Installation + +```bash +npm install @novu/agent-toolkit +``` + +Install the peer dependency for the framework you use: + +| Framework | Peer dependency | Import path | +|---|---|---| +| OpenAI | `openai >= 4.0.0` | `@novu/agent-toolkit/openai` | +| LangChain | `@langchain/core >= 0.2.0` | `@novu/agent-toolkit/langchain` | +| Vercel AI SDK | `ai >= 6.0.0` | `@novu/agent-toolkit/ai-sdk` | + +## Quick Start + +```typescript +import { createNovuAgentToolkit } from '@novu/agent-toolkit/openai'; +import OpenAI from 'openai'; + +const openai = new OpenAI(); + +const toolkit = await createNovuAgentToolkit({ + secretKey: process.env.NOVU_SECRET_KEY, + subscriberId: 'user-123', +}); + +const response = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'Send a welcome email to user-123' }], + tools: toolkit.tools, +}); + +// Handle tool calls +for (const toolCall of response.choices[0].message.tool_calls ?? []) { + const result = await toolkit.handleToolCall(toolCall); + console.log(result); +} +``` + +## Configuration + +Every adapter's `createNovuAgentToolkit` accepts a `NovuToolkitConfig` object: + +```typescript +type NovuToolkitConfig = { + secretKey: string; + subscriberId: string; + backendUrl?: string; + workflows?: { + tags?: string[]; + workflowIds?: string[]; + }; +}; +``` + +| Option | Required | Description | +|---|---|---| +| `secretKey` | Yes | Your Novu API secret key. | +| `subscriberId` | Yes | Default subscriber ID used when triggering workflows. | +| `backendUrl` | No | Custom Novu API URL (defaults to Novu Cloud). | +| `workflows.tags` | No | Filter discovered workflows by tags. | +| `workflows.workflowIds` | No | Restrict discovered workflows to specific IDs. | + +## Framework Adapters + +Each adapter exposes a `createNovuAgentToolkit` function that returns tools in the native format for that framework. + +### OpenAI + +```typescript +import { createNovuAgentToolkit } from '@novu/agent-toolkit/openai'; + +const toolkit = await createNovuAgentToolkit({ + secretKey: process.env.NOVU_SECRET_KEY, + subscriberId: 'user-123', +}); + +// toolkit.tools — OpenAI function tool definitions +// toolkit.handleToolCall — execute a tool call and return a tool message +``` + +The returned `toolkit` provides: + +- **`tools`** — Array of OpenAI-compatible function tool definitions. +- **`handleToolCall(toolCall)`** — Executes a tool call and returns a `{ role: 'tool', tool_call_id, content }` message ready to append to the conversation. + +### LangChain + +```typescript +import { createNovuAgentToolkit } from '@novu/agent-toolkit/langchain'; + +const toolkit = await createNovuAgentToolkit({ + secretKey: process.env.NOVU_SECRET_KEY, + subscriberId: 'user-123', +}); + +// toolkit.tools — DynamicStructuredTool[] ready for use with LangChain agents +``` + +The returned `toolkit` provides: + +- **`tools`** — Array of `DynamicStructuredTool` instances that can be passed directly to LangChain agents or executors. + +### Vercel AI SDK + +```typescript +import { createNovuAgentToolkit } from '@novu/agent-toolkit/ai-sdk'; + +const toolkit = await createNovuAgentToolkit({ + secretKey: process.env.NOVU_SECRET_KEY, + subscriberId: 'user-123', +}); + +// toolkit.tools — ToolSet compatible with generateText / streamText +``` + +The returned `toolkit` provides: + +- **`tools`** — A `ToolSet` object that can be passed to `generateText`, `streamText`, or other Vercel AI SDK functions. + +## Built-in Tools + +The toolkit ships with two built-in tools that are always available: + +### `trigger_workflow` + +Triggers any Novu workflow by its identifier. Use this as a generic entry point to send notifications. + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `workflowId` | `string` | Yes | The workflow identifier to trigger. | +| `payload` | `Record` | No | Data passed to the workflow for rendering. | +| `overrides` | `Record` | No | Provider-specific configuration overrides. | +| `subscriberId` | `string` | No | Target subscriber (defaults to configured `subscriberId`). | +| `transactionId` | `string` | No | Unique key for deduplication. | + +### `update_preferences` + +Updates notification channel preferences for a subscriber, either globally or for a specific workflow. + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `workflowId` | `string` | No | Scope to a specific workflow. Omit for global preferences. | +| `channels` | `object` | No | Channel toggles: `email`, `sms`, `push`, `inApp`, `chat`. | +| `subscriberId` | `string` | No | Target subscriber (defaults to configured `subscriberId`). | + +## Dynamic Workflow Tools + +On initialization the toolkit fetches your Novu workflows and creates a dedicated tool for each one. These tools are named `trigger_` (with hyphens replaced by underscores) and include the workflow's payload schema so the LLM knows exactly what data to provide. + +Filter which workflows are exposed using the `workflows` config option: + +```typescript +const toolkit = await createNovuAgentToolkit({ + secretKey: process.env.NOVU_SECRET_KEY, + subscriberId: 'user-123', + workflows: { + tags: ['ai-agent'], + workflowIds: ['welcome-email', 'order-confirmation'], + }, +}); +``` diff --git a/packages/agent-toolkit/package.json b/packages/agent-toolkit/package.json new file mode 100644 index 00000000000..69a121550e2 --- /dev/null +++ b/packages/agent-toolkit/package.json @@ -0,0 +1,143 @@ +{ + "name": "@novu/agent-toolkit", + "version": "0.1.0", + "description": "Novu Agent Toolkit - expose Novu notification workflows as LLM agent tools.", + "main": "./dist/cjs/index.cjs", + "types": "./dist/cjs/index.d.cts", + "module": "./dist/esm/index.js", + "type": "module", + "publishConfig": { + "access": "public" + }, + "private": false, + "repository": { + "type": "git", + "url": "git+https://github.com/novuhq/novu.git" + }, + "files": [ + "dist", + "openai", + "langchain", + "ai-sdk", + "human-in-the-loop", + "core", + "README.md" + ], + "scripts": { + "build": "NODE_ENV=production tsup", + "build:watch": "tsup --watch", + "check": "biome check .", + "check:fix": "biome check --write ." + }, + "keywords": [ + "novu", + "agent", + "toolkit", + "ai", + "llm", + "notifications", + "workflows", + "openai", + "langchain", + "vercel-ai-sdk" + ], + "author": "Novu Team ", + "license": "ISC", + "exports": { + ".": { + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./openai": { + "require": { + "types": "./dist/cjs/openai/index.d.cts", + "default": "./dist/cjs/openai/index.cjs" + }, + "import": { + "types": "./dist/esm/openai/index.d.ts", + "default": "./dist/esm/openai/index.js" + } + }, + "./langchain": { + "require": { + "types": "./dist/cjs/langchain/index.d.cts", + "default": "./dist/cjs/langchain/index.cjs" + }, + "import": { + "types": "./dist/esm/langchain/index.d.ts", + "default": "./dist/esm/langchain/index.js" + } + }, + "./ai-sdk": { + "require": { + "types": "./dist/cjs/ai-sdk/index.d.cts", + "default": "./dist/cjs/ai-sdk/index.cjs" + }, + "import": { + "types": "./dist/esm/ai-sdk/index.d.ts", + "default": "./dist/esm/ai-sdk/index.js" + } + }, + "./human-in-the-loop": { + "require": { + "types": "./dist/cjs/human-in-the-loop/index.d.cts", + "default": "./dist/cjs/human-in-the-loop/index.cjs" + }, + "import": { + "types": "./dist/esm/human-in-the-loop/index.d.ts", + "default": "./dist/esm/human-in-the-loop/index.js" + } + }, + "./core": { + "require": { + "types": "./dist/cjs/core/index.d.cts", + "default": "./dist/cjs/core/index.cjs" + }, + "import": { + "types": "./dist/esm/core/index.d.ts", + "default": "./dist/esm/core/index.js" + } + } + }, + "peerDependencies": { + "openai": ">=4.0.0", + "@langchain/core": ">=0.2.0", + "ai": ">=6.0.0" + }, + "peerDependenciesMeta": { + "openai": { + "optional": true + }, + "@langchain/core": { + "optional": true + }, + "ai": { + "optional": true + } + }, + "dependencies": { + "@novu/api": "workspace:*", + "json-schema-to-zod": "^2.7.0", + "zod": "^4.0.0", + "zod-to-json-schema": "^3.25.1" + }, + "devDependencies": { + "@langchain/core": "^0.3.0", + "@types/node": "^20.15.0", + "ai": "^6.0.0", + "openai": "^4.0.0", + "tsup": "^8.0.2", + "typescript": "5.6.2" + }, + "nx": { + "tags": [ + "type:package" + ] + } +} diff --git a/packages/agent-toolkit/src/ai-sdk/index.ts b/packages/agent-toolkit/src/ai-sdk/index.ts new file mode 100644 index 00000000000..f0c3afab2a7 --- /dev/null +++ b/packages/agent-toolkit/src/ai-sdk/index.ts @@ -0,0 +1,125 @@ +import { tool, type Tool, type ToolExecutionOptions, type ToolSet } from 'ai'; +import type { ZodTypeAny } from 'zod'; +import { NovuToolkit } from '../core/novu-toolkit.js'; +import type { NovuToolkitConfig } from '../core/types.js'; +import { + executeWithDecision, + handleWebhookEvent, + triggerHumanInputWorkflow, + wrapToolDescription, +} from '../human-in-the-loop/index.js'; +import type { + DeferredToolCall, + DeferredToolCallInteractionResult, + HumanDecision, + HumanInputConfig, + WebhookEvent, +} from '../human-in-the-loop/types.js'; +import { novuToolToAiSdkTool } from './tool-converter.js'; + +export type { ToolSet as AiSdkToolSet }; +export type { DeferredToolCall, DeferredToolCallInteractionResult, HumanDecision, HumanInputConfig, WebhookEvent }; + +type NovuAiSdkToolkit = { + tools: ToolSet; + requireHumanInput: (toolsToWrap: ToolSet, inputConfig: HumanInputConfig) => ToolSet; + resumeToolExecution: (toolCall: DeferredToolCall, decision: HumanDecision) => Promise; + handleWebhookEvent: (event: WebhookEvent) => DeferredToolCallInteractionResult | null; +}; + +export async function createNovuAgentToolkit(config: NovuToolkitConfig): Promise { + const toolkit = new NovuToolkit(config); + await toolkit.initialize(); + + const novuTools = toolkit.getTools(); + const client = toolkit.getClient(); + const toolkitConfig = toolkit.getConfig(); + + const tools: ToolSet = Object.fromEntries( + novuTools.map((t) => [t.method, novuToolToAiSdkTool(t, client, toolkitConfig)]), + ); + + const pendingTools = new Map(); + + const requireHumanInput = (toolsToWrap: ToolSet, inputConfig: HumanInputConfig): ToolSet => { + const wrappedTools: ToolSet = {}; + + for (const [method, originalTool] of Object.entries(toolsToWrap)) { + pendingTools.set(method, originalTool); + + wrappedTools[method] = tool({ + description: wrapToolDescription(originalTool.description ?? ''), + inputSchema: originalTool.inputSchema as ZodTypeAny, + execute: async (args: unknown, options: ToolExecutionOptions) => { + const toolCall: DeferredToolCall = { + id: options.toolCallId ?? crypto.randomUUID(), + method, + args, + extra: { toolCallId: options.toolCallId }, + }; + + await triggerHumanInputWorkflow({ + client, + toolCall, + inputConfig, + }); + + return { + type: 'tool-status', + status: 'pending-input', + toolCallId: toolCall.id, + }; + }, + }) as Tool; + } + + return wrappedTools; + }; + + const resumeToolExecution = async (toolCall: DeferredToolCall, decision: HumanDecision): Promise => { + const originalTool = pendingTools.get(toolCall.method); + + if (!originalTool) { + throw new Error( + `Tool "${toolCall.method}" not found. Make sure requireHumanInput was called with this tool before attempting to resume.`, + ); + } + + const executeFn = originalTool.execute as + | ((args: unknown, options: ToolExecutionOptions) => PromiseLike) + | undefined; + + if (!executeFn) { + throw new Error(`Tool "${toolCall.method}" does not have an execute function.`); + } + + const options: ToolExecutionOptions = { + toolCallId: (toolCall.extra?.toolCallId as string) ?? toolCall.id, + messages: [], + }; + + const result = await executeWithDecision( + (args) => executeFn(args, options) as Promise, + toolCall, + decision, + ); + + if (decision.type === 'reject') { + return result; + } + + return { + type: 'tool-status', + status: 'completed', + toolCallId: toolCall.id, + result, + }; + }; + + return { + tools, + requireHumanInput, + resumeToolExecution, + handleWebhookEvent, + }; +} diff --git a/packages/agent-toolkit/src/ai-sdk/tool-converter.ts b/packages/agent-toolkit/src/ai-sdk/tool-converter.ts new file mode 100644 index 00000000000..41bcc74a88b --- /dev/null +++ b/packages/agent-toolkit/src/ai-sdk/tool-converter.ts @@ -0,0 +1,16 @@ +import { tool, type Tool } from 'ai'; +import type { ZodTypeAny } from 'zod'; +import type { Novu } from '@novu/api'; +import type { NovuToolDefinition, NovuToolkitConfig } from '../core/types.js'; + +export function novuToolToAiSdkTool( + novuTool: NovuToolDefinition, + client: Novu, + config: NovuToolkitConfig, +): Tool { + return tool({ + description: novuTool.description, + inputSchema: novuTool.parameters as ZodTypeAny, + execute: async (input: unknown) => novuTool.bindExecute(client, config)(input), + }) as Tool; +} diff --git a/packages/agent-toolkit/src/core/index.ts b/packages/agent-toolkit/src/core/index.ts new file mode 100644 index 00000000000..6b993b6ffec --- /dev/null +++ b/packages/agent-toolkit/src/core/index.ts @@ -0,0 +1,3 @@ +export { NovuTool } from './novu-tool.js'; +export { NovuToolkit } from './novu-toolkit.js'; +export type { NovuToolkitConfig, NovuToolDefinition, NovuToolExecute } from './types.js'; diff --git a/packages/agent-toolkit/src/core/novu-tool.ts b/packages/agent-toolkit/src/core/novu-tool.ts new file mode 100644 index 00000000000..6dd143e6ed5 --- /dev/null +++ b/packages/agent-toolkit/src/core/novu-tool.ts @@ -0,0 +1,35 @@ +import type { ZodTypeAny } from 'zod'; +import type { Novu } from '@novu/api'; +import type { NovuToolkitConfig, NovuToolDefinition, NovuToolExecute } from './types.js'; + +type NovuToolArgs = { + method: string; + name: string; + description: string; + parameters: ZodTypeAny; + execute: (client: Novu, config: NovuToolkitConfig) => NovuToolExecute; +}; + +export function NovuTool(args: NovuToolArgs): NovuToolDefinition { + const { method, name, description, parameters, execute } = args; + + return { + method, + name, + description, + parameters, + bindExecute: (client: Novu, config: NovuToolkitConfig) => { + const fn = execute(client, config); + + return async (params: unknown) => { + try { + return await fn(params); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + return { error: message }; + } + }; + }, + }; +} diff --git a/packages/agent-toolkit/src/core/novu-toolkit.ts b/packages/agent-toolkit/src/core/novu-toolkit.ts new file mode 100644 index 00000000000..95f8e2c9c59 --- /dev/null +++ b/packages/agent-toolkit/src/core/novu-toolkit.ts @@ -0,0 +1,43 @@ +import { Novu } from '@novu/api'; +import type { NovuToolkitConfig, NovuToolDefinition } from './types.js'; +import { builtInTools, createWorkflowTools } from '../tools/index.js'; + +export class NovuToolkit { + private readonly client: Novu; + private readonly config: NovuToolkitConfig; + private tools: NovuToolDefinition[] = []; + private initialized = false; + + constructor(config: NovuToolkitConfig) { + this.config = config; + this.client = new Novu({ + security: { secretKey: config.secretKey }, + serverURL: config.backendUrl, + }); + } + + async initialize(): Promise { + if (this.initialized) return; + + const workflowTools = await createWorkflowTools(this.client, this.config); + + this.tools = [...builtInTools, ...workflowTools]; + this.initialized = true; + } + + getTools(): NovuToolDefinition[] { + if (!this.initialized) { + throw new Error('NovuToolkit must be initialized before accessing tools. Call initialize() first.'); + } + + return this.tools; + } + + getClient(): Novu { + return this.client; + } + + getConfig(): NovuToolkitConfig { + return this.config; + } +} diff --git a/packages/agent-toolkit/src/core/types.ts b/packages/agent-toolkit/src/core/types.ts new file mode 100644 index 00000000000..366e1ffbe3e --- /dev/null +++ b/packages/agent-toolkit/src/core/types.ts @@ -0,0 +1,23 @@ +import type { ZodTypeAny } from 'zod'; +import type { Novu } from '@novu/api'; + +export type NovuToolkitConfig = { + secretKey: string; + subscriberId: string; + backendUrl?: string; + context?: Record; + workflows?: { + tags?: string[]; + workflowIds?: string[]; + }; +}; + +export type NovuToolExecute = (params: TParams) => Promise; + +export type NovuToolDefinition = { + method: string; + name: string; + description: string; + parameters: ZodTypeAny; + bindExecute: (client: Novu, config: NovuToolkitConfig) => NovuToolExecute; +}; diff --git a/packages/agent-toolkit/src/human-in-the-loop/index.ts b/packages/agent-toolkit/src/human-in-the-loop/index.ts new file mode 100644 index 00000000000..96d92be8f51 --- /dev/null +++ b/packages/agent-toolkit/src/human-in-the-loop/index.ts @@ -0,0 +1,97 @@ +import type { Novu } from '@novu/api'; +import type { + DeferredToolCall, + DeferredToolCallInteractionResult, + DeferredToolCallWorkflowPayload, + HumanDecision, + HumanInputConfig, + WebhookEvent, +} from './types.js'; + +export type { DeferredToolCall, DeferredToolCallInteractionResult, HumanDecision, HumanInputConfig, WebhookEvent }; + +const DEFAULT_ALLOWED_DECISIONS: Array<'approve' | 'edit' | 'reject'> = ['approve', 'reject']; + +export function wrapToolDescription(description: string): string { + return `${description}\n\nThis tool call is deferred and requires human input before execution. You will NOT receive a result immediately — this is NOT an error. Do NOT retry the tool call. The result will be provided once a human has reviewed and approved the action.`; +} + +export async function triggerHumanInputWorkflow({ + client, + toolCall, + inputConfig, +}: { + client: Novu; + toolCall: DeferredToolCall; + inputConfig: HumanInputConfig; +}): Promise { + if (inputConfig.onBeforeTrigger) { + await inputConfig.onBeforeTrigger(toolCall); + } + + const payload: DeferredToolCallWorkflowPayload = { + type: 'deferred_tool_call', + toolCall, + allowedDecisions: inputConfig.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS, + metadata: inputConfig.metadata, + }; + + const response = await client.trigger({ + workflowId: inputConfig.workflowId, + to: inputConfig.subscribers.length === 1 ? inputConfig.subscribers[0] : inputConfig.subscribers, + payload: payload as unknown as Record, + }); + + if (inputConfig.onAfterTrigger) { + await inputConfig.onAfterTrigger(toolCall, response.result); + } + + return response.result; +} + +export function handleWebhookEvent(event: WebhookEvent): DeferredToolCallInteractionResult | null { + if (event.type !== 'message.interacted') { + return null; + } + + const message = event.data; + + if (!message?.data || message.data.type !== 'deferred_tool_call' || !message.data.toolCall) { + return null; + } + + const { toolCall, metadata, decision } = message.data; + + const resolvedDecision: HumanDecision = decision ?? { type: 'approve' }; + + return { + workflowId: message.source?.key ?? '', + decision: resolvedDecision, + toolCall: { + id: toolCall.id, + method: toolCall.method, + args: toolCall.args, + extra: toolCall.extra, + }, + metadata, + context: { + messageId: message.id ?? '', + channelId: message.channel_id ?? '', + timestamp: event.created_at, + }, + }; +} + +export async function executeWithDecision( + executeFn: (args: unknown) => Promise, + toolCall: DeferredToolCall, + decision: HumanDecision, +): Promise { + if (decision.type === 'reject') { + return { type: 'tool-status', status: 'rejected', message: decision.message }; + } + + const args = decision.type === 'edit' ? decision.args : toolCall.args; + + return executeFn(args); +} diff --git a/packages/agent-toolkit/src/human-in-the-loop/types.ts b/packages/agent-toolkit/src/human-in-the-loop/types.ts new file mode 100644 index 00000000000..459f420e176 --- /dev/null +++ b/packages/agent-toolkit/src/human-in-the-loop/types.ts @@ -0,0 +1,56 @@ +export type HumanDecision = + | { type: 'approve' } + | { type: 'edit'; args: Record } + | { type: 'reject'; message: string }; + +export type DeferredToolCall = { + id: string; + method: string; + args: unknown; + extra?: Record; +}; + +export type HumanInputConfig = { + workflowId: string; + subscribers: string[]; + allowedDecisions?: Array<'approve' | 'edit' | 'reject'>; + metadata?: Record; + onBeforeTrigger?: (toolCall: DeferredToolCall) => Promise; + onAfterTrigger?: (toolCall: DeferredToolCall, result: unknown) => Promise; +}; + +export type DeferredToolCallWorkflowPayload = { + type: 'deferred_tool_call'; + toolCall: DeferredToolCall; + allowedDecisions: Array<'approve' | 'edit' | 'reject'>; + metadata?: Record; +}; + +export type WebhookEvent = { + type: string; + created_at: string; + event_data?: unknown; + data?: { + id?: string; + channel_id?: string; + source?: { key?: string }; + data?: { + type?: string; + toolCall?: DeferredToolCall; + metadata?: Record; + decision?: HumanDecision; + }; + }; +}; + +export type DeferredToolCallInteractionResult = { + workflowId: string; + decision: HumanDecision; + toolCall: DeferredToolCall; + metadata?: Record; + context: { + messageId: string; + channelId: string; + timestamp: string; + }; +}; diff --git a/packages/agent-toolkit/src/index.ts b/packages/agent-toolkit/src/index.ts new file mode 100644 index 00000000000..118d32b0788 --- /dev/null +++ b/packages/agent-toolkit/src/index.ts @@ -0,0 +1,3 @@ +export { NovuTool, NovuToolkit } from './core/index.js'; +export type { NovuToolkitConfig, NovuToolDefinition, NovuToolExecute } from './core/index.js'; +export { triggerWorkflow, updatePreferences } from './tools/index.js'; diff --git a/packages/agent-toolkit/src/langchain/index.ts b/packages/agent-toolkit/src/langchain/index.ts new file mode 100644 index 00000000000..a015f0a37c9 --- /dev/null +++ b/packages/agent-toolkit/src/langchain/index.ts @@ -0,0 +1,89 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { NovuToolkit } from '../core/novu-toolkit.js'; +import type { NovuToolkitConfig } from '../core/types.js'; +import { + executeWithDecision, + handleWebhookEvent, + triggerHumanInputWorkflow, + wrapToolDescription, +} from '../human-in-the-loop/index.js'; +import type { + DeferredToolCall, + DeferredToolCallInteractionResult, + HumanDecision, + HumanInputConfig, + WebhookEvent, +} from '../human-in-the-loop/types.js'; +import { novuToolToLangchainTool } from './tool-converter.js'; + +export type { DeferredToolCall, DeferredToolCallInteractionResult, HumanDecision, HumanInputConfig, WebhookEvent }; + +type NovuLangchainToolkit = { + tools: DynamicStructuredTool[]; + requireHumanInput: (toolsToWrap: DynamicStructuredTool[], inputConfig: HumanInputConfig) => DynamicStructuredTool[]; + resumeToolExecution: (toolCall: DeferredToolCall, decision: HumanDecision) => Promise; + handleWebhookEvent: (event: WebhookEvent) => DeferredToolCallInteractionResult | null; +}; + +export async function createNovuAgentToolkit(config: NovuToolkitConfig): Promise { + const toolkit = new NovuToolkit(config); + await toolkit.initialize(); + + const novuTools = toolkit.getTools(); + const client = toolkit.getClient(); + const toolkitConfig = toolkit.getConfig(); + + const tools = novuTools.map((tool) => novuToolToLangchainTool(tool, client, toolkitConfig)); + + const pendingTools = new Map(); + + const requireHumanInput = ( + toolsToWrap: DynamicStructuredTool[], + inputConfig: HumanInputConfig, + ): DynamicStructuredTool[] => { + return toolsToWrap.map((originalTool) => { + pendingTools.set(originalTool.name, originalTool); + + return new DynamicStructuredTool({ + name: originalTool.name, + description: wrapToolDescription(originalTool.description), + schema: originalTool.schema as never, + func: async (args: unknown) => { + const toolCall: DeferredToolCall = { + id: crypto.randomUUID(), + method: originalTool.name, + args, + }; + + await triggerHumanInputWorkflow({ + client, + toolCall, + inputConfig, + }); + + return JSON.stringify({ type: 'tool-status', status: 'pending-input', toolCallId: toolCall.id }); + }, + }); + }); + }; + + const resumeToolExecution = async (toolCall: DeferredToolCall, decision: HumanDecision): Promise => { + const originalTool = pendingTools.get(toolCall.method); + + if (!originalTool) { + throw new Error( + `Tool "${toolCall.method}" not found. Make sure requireHumanInput was called with this tool before attempting to resume.`, + ); + } + + const result = await executeWithDecision( + async (args) => originalTool.func(args as Record), + toolCall, + decision, + ); + + return typeof result === 'string' ? result : JSON.stringify(result); + }; + + return { tools, requireHumanInput, resumeToolExecution, handleWebhookEvent }; +} diff --git a/packages/agent-toolkit/src/langchain/tool-converter.ts b/packages/agent-toolkit/src/langchain/tool-converter.ts new file mode 100644 index 00000000000..7e15806e522 --- /dev/null +++ b/packages/agent-toolkit/src/langchain/tool-converter.ts @@ -0,0 +1,20 @@ +import { DynamicStructuredTool } from '@langchain/core/tools'; +import type { Novu } from '@novu/api'; +import type { NovuToolDefinition, NovuToolkitConfig } from '../core/types.js'; + +export function novuToolToLangchainTool( + tool: NovuToolDefinition, + client: Novu, + config: NovuToolkitConfig, +): DynamicStructuredTool { + return new DynamicStructuredTool({ + name: tool.method, + description: tool.description, + schema: tool.parameters as never, + func: async (input) => { + const result = await tool.bindExecute(client, config)(input); + + return typeof result === 'string' ? result : JSON.stringify(result); + }, + }); +} diff --git a/packages/agent-toolkit/src/openai/index.ts b/packages/agent-toolkit/src/openai/index.ts new file mode 100644 index 00000000000..63a43b781b1 --- /dev/null +++ b/packages/agent-toolkit/src/openai/index.ts @@ -0,0 +1,155 @@ +import { NovuToolkit } from '../core/novu-toolkit.js'; +import type { NovuToolkitConfig } from '../core/types.js'; +import { + executeWithDecision, + handleWebhookEvent, + triggerHumanInputWorkflow, + wrapToolDescription, +} from '../human-in-the-loop/index.js'; +import type { + DeferredToolCall, + DeferredToolCallInteractionResult, + HumanDecision, + HumanInputConfig, + WebhookEvent, +} from '../human-in-the-loop/types.js'; +import { novuToolToOpenAITool, type OpenAIFunctionTool } from './tool-converter.js'; + +export type { OpenAIFunctionTool }; +export type { DeferredToolCall, DeferredToolCallInteractionResult, HumanDecision, HumanInputConfig, WebhookEvent }; + +type ToolCall = { + id: string; + function: { + name: string; + arguments: string; + }; +}; + +type ToolCallResult = { + role: 'tool'; + tool_call_id: string; + content: string; +}; + +type NovuOpenAIToolkit = { + tools: OpenAIFunctionTool[]; + handleToolCall: (toolCall: ToolCall) => Promise; + requireHumanInput: (toolsToWrap: OpenAIFunctionTool[], inputConfig: HumanInputConfig) => OpenAIFunctionTool[]; + resumeToolExecution: (toolCall: DeferredToolCall, decision: HumanDecision) => Promise; + handleWebhookEvent: (event: WebhookEvent) => DeferredToolCallInteractionResult | null; +}; + +export async function createNovuAgentToolkit(config: NovuToolkitConfig): Promise { + const toolkit = new NovuToolkit(config); + await toolkit.initialize(); + + const novuTools = toolkit.getTools(); + const client = toolkit.getClient(); + const toolkitConfig = toolkit.getConfig(); + + const tools = novuTools.map(novuToolToOpenAITool); + + const toolMap = new Map(novuTools.map((t) => [t.method, t])); + const guardedToolConfigs = new Map(); + + const requireHumanInput = ( + toolsToWrap: OpenAIFunctionTool[], + inputConfig: HumanInputConfig, + ): OpenAIFunctionTool[] => { + return toolsToWrap.map((t) => { + guardedToolConfigs.set(t.function.name, inputConfig); + + return { + ...t, + function: { + ...t.function, + description: wrapToolDescription(t.function.description ?? ''), + }, + }; + }); + }; + + const handleToolCall = async (toolCall: ToolCall): Promise => { + const toolName = toolCall.function.name; + const guardedConfig = guardedToolConfigs.get(toolName); + + let args: unknown; + try { + args = JSON.parse(toolCall.function.arguments); + } catch { + return { + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify({ error: 'Invalid tool arguments: failed to parse JSON.' }), + }; + } + + if (guardedConfig) { + const deferredCall: DeferredToolCall = { + id: toolCall.id, + method: toolName, + args, + }; + + await triggerHumanInputWorkflow({ + client, + toolCall: deferredCall, + inputConfig: guardedConfig, + }); + + return { + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify({ type: 'tool-status', status: 'pending-input', toolCallId: toolCall.id }), + }; + } + + const tool = toolMap.get(toolName); + + if (!tool) { + return { + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${toolName}` }), + }; + } + + const result = await tool.bindExecute(client, toolkitConfig)(args); + + return { + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(result), + }; + }; + + const resumeToolExecution = async ( + toolCall: DeferredToolCall, + decision: HumanDecision, + ): Promise => { + const tool = toolMap.get(toolCall.method); + + if (!tool) { + return { + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${toolCall.method}` }), + }; + } + + const result = await executeWithDecision( + (args) => tool.bindExecute(client, toolkitConfig)(args), + toolCall, + decision, + ); + + return { + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(result), + }; + }; + + return { tools, handleToolCall, requireHumanInput, resumeToolExecution, handleWebhookEvent }; +} diff --git a/packages/agent-toolkit/src/openai/tool-converter.ts b/packages/agent-toolkit/src/openai/tool-converter.ts new file mode 100644 index 00000000000..c85a65acf72 --- /dev/null +++ b/packages/agent-toolkit/src/openai/tool-converter.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import type { NovuToolDefinition } from '../core/types.js'; + +export type OpenAIFunctionTool = { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +}; + +export function novuToolToOpenAITool(tool: NovuToolDefinition): OpenAIFunctionTool { + return { + type: 'function', + function: { + name: tool.method, + description: tool.description, + parameters: z.toJSONSchema(tool.parameters) as Record, + }, + }; +} diff --git a/packages/agent-toolkit/src/tools/index.ts b/packages/agent-toolkit/src/tools/index.ts new file mode 100644 index 00000000000..aa28e37ba50 --- /dev/null +++ b/packages/agent-toolkit/src/tools/index.ts @@ -0,0 +1,8 @@ +import { triggerWorkflow } from './trigger-workflow.js'; +import { updatePreferences } from './preferences.js'; + +export { triggerWorkflow } from './trigger-workflow.js'; +export { updatePreferences } from './preferences.js'; +export { createWorkflowTools } from './workflows-as-tools.js'; + +export const builtInTools = [triggerWorkflow, updatePreferences] as const; diff --git a/packages/agent-toolkit/src/tools/preferences.ts b/packages/agent-toolkit/src/tools/preferences.ts new file mode 100644 index 00000000000..075d5a6d4b1 --- /dev/null +++ b/packages/agent-toolkit/src/tools/preferences.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { NovuTool } from '../core/novu-tool.js'; + +export const updatePreferences = NovuTool({ + method: 'update_preferences', + name: 'Update notification preferences', + description: + 'Updates the notification channel preferences for a subscriber. If a workflowId is provided, updates preferences for that specific workflow. Otherwise, updates global preferences. Use this when a user wants to opt in or out of specific notification channels.', + parameters: z.object({ + workflowId: z + .string() + .optional() + .describe('The workflow identifier to update preferences for. If omitted, updates global subscriber preferences.'), + channels: z + .object({ + email: z.boolean().optional().describe('Enable or disable email notifications.'), + sms: z.boolean().optional().describe('Enable or disable SMS notifications.'), + push: z.boolean().optional().describe('Enable or disable push notifications.'), + inApp: z.boolean().optional().describe('Enable or disable in-app notifications.'), + chat: z.boolean().optional().describe('Enable or disable chat notifications.'), + }) + .optional() + .describe('Channel-level preferences to update.'), + subscriberId: z + .string() + .optional() + .describe('The subscriber ID whose preferences to update. Defaults to the configured subscriberId.'), + }), + execute: (client, config) => async (params) => { + const { workflowId, channels, subscriberId } = params as { + workflowId?: string; + channels?: { + email?: boolean; + sms?: boolean; + push?: boolean; + inApp?: boolean; + chat?: boolean; + }; + subscriberId?: string; + }; + + const targetSubscriberId = subscriberId ?? config.subscriberId; + + const response = await client.subscribers.preferences.update( + { + workflowId, + channels: channels + ? { + email: channels.email, + sms: channels.sms, + push: channels.push, + inApp: channels.inApp, + chat: channels.chat, + } + : undefined, + }, + targetSubscriberId, + ); + + return response.result; + }, +}); diff --git a/packages/agent-toolkit/src/tools/trigger-workflow.ts b/packages/agent-toolkit/src/tools/trigger-workflow.ts new file mode 100644 index 00000000000..2aadba07a5e --- /dev/null +++ b/packages/agent-toolkit/src/tools/trigger-workflow.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { NovuTool } from '../core/novu-tool.js'; + +export const triggerWorkflow = NovuTool({ + method: 'trigger_workflow', + name: 'Trigger workflow', + description: + 'Triggers a Novu notification workflow by its identifier. Use this to send notifications to a subscriber via any configured channel (email, SMS, push, in-app, chat). Returns a transactionId that can be used to track the notification.', + parameters: z.object({ + workflowId: z.string().describe('The identifier of the workflow to trigger.'), + payload: z + .record(z.string(), z.unknown()) + .optional() + .describe('Additional data to pass to the workflow for rendering notification content.'), + overrides: z + .record(z.string(), z.unknown()) + .optional() + .describe('Provider-specific configuration overrides.'), + subscriberId: z + .string() + .optional() + .describe('The subscriber ID to send the notification to. Defaults to the configured subscriberId.'), + transactionId: z + .string() + .optional() + .describe('Optional unique identifier for deduplication. If the same transactionId is sent again, the trigger is ignored.'), + }), + execute: (client, config) => async (params) => { + const { workflowId, payload, overrides, subscriberId, transactionId } = params as { + workflowId: string; + payload?: Record; + overrides?: Record; + subscriberId?: string; + transactionId?: string; + }; + + const response = await client.trigger({ + workflowId, + to: subscriberId ?? config.subscriberId, + payload, + overrides: overrides as never, + transactionId, + }); + + return { + transactionId: response.result.transactionId, + acknowledged: response.result.acknowledged, + status: response.result.status, + }; + }, +}); diff --git a/packages/agent-toolkit/src/tools/workflows-as-tools.ts b/packages/agent-toolkit/src/tools/workflows-as-tools.ts new file mode 100644 index 00000000000..85290c13efa --- /dev/null +++ b/packages/agent-toolkit/src/tools/workflows-as-tools.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; +import { jsonSchemaToZod } from 'json-schema-to-zod'; +import type { Novu } from '@novu/api'; +import { NovuTool } from '../core/novu-tool.js'; +import type { NovuToolDefinition, NovuToolkitConfig } from '../core/types.js'; + +type WorkflowSummary = { + workflowId: string; + name: string; + description?: string | null; + payloadSchema?: Record | null; +}; + +function buildPayloadSchema(payloadSchema?: Record | null): z.ZodTypeAny { + if (!payloadSchema) { + return z.record(z.string(), z.unknown()).optional().describe('Payload data to pass to the workflow.'); + } + + try { + const zodCode = jsonSchemaToZod(payloadSchema as object); + // Using Function constructor to avoid bundler warnings about direct eval + // This is intentional: we need to dynamically evaluate generated Zod schema code + // from the workflow's JSON Schema definition at runtime. + const schema = new Function('z', `return ${zodCode}`)(z) as z.ZodTypeAny; + + return schema.describe('Payload data to pass to the workflow.'); + } catch { + return z.record(z.string(), z.unknown()).optional().describe('Payload data to pass to the workflow.'); + } +} + +function workflowAsTool(workflow: WorkflowSummary): NovuToolDefinition { + const methodName = `trigger_${workflow.workflowId.replace(/-/g, '_')}`; + const payloadSchema = buildPayloadSchema(workflow.payloadSchema); + + return NovuTool({ + method: methodName, + name: `Trigger ${workflow.name}`, + description: [ + `Triggers the "${workflow.name}" workflow (ID: ${workflow.workflowId}).`, + `Use this tool when asked to notify, send, or trigger "${workflow.name}" or "${workflow.workflowId}".`, + workflow.description ? `Additional context: ${workflow.description}` : '', + `Returns a transactionId that can be used to track the notification.`, + ] + .filter(Boolean) + .join(' '), + parameters: z.object({ + payload: payloadSchema, + subscriberId: z + .string() + .optional() + .describe('The subscriber to notify. Defaults to the configured subscriberId.'), + transactionId: z + .string() + .optional() + .describe('Optional deduplication key. Duplicate transactionIds are ignored.'), + }), + execute: (client, config) => async (params) => { + const { payload, subscriberId, transactionId } = params as { + payload?: Record; + subscriberId?: string; + transactionId?: string; + }; + + const response = await client.trigger({ + workflowId: workflow.workflowId, + to: subscriberId ?? config.subscriberId, + payload, + transactionId, + }); + + return { + transactionId: response.result.transactionId, + acknowledged: response.result.acknowledged, + status: response.result.status, + }; + }, + }); +} + +export async function createWorkflowTools( + client: Novu, + config: NovuToolkitConfig, +): Promise { + const { tags, workflowIds } = config.workflows ?? {}; + + const listResponse = await client.workflows.list({ tags }); + const workflows = listResponse.result.workflows ?? []; + + const filtered = workflowIds + ? workflows.filter((w) => workflowIds.includes(w.workflowId)) + : workflows; + + const tools: NovuToolDefinition[] = []; + + for (const summary of filtered) { + let payloadSchema: Record | null = null; + + try { + const detail = await client.workflows.get(summary.workflowId); + payloadSchema = (detail.result.payloadSchema as Record) ?? null; + } catch { + // continue without schema + } + + tools.push( + workflowAsTool({ + workflowId: summary.workflowId, + name: summary.name, + description: undefined, + payloadSchema, + }), + ); + } + + return tools; +} diff --git a/packages/agent-toolkit/tsconfig.json b/packages/agent-toolkit/tsconfig.json new file mode 100644 index 00000000000..1c9b1af6ce3 --- /dev/null +++ b/packages/agent-toolkit/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "ES2020", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "noImplicitAny": true, + "sourceMap": true, + "rootDir": ".", + "outDir": "./dist", + "strict": true + }, + "include": ["./src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-toolkit/tsup.config.ts b/packages/agent-toolkit/tsup.config.ts new file mode 100644 index 00000000000..1757914e037 --- /dev/null +++ b/packages/agent-toolkit/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, type Options } from 'tsup'; + +const baseConfig: Options = { + entry: [ + 'src/index.ts', + 'src/core/index.ts', + 'src/openai/index.ts', + 'src/langchain/index.ts', + 'src/ai-sdk/index.ts', + 'src/human-in-the-loop/index.ts', + ], + sourcemap: false, + clean: true, + dts: true, + minify: false, +}; + +export const cjsConfig: Options = { + ...baseConfig, + format: 'cjs', + outDir: 'dist/cjs', +}; + +export const esmConfig: Options = { + ...baseConfig, + format: 'esm', + outDir: 'dist/esm', +}; + +export default defineConfig([cjsConfig, esmConfig]); diff --git a/playground/nextjs/.env.example b/playground/nextjs/.env.example index 5981dff394c..23914f8d57a 100644 --- a/playground/nextjs/.env.example +++ b/playground/nextjs/.env.example @@ -2,3 +2,9 @@ NEXT_PUBLIC_NOVU_BACKEND_URL=https://dev.api.novu.co NEXT_PUBLIC_NOVU_SOCKET_URL=https://dev.ws.novu.co NEXT_PUBLIC_NOVU_APP_ID= NEXT_PUBLIC_NOVU_SUBSCRIBER_ID= + +# Agent Toolkit HITL Playground +OPENAI_API_KEY= +NOVU_SECRET_KEY= +NOVU_SUBSCRIBER_ID= +NOVU_HITL_WORKFLOW_ID=refund-approval diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index bec7324ec09..cc9ccd3cb7c 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -9,23 +9,58 @@ "lint": "next lint" }, "dependencies": { + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/react": "^3.0.0", + "@novu/agent-toolkit": "workspace:*", "@novu/nextjs": "workspace:*", "@radix-ui/colors": "^3.0.0", + "@radix-ui/react-accordion": "^1.2.1", + "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-popover": "^1.1.1", - "@radix-ui/react-slot": "^1.1.0", - "class-variance-authority": "^0.7.0", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@rive-app/react-webgl2": "^4.26.1", + "@streamdown/cjk": "^1.0.1", + "@streamdown/code": "^1.0.1", + "@streamdown/math": "^1.0.1", + "@streamdown/mermaid": "^1.0.1", + "@xyflow/react": "^12.3.2", + "ai": "^6.0.50", + "ansi-to-react": "^6.2.6", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "1.0.0", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.439.0", + "media-chrome": "^4.17.2", + "motion": "^11.18.2", + "nanoid": "^5.1.6", "next": "15.4.10", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.0.1", "react-infinite-scroll-component": "^6.0.0", + "react-jsx-parser": "^2.4.1", + "shiki": "^3.21.0", + "streamdown": "^2.1.0", "tailwind-merge": "^2.4.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "tokenlens": "^1.3.1", + "use-stick-to-bottom": "^1.1.2", + "zod": "^4.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.12", diff --git a/playground/nextjs/src/app/agent-toolkit/app-sidenav.tsx b/playground/nextjs/src/app/agent-toolkit/app-sidenav.tsx new file mode 100644 index 00000000000..005daba9424 --- /dev/null +++ b/playground/nextjs/src/app/agent-toolkit/app-sidenav.tsx @@ -0,0 +1,79 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from '@/lib/utils'; + +type LinkType = { + href: string; + label: string; + category?: string; +}; + +const LINKS: LinkType[] = [ + { href: '/agent-toolkit', label: 'Refund Agent (HITL)', category: 'AI' }, + { href: '/', label: 'Default Inbox', category: 'Components' }, + { href: '/render-bell', label: 'Render Bell', category: 'Components' }, + { href: '/render-notification', label: 'Render Notification', category: 'Components' }, + { href: '/notifications', label: 'Notifications', category: 'Components' }, + { href: '/preferences', label: 'Preferences', category: 'Components' }, + { href: '/subscription', label: 'Subscription', category: 'Components' }, + { href: '/subscription-components', label: 'Subscription Components', category: 'Components' }, + { href: '/subscription-hooks', label: 'Subscription Hooks', category: 'Components' }, + { href: '/novu-theme', label: 'Novu Theme', category: 'Customization' }, + { href: '/custom-popover', label: 'Custom Popover', category: 'Customization' }, + { href: '/custom-subject-body', label: 'Custom Subject Body', category: 'Customization' }, + { href: '/custom-icons', label: 'Custom Icons', category: 'Customization' }, + { href: '/hooks', label: 'Hooks', category: 'Advanced' }, +]; + +const NavLink = ({ href, label }: LinkType) => { + const pathname = usePathname(); + const isActive = pathname === href; + + return ( + + {isActive &&