diff --git a/.cspell.json b/.cspell.json deleted file mode 100644 index bbc993a91e2..00000000000 --- a/.cspell.json +++ /dev/null @@ -1,903 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", - "version": "0.2", - "language": "en", - "words": [ - "ABNF", - "addrs", - "adresses", - "africas", - "africastalking", - "Aland", - "alanturing", - "alexjoverm", - "alist", - "aliyun", - "ALLCAPS", - "amannn", - "analagous", - "Angularjs", - "sinch", - "evalsha", - "antd", - "anthonybaxter", - "anticon", - "apidevtools", - "APIJSON", - "apikey", - "appendonly", - "ARGB", - "arobj", - "arpa", - "asdas", - "atext", - "attatchment", - "Attatchment", - "authres", - "authserv", - "autobuild", - "autodocs", - "autogeneration", - "autoload", - "automators", - "automerge", - "Automount", - "autopipelining", - "autorun", - "AWSCLIV", - "AXFR", - "axios", - "azcopy", - "backoff", - "BADALG", - "BADKEY", - "BADMODE", - "BADNAME", - "BADSIG", - "BADTIME", - "BADVERS", - "Barnea", - "bcast", - "behaviour", - "bestguess", - "binipdisplay", - "bitauth", - "bitjson", - "bitnami", - "bitnamicharts", - "bitsize", - "bmsi", - "bodyhash", - "bookmarkable", - "braindead", - "brevo", - "BTAG", - "BUILDKIT", - "buildscript", - "buildx", - "bullmq", - "Burkina", - "bzrignore", - "cacheable", - "cafebabe", - "calcom", - "callout", - "canonicalization", - "canonicalized", - "chartjs", - "cheaders", - "Chmara", - "chsh", - "cidrlength", - "cidrmatch", - "cidrmax", - "cimg", - "cimode", - "circleci", - "cirdmatch", - "classpath", - "classstr", - "clickatell", - "ClickHouse", - "Clickjacking", - "clicksend", - "Clicksend", - "clonedeep", - "clsx", - "cmdk", - "cnamek", - "cnames", - "codecov", - "codeql", - "codesandbox", - "codesee", - "codestream", - "coep", - "combinators", - "commitlint", - "companyname", - "compatbility", - "cond", - "connrefused", - "Consolas", - "consts", - "containerd", - "contenteditable", - "Contextand", - "cpack", - "Craco", - "credentialless", - "crossorigin", - "CSNET", - "customdesigned", - "custome", - "Datetime", - "datetimepicker", - "dbaeumer", - "dbnavigator", - "dbpath", - "Deadlinks", - "dearmor", - "decompile", - "dedup", - "deduplicated", - "deepmerge", - "Deepmerge", - "defexps", - "deliverytime", - "dependabot", - "devcontainer", - "devcontainers", - "devs", - "Dima", - "dinfo", - "directcss", - "Dispatchable", - "dkimpy", - "Inkeep", - "cxkit", - "dnscount", - "dnscounttype", - "dnsfunc", - "Prefs", - "dnslookup", - "dnsplug", - "dnspython", - "DNSSEC", - "dnsvalues", - "dnsvaluestype", - "Docgen", - "domainkey", - "domainmatch", - "domainname", - "domainsuffix", - "donefunc", - "Dotan", - "dotenv", - "dotenvcreate", - "doublecolon", - "dtos", - "dummytype", - "Duplicable", - "EARLYHINTS", - "eazy", - "editorconfig", - "ehlo", - "elif", - "emailjs", - "Embeddable", - "Embedder", - "Emek", - "EMSA", - "EMUI", - "endcapture", - "endcase", - "endgroup", - "endtablerow", - "enroute", - "envalid", - "envsetup", - "EQAs", - "errmsg", - "errordto", - "esbenp", - "esnext", - "EVENT", - "everytime", - "exampledark", - "examplelight", - "execa", - "existant", - "explicity", - "exponentiate", - "externaldb", - "externalredis", - "facated", - "Faso", - "Fdfdf", - "fieldname", - "fieldtype", - "filtertopicsresponsedto", - "Fira", - "firestore", - "Firetext", - "firsttris", - "fjogeleit", - "flowwer", - "focusable", - "fontawesome", - "forloop", - "FORMERR", - "fortawesome", - "frameworkterminal", - "fullname", - "funcs", - "functionaly", - "ganalytics", - "Gathman", - "Generatethemailsendrequest", - "gethostname", - "getp", - "getsentry", - "getstream", - "Getstream", - "ghaction", - "globby", - "googlemail", - "grayscale", - "Greenkeeper", - "Grossman", - "GROUP_BY", - "GROUPBY", - "Gupshup", - "Hacksquad", - "hbspt", - "headerapikey", - "HeaderNavNew", - "healthcheck", - "HEAY", - "Hewgill", - "HEXTET", - "hextets", - "hgcheck", - "hgignore", - "hljs", - "hmac", - "hokify", - "hookform", - "hostedtoolcache", - "hostnames", - "hotmail", - "hset", - "hsforms", - "hstack", - "htmlonly", - "hubspot", - "Hygen", - "IAPNS", - "ical", - "Icann", - "Idand", - "idemp", - "idempotancy", - "idempotency", - "Idempotency", - "IDEMPOTENCY", - "iframe", - "iife", - "ILIKE", - "INAPP", - "infobip", - "INITDB", - "inotify", - "Intelli", - "ioredis", - "ipaddr", - "ipaddrs", - "ipadrress", - "ipam", - "iplist", - "IQUERY", - "isend", - "isequal", - "istanbuljs", - "iterhosts", - "jpath", - "jsonify", - "Kamil", - "kannel", - "kebabcase", - "keybase", - "Keymap", - "keyrings", - "keysize", - "KILLSWITCH", - "kitterma", - "Kitterman", - "klass", - "Kleineidam", - "kompose", - "Krakend", - "Kustomization", - "kustomize", - "lastindex", - "Lato", - "Lentczner", - "lezer", - "libarary", - "libauth", - "libspf", - "limitbar", - "liquidjs", - "liquified", - "Listoflistofobjects", - "Listofobjects", - "livemode", - "localforage", - "localstack", - "localstorage", - "LOGDECORATOR", - "loglevel", - "Lookback", - "machdep", - "mailchimp", - "maildata", - "mailersend", - "Mailersend", - "Mailgun", - "mailin", - "Mailjet", - "mailparser", - "Maily", - "Maizzle", - "mansagroup", - "mantine", - "maqsam", - "Markunread", - "marocchino", - "martinbeentjes", - "maskdata", - "maxage", - "maxsize", - "maxtimeout", - "mechnisms", - "mechs", - "mediumdark", - "Menlo", - "messagebird", - "Metafiles", - "Metas", - "metatype", - "middlewares", - "Millis", - "minkey", - "millify", - "mintlify", - "MINTLIFY", - "mispelled", - "MITM", - "MJML", - "mkdir", - "mkdocs", - "mlen", - "mobishastra", - "Mobishastra", - "Mobishatra", - "moby", - "Modiin", - "modlen", - "mongod", - "mongosh", - "monokai", - "monorepository", - "motionone", - "mpeltonen", - "mpim", - "MPIMs", - "mrkdwn", - "MSTCP", - "msteams", - "multilines", - "Munpacker", - "mxlookup", - "mxnames", - "myicon", - "Myśliwiec", - "nameserver", - "navigatable", - "nbits", - "nbsp", - "neom", - "Nestedobject", - "nestframework", - "nestjs", - "netblock", - "netcore", - "netwrk", - "newrelic", - "nexmo", - "nextjs", - "nikolaik", - "nimma", - "Nimma", - "nindent", - "njsproj", - "nocheck", - "noconflict", - "nodemailer", - "nodrag", - "NOERROR", - "nonenumerable", - "noninteractive", - "noopener", - "noreferrer", - "norender", - "nosniff", - "NOTAUTH", - "notif", - "NOTIFGROUPID", - "notifire", - "NOTIMP", - "NOTZONE", - "novu", - "Novu", - "NOVU", - "Novu's", - "novuapp", - "novugo", - "novuhq", - "novutest", - "npmignore", - "npmjs", - "npmrc", - "nrwl", - "ntfr", - "ntoa", - "ntvs", - "nunito", - "nuxt", - "nvsk", - "nwtgck", - "NXDOMAIN", - "NXRRSET", - "occurance", - "ohmyzsh", - "oidc", - "oklch", - "opcodemap", - "opcodestr", - "openapi", - "OPENCOLLECTIVE", - "opentelemetry", - "orcid", - "ORGANISATION", - "Otel", - "otlp", - "OTLP", - "outdir", - "Outgoers", - "Paramtype", - "parens", - "partnerintegration", - "partysocket", - "pepipost", - "permerror", - "permerrors", - "personalizations", - "PGID", - "picocolors", - "pinno", - "pino", - "PKCS", - "pkdata", - "Placeholdable", - "playerids", - "plivo", - "Plivo", - "plusplus", - "pnpm", - "portname", - "powerpack", - "prebuild", - "prebuilds", - "preferencechannels", - "prefiltering", - "prefixlen", - "preheader", - "prepopulating", - "prepush", - "presigner", - "Previewable", - "Primitiveboolean", - "Primitivenumber", - "Primitivestring", - "printjson", - "privkey", - "Projectkeys", - "protonmail", - "pton", - "ptrnames", - "ptrs", - "pubid", - "PUID", - "pulumi", - "pulsecron", - "Pushpad", - "Pushwoosh", - "pychecker", - "pydns", - "pyspf", - "QCLASS", - "qtype", - "querybuilder", - "querytime", - "Quickstart", - "quickstarts", - "Quickstarts", - "quickstartslogo", - "Radek", - "Raleway", - "ratelimit", - "Ratelimit", - "ravsamhq", - "RBPT", - "rcode", - "rdatatype", - "rdtype", - "reactjs", - "readall", - "rediscommander", - "redismaster", - "Redmine", - "reindented", - "relabelings", - "Relabelings", - "releasewards", - "RELEASLY", - "relocator", - "replayable", - "replstate", - "reshard", - "Resizer", - "resmap", - "resobj", - "Resolv", - "restapi", - "retryable", - "Retryable", - "RETRYABLE", - "revlookup", - "revlookupall", - "Rica", - "righthand", - "rimraf", - "rindex", - "ringcentral", - "rjsf", - "RJSF", - "Roboto", - "rrggbb", - "RSASSA", - "rstrip", - "runtimes", - "ryver", - "sadd", - "sandboxed", - "scope", - "scopsy", - "Scriptable", - "sdkerror", - "Segoe", - "sema", - "sendchamp", - "Sendchamp", - "sendgrid", - "Sendinblue", - "sendsms", - "servername", - "SERVFAIL", - "sess", - "settext", - "shelljs", - "Shortcodes", - "shortid", - "Sider", - "sigfields", - "sigheader", - "sigheaders", - "signup", - "simpletexting", - "Simpletexting", - "slaveof", - "SLMXRN", - "slodipvh", - "slugified", - "smembers", - "smoothstep", - "snapshotter", - "snooze", - "snoozed", - "softfail", - "softwareupdate", - "SOLOPRENEUR", - "someurl", - "sonarjs", - "sonner", - "Sonner", - "sortlist", - "sourcemaps", - "spamassassin", - "spamc", - "Spamc", - "sparkpost", - "spftxt", - "springboot", - "Springboot", - "spyon", - "squiebras", - "sscan", - "stackalt", - "statefulset", - "Statefulset", - "STATEFULSETS", - "statusmap", - "statusstr", - "stddev", - "stdev", - "Stdev", - "stefanprodan", - "stringdisplay", - "stripdot", - "Strobl", - "stroeder", - "Styleable", - "subfolders", - "subjectsthum", - "sublist", - "subnetmask", - "subresource", - "subscriberpayloaddto", - "superagent", - "supernet", - "supertest", - "suported", - "Svix", - "Syncable", - "tablerowloop", - "tabnannied", - "tailwindcss", - "tanstack", - "TASKFORCESH", - "Tcpip", - "Telnyx", - "telynx", - "temperror", - "tempfail", - "TEMPLATEID", - "templating", - "Termii", - "testapp", - "testmode", - "testnovu", - "TESTTTT", - "textareas", - "tfstate", - "Tiering", - "timelimit", - "timethen", - "tiptap", - "titlecase", - "titleize", - "TKEY", - "TMPDIR", - "tmproj", - "toastr", - "Tolerations", - "Tomer", - "topicsubscribers", - "TOPLAB", - "totp", - "touchpoint", - "tplvalues", - "Transac", - "TRANSID", - "transpiled", - "trunc", - "truncatewords", - "tsbuildinfo", - "TSIG", - "tsndr", - "tspan", - "tsup", - "tufjs", - "TTFB", - "TVVV", - "Twilio", - "Twillio", - "typedoc", - "typemap", - "typeof", - "Udnw", - "unarchived", - "Unarchived", - "Unavalidated", - "Unfetch", - "unplugin", - "Unpromoted", - "unpublish", - "unsnooze", - "unsnoozed", - "unsub", - "untracked", - "unvalidated", - "Unvalidated", - "upsert", - "upserted", - "upserting", - "unstub", - "upstash", - "Upstash", - "usecase", - "USECASE", - "usecases", - "Usergroup", - "userid", - "uuidv", - "vercel", - "verifyspf", - "VITE", - "Vonage", - "vstack", - "WaitList", - "WEASL", - "webcomponent", - "webfontloader", - "webpush", - "Webpush", - "websockets", - "whatsapp", - "whatsappbusiness", - "xcodebuild", - "xkeysib", - "xmlschema", - "xyflow", - "zulip", - "zwnj", - "Bienvenido", - "imedia", - "Brandname", - "telco", - "unifonic", - "smsmode", - "BLKTM", - "Novuand", - "kobalte", - "jsonrepair", - "xoxb", - "fanout", - "adminconsent", - "Arik", - "Chakma", - "selectednode", - "valibot", - "jsonlogic", - "flexbox", - "Checkpointer", - "supabase", - "checkpointer", - "langchain", - "langgraph", - "Vitest", - "signoz", - "sanitised", - "serialises", - "customerio" - ], - "flagWords": [], - "patterns": [ - { - "name": "Markdown links", - "pattern": "\\((.*)\\)", - "description": "" - }, - { - "name": "Markdown code blocks", - "pattern": "/^(\\s*`{3,}).*[\\s\\S]*?^\\1/gmx", - "description": "Taken from the cSpell example at https://cspell.org/configuration/patterns/#verbose-regular-expressions" - }, - { - "name": "Inline code blocks", - "pattern": "\\`([^\\`\\r\\n]+?)\\`", - "description": "https://stackoverflow.com/questions/41274241/how-to-capture-inline-markdown-code-but-not-a-markdown-code-fence-with-regex" - }, - { - "name": "Link contents", - "pattern": "\\", - "description": "" - }, - { - "name": "Snippet references", - "pattern": "-- snippet:(.*)", - "description": "" - }, - { - "name": "Snippet references 2", - "pattern": "\\<\\[sample:(.*)", - "description": "another kind of snippet reference" - }, - { - "name": "Multi-line code blocks", - "pattern": "/^\\s*```[\\s\\S]*?^\\s*```/gm" - }, - { - "name": "HTML Tags", - "pattern": "<[^>]*>", - "description": "Reference: https://stackoverflow.com/questions/11229831/regular-expression-to-remove-html-tags-from-a-string" - }, - { - "name": "projectIds", - "pattern": "/projectId.*/" - } - ], - "ignoreRegExpList": [ - "Markdown links", - "Markdown code blocks", - "Inline code blocks", - "Link contents", - "Snippet references", - "Snippet references 2", - "Multi-line code blocks", - "HTML Tags", - "projectIds" - ], - "ignorePaths": [ - ".cspell.json", - ".env.development", - ".env.local", - ".env.production", - ".env.test", - ".env", - ".example.env", - ".gitignore", - ".vscode/settings.json", - "biome.json", - "*.riv", - "*/**/.vscode/settings.json", - "*/**/CHANGELOG.md", - "**/*.e2e.ts", - "**/*.spec.ts", - "angular.json", - "apps/api/src/.env.test", - "apps/api/src/app/analytics/usecases/hubspot-identify-form/hubspot-identify-form.usecase.ts", - "apps/api/src/app/workflows-v2/maily-test-data.ts", - "apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.usecase.ts", - "libs/application-generic/src/utils/json-schema-mock.ts", - "libs/internal-sdk/**/*", - "apps/widget/public/iframeResizer.contentWindow.js", - "apps/worker/README.md", - "apps/ws/src/.env.test", - "apps/ws/src/.example.env", - "libs/application-generic/src/.env.test", - "ng-package.json", - "node_modules/**", - "novu.code-workspace", - "package-lock.json", - "package.json", - "packages/framework/src/jsonSchemaFaker.js", - "packages/js/src/ui/index.directcss", - "packages/js/tsup.config.ts", - "packages/shared/src/types/timezones/timezones.types.ts", - "pnpm-lock.yaml", - "pnpm-workspace.yaml", - "tsconfig.json", - "unreadRead", - "websockets", - "apps/dashboard/src/hooks/use-plain-chat.ts", - "apps/dashboard/src/components/variable/constants.ts", - "apps/dashboard/src/utils/locales.ts", - "apps/dashboard/src/components/primitives/constants.ts", - "enterprise/workers/socket/worker-configuration.d.ts", - "enterprise/workers/scheduler/worker-configuration.d.ts", - "enterprise/workers/step-resolver/worker-configuration.d.ts" - ] -} diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index a14f33bf72f..ac688d3174a 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -21,19 +21,6 @@ jobs: - name: 'Dependency Review' uses: actions/dependency-review-action@v4 - spellcheck: - name: Spell check - runs-on: blacksmith-4vcpu-ubuntu-2404 - environment: Linting - steps: - - uses: actions/checkout@v5 - - name: Run Spell Check - uses: streetsidesoftware/cspell-action@v6 - with: - root: 'apps/dashboard' - files: '**/*' - incremental_files_only: true - find-flags: runs-on: blacksmith-4vcpu-ubuntu-2404 name: Find LaunchDarkly feature flags in diff diff --git a/.source b/.source index 3d5c2da9bab..75e8eeb78da 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 3d5c2da9bab4d95a0bc5694da0dda8aab8684f18 +Subproject commit 75e8eeb78da6d8b8b2403172666cca55029523c4 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/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/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/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/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/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/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'); + } } }, []); 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/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/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)}> - + 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; 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, 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)} diff --git a/apps/dashboard/src/hooks/use-is-mobile.ts b/apps/dashboard/src/hooks/use-is-mobile.ts new file mode 100644 index 00000000000..234ff820952 --- /dev/null +++ b/apps/dashboard/src/hooks/use-is-mobile.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState(() => { + 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}>
-
-
+
+
; }; 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, diff --git a/package.json b/package.json index d9663f83e80..370d0fef918 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,6 @@ "cpx": "^1.5.0", "cross-env": "^7.0.3", "cross-spawn": "^7.0.3", - "cspell": "^6.19.2", "deep-extend": "^0.6.0", "detect-port": "^1.3.0", "execa": "^9.3.1", @@ -174,7 +173,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", @@ -202,7 +201,12 @@ "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", + "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", + "msgpackr": "^1.10.1", + "multer@<2.1.1": "^2.1.1" }, "onlyBuiltDependencies": [ "@clerk/shared", 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/packages/providers/package.json b/packages/providers/package.json index 7c05b8a5bec..44222b647da 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -87,7 +87,6 @@ "@types/sparkpost": "^2.1.5", "@types/uuid": "^8.3.4", "codecov": "^3.5.0", - "cspell": "~6.19.2", "nock": "^13.1.3", "npm-run-all": "^4.1.5", "open-cli": "^6.0.1", 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 &&