From 65ea0e1a21143d63ea9985d1609257b16216f7b5 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Mon, 16 Feb 2026 10:27:30 +0100 Subject: [PATCH 1/4] feat(novu): email step resolver init & publish cmds fixes NV-7094 (#9989) --- packages/novu/package.json | 1 + .../templates/no-default-export.tsx | 11 + .../__fixtures__/templates/no-react-email.tsx | 9 + .../templates/should-be-ignored.test.tsx | 11 + .../__fixtures__/templates/test-file.test.tsx | 11 + .../__fixtures__/templates/test-template.tsx | 11 + .../__fixtures__/templates/valid-template.tsx | 19 + .../novu/src/commands/email/api/client.ts | 220 ++++++++++ packages/novu/src/commands/email/api/index.ts | 1 + .../commands/email/bundler/bundler.spec.ts | 111 +++++ .../src/commands/email/bundler/bundler.ts | 122 ++++++ .../src/commands/email/bundler/config.spec.ts | 49 +++ .../novu/src/commands/email/bundler/config.ts | 111 +++++ .../novu/src/commands/email/bundler/index.ts | 2 + .../commands/email/config/define-config.ts | 5 + .../novu/src/commands/email/config/index.ts | 4 + .../novu/src/commands/email/config/loader.ts | 90 ++++ .../src/commands/email/config/schema.spec.ts | 341 +++++++++++++++ .../novu/src/commands/email/config/schema.ts | 96 +++++ .../src/commands/email/discovery/index.ts | 2 + .../email/discovery/step-discovery.spec.ts | 254 +++++++++++ .../email/discovery/step-discovery.ts | 277 ++++++++++++ .../discovery/template-discovery.spec.ts | 51 +++ .../email/discovery/template-discovery.ts | 155 +++++++ packages/novu/src/commands/email/index.ts | 2 + packages/novu/src/commands/email/init.ts | 350 +++++++++++++++ packages/novu/src/commands/email/publish.ts | 407 ++++++++++++++++++ .../__snapshots__/step-file.spec.ts.snap | 97 +++++ .../__snapshots__/worker-wrapper.spec.ts.snap | 258 +++++++++++ .../src/commands/email/templates/index.ts | 1 + .../email/templates/step-file.spec.ts | 33 ++ .../src/commands/email/templates/step-file.ts | 36 ++ .../email/templates/worker-wrapper.spec.ts | 54 +++ .../email/templates/worker-wrapper.ts | 133 ++++++ packages/novu/src/commands/email/types.ts | 42 ++ .../commands/email/utils/data-transforms.ts | 53 +++ .../src/commands/email/utils/environment.ts | 17 + .../src/commands/email/utils/file-paths.ts | 30 ++ .../novu/src/commands/email/utils/index.ts | 17 + .../novu/src/commands/email/utils/spinner.ts | 32 ++ .../novu/src/commands/email/utils/table.ts | 46 ++ .../src/commands/email/utils/validation.ts | 55 +++ packages/novu/src/index.ts | 42 ++ packages/novu/tsconfig.json | 2 +- packages/novu/vitest.config.ts | 7 + pnpm-lock.yaml | 241 +++++++++++ 46 files changed, 3918 insertions(+), 1 deletion(-) create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx create mode 100644 packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx create mode 100644 packages/novu/src/commands/email/api/client.ts create mode 100644 packages/novu/src/commands/email/api/index.ts create mode 100644 packages/novu/src/commands/email/bundler/bundler.spec.ts create mode 100644 packages/novu/src/commands/email/bundler/bundler.ts create mode 100644 packages/novu/src/commands/email/bundler/config.spec.ts create mode 100644 packages/novu/src/commands/email/bundler/config.ts create mode 100644 packages/novu/src/commands/email/bundler/index.ts create mode 100644 packages/novu/src/commands/email/config/define-config.ts create mode 100644 packages/novu/src/commands/email/config/index.ts create mode 100644 packages/novu/src/commands/email/config/loader.ts create mode 100644 packages/novu/src/commands/email/config/schema.spec.ts create mode 100644 packages/novu/src/commands/email/config/schema.ts create mode 100644 packages/novu/src/commands/email/discovery/index.ts create mode 100644 packages/novu/src/commands/email/discovery/step-discovery.spec.ts create mode 100644 packages/novu/src/commands/email/discovery/step-discovery.ts create mode 100644 packages/novu/src/commands/email/discovery/template-discovery.spec.ts create mode 100644 packages/novu/src/commands/email/discovery/template-discovery.ts create mode 100644 packages/novu/src/commands/email/index.ts create mode 100644 packages/novu/src/commands/email/init.ts create mode 100644 packages/novu/src/commands/email/publish.ts create mode 100644 packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap create mode 100644 packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap create mode 100644 packages/novu/src/commands/email/templates/index.ts create mode 100644 packages/novu/src/commands/email/templates/step-file.spec.ts create mode 100644 packages/novu/src/commands/email/templates/step-file.ts create mode 100644 packages/novu/src/commands/email/templates/worker-wrapper.spec.ts create mode 100644 packages/novu/src/commands/email/templates/worker-wrapper.ts create mode 100644 packages/novu/src/commands/email/types.ts create mode 100644 packages/novu/src/commands/email/utils/data-transforms.ts create mode 100644 packages/novu/src/commands/email/utils/environment.ts create mode 100644 packages/novu/src/commands/email/utils/file-paths.ts create mode 100644 packages/novu/src/commands/email/utils/index.ts create mode 100644 packages/novu/src/commands/email/utils/spinner.ts create mode 100644 packages/novu/src/commands/email/utils/table.ts create mode 100644 packages/novu/src/commands/email/utils/validation.ts create mode 100644 packages/novu/vitest.config.ts diff --git a/packages/novu/package.json b/packages/novu/package.json index 32a69114332..44d870eb7d7 100644 --- a/packages/novu/package.json +++ b/packages/novu/package.json @@ -73,6 +73,7 @@ "commander": "^9.0.0", "configstore": "^5.0.0", "dotenv": "^16.4.5", + "esbuild": "^0.19.0", "form-data": "^4.0.5", "get-port": "^5.1.1", "gradient-string": "^2.0.0", diff --git a/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx b/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx new file mode 100644 index 00000000000..3937a0c8032 --- /dev/null +++ b/packages/novu/src/commands/email/__fixtures__/templates/no-default-export.tsx @@ -0,0 +1,11 @@ +import { Body, Container, Html } from '@react-email/components'; + +export function EmailComponent() { + return ( + + + No default export + + + ); +} diff --git a/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx b/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx new file mode 100644 index 00000000000..6f52b673be2 --- /dev/null +++ b/packages/novu/src/commands/email/__fixtures__/templates/no-react-email.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function RegularComponent() { + return ( +
+

This has JSX but no React Email imports

+
+ ); +} diff --git a/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx b/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx new file mode 100644 index 00000000000..880168d4ff3 --- /dev/null +++ b/packages/novu/src/commands/email/__fixtures__/templates/should-be-ignored.test.tsx @@ -0,0 +1,11 @@ +import { Body, Container, Html } from '@react-email/components'; + +export default function IgnoredEmail() { + return ( + + + This file matches *.test.tsx pattern and should be ignored + + + ); +} diff --git a/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx b/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx new file mode 100644 index 00000000000..bc108b9e14f --- /dev/null +++ b/packages/novu/src/commands/email/__fixtures__/templates/test-file.test.tsx @@ -0,0 +1,11 @@ +import { Body, Container, Html } from '@react-email/components'; + +export default function TestEmail() { + return ( + + + This is a test file and should be ignored + + + ); +} diff --git a/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx b/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx new file mode 100644 index 00000000000..bc108b9e14f --- /dev/null +++ b/packages/novu/src/commands/email/__fixtures__/templates/test-template.tsx @@ -0,0 +1,11 @@ +import { Body, Container, Html } from '@react-email/components'; + +export default function TestEmail() { + return ( + + + This is a test file and should be ignored + + + ); +} diff --git a/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx b/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx new file mode 100644 index 00000000000..9b59b9fc43b --- /dev/null +++ b/packages/novu/src/commands/email/__fixtures__/templates/valid-template.tsx @@ -0,0 +1,19 @@ +import { Body, Container, Head, Heading, Html, Text } from '@react-email/components'; + +interface WelcomeEmailProps { + name?: string; +} + +export default function WelcomeEmail({ name = 'User' }: WelcomeEmailProps) { + return ( + + + + + Welcome, {name}! + Thanks for joining us. + + + + ); +} diff --git a/packages/novu/src/commands/email/api/client.ts b/packages/novu/src/commands/email/api/client.ts new file mode 100644 index 00000000000..50af25a1157 --- /dev/null +++ b/packages/novu/src/commands/email/api/client.ts @@ -0,0 +1,220 @@ +import axios from 'axios'; +import FormData from 'form-data'; +import type { DeploymentResult, EnvironmentInfo, StepResolverManifestStep, StepResolverReleaseBundle } from '../types'; + +export class StepResolverClient { + constructor( + private apiUrl: string, + private secretKey: string + ) {} + + private getAuthHeaders() { + return { + Authorization: `ApiKey ${this.secretKey}`, + }; + } + + async validateConnection(): Promise { + try { + await axios.get(`${this.apiUrl}/v1/users/me`, { + headers: this.getAuthHeaders(), + }); + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 401) { + throw new Error('Invalid API key. Please check your secret key.'); + } + throw new Error(`Connection failed: ${error.response?.data?.message || error.message}`); + } + throw error; + } + } + + async getEnvironmentInfo(): Promise { + try { + const response = await axios.get(`${this.apiUrl}/v1/environments/me`, { + headers: this.getAuthHeaders(), + }); + + const envData = response.data.data; + + return { + _id: envData._id, + name: envData.name, + _organizationId: envData._organizationId, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 401) { + throw new Error('Invalid API key. Please check your secret key.'); + } + if (error.response?.status === 404) { + throw new Error('Environment not found. Please ensure your API key has proper permissions.'); + } + throw new Error(`Failed to fetch environment: ${error.response?.data?.message || error.message}`); + } + throw error; + } + } + + async deployRelease( + bundle: StepResolverReleaseBundle, + manifestSteps: StepResolverManifestStep[] + ): Promise { + try { + const formData = new FormData(); + formData.append('manifest', JSON.stringify({ steps: manifestSteps })); + formData.append('bundle', Buffer.from(bundle.code, 'utf8'), { + filename: 'worker.mjs', + contentType: 'application/javascript+module', + }); + + const response = await axios.post(`${this.apiUrl}/v2/step-resolvers/deploy`, formData, { + headers: { + ...this.getAuthHeaders(), + ...formData.getHeaders(), + }, + // Limit is enforced on the server side + maxBodyLength: Infinity, + }); + + const data = response.data.data; + if ( + typeof data?.stepResolverHash !== 'string' || + typeof data?.workerId !== 'string' || + typeof data?.deployedAt !== 'string' + ) { + throw new Error('Invalid deployment response from API'); + } + + return { + stepResolverHash: data.stepResolverHash, + workerId: data.workerId, + selectedStepsCount: data.selectedStepsCount ?? manifestSteps.length, + deployedAt: data.deployedAt, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + const apiMessage = this.formatApiErrorMessage(error.response?.data, error.message || 'Request failed'); + + if (error.response?.status === 401) { + throw new Error('Invalid API key. Please check your secret key.'); + } + if (error.response?.status === 400) { + throw new Error(`Bad request: ${apiMessage}`); + } + if (error.response?.status === 404) { + const stepContext = this.extractStepContext(error.response.data); + if (stepContext) { + throw new Error(`Not found: ${stepContext}. Make sure the workflow and its steps exist before publishing.`); + } + throw new Error('Workflow or step not found. Make sure the workflow and its steps exist before publishing.'); + } + if (error.response?.status === 429) { + throw new Error('Rate limit exceeded. Please try again later.'); + } + if (error.response?.status >= 500) { + throw new Error(`Server error (${error.response.status}): ${apiMessage || 'Internal server error'}`); + } + + throw new Error(`Deployment failed (${error.response?.status || 'unknown'}): ${apiMessage}`); + } + + if (error instanceof Error) { + throw new Error(`Network error: ${error.message}`); + } + + throw new Error('Unknown deployment error occurred'); + } + } + + private formatApiErrorMessage(data: unknown, fallback: string): string { + const root = asRecord(data); + if (!root) { + return fallback; + } + + const baseMessage = this.readMessage(root) ?? this.readString(root.error) ?? fallback; + const stepContext = this.extractStepContext(root); + + if (!stepContext) { + return baseMessage; + } + + return `${baseMessage} (${stepContext})`; + } + + private readMessage(payload: Record): string | undefined { + const rawMessage = payload.message; + + if (typeof rawMessage === 'string' && rawMessage.trim().length > 0) { + return rawMessage; + } + + if (Array.isArray(rawMessage)) { + const messages = rawMessage.filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0 + ); + if (messages.length > 0) { + return messages.join(', '); + } + } + + const messageRecord = asRecord(rawMessage); + if (messageRecord) { + const nestedMessage = this.readString(messageRecord.message); + if (nestedMessage) { + return nestedMessage; + } + } + + return undefined; + } + + private extractStepContext(payload: Record): string | undefined { + const possibleSources: Record[] = [payload]; + const ctx = asRecord(payload.ctx); + if (ctx) { + possibleSources.push(ctx); + } + + const nestedMessage = asRecord(payload.message); + if (nestedMessage) { + possibleSources.push(nestedMessage); + } + + for (const source of possibleSources) { + const workflowId = this.readString(source.workflowId); + const stepId = this.readString(source.stepId); + + if (workflowId && stepId) { + return `workflowId=${workflowId}, stepId=${stepId}`; + } + if (workflowId) { + return `workflowId=${workflowId}`; + } + if (stepId) { + return `stepId=${stepId}`; + } + } + + return undefined; + } + + private readString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return value as Record; +} diff --git a/packages/novu/src/commands/email/api/index.ts b/packages/novu/src/commands/email/api/index.ts new file mode 100644 index 00000000000..b853a8bc7f0 --- /dev/null +++ b/packages/novu/src/commands/email/api/index.ts @@ -0,0 +1 @@ +export { StepResolverClient } from './client'; diff --git a/packages/novu/src/commands/email/bundler/bundler.spec.ts b/packages/novu/src/commands/email/bundler/bundler.spec.ts new file mode 100644 index 00000000000..2e0ea1fb15c --- /dev/null +++ b/packages/novu/src/commands/email/bundler/bundler.spec.ts @@ -0,0 +1,111 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DiscoveredStep } from '../types'; +import { bundleRelease } from './bundler'; + +describe('bundleRelease', () => { + let tempDir = ''; + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should bundle release when aliases are provided', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-bundler-alias-')); + const sourceDir = path.join(tempDir, 'src'); + const stepDir = path.join(tempDir, 'novu'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.mkdirSync(stepDir, { recursive: true }); + + fs.writeFileSync(path.join(sourceDir, 'utils.ts'), "export const body = 'Hello from alias';\n", 'utf8'); + + const stepFilePath = path.join(stepDir, 'welcome.step.ts'); + fs.writeFileSync( + stepFilePath, + ` +import { body } from '@emails/utils'; + +export const stepId = 'welcome-email'; +export const workflowId = 'onboarding'; +export const type = 'email'; + +export default async function () { + return { + subject: 'Welcome', + body + }; +} + `.trim(), + 'utf8' + ); + + const steps: DiscoveredStep[] = [ + { + stepId: 'welcome-email', + workflowId: 'onboarding', + type: 'email', + filePath: stepFilePath, + relativePath: 'welcome.step.ts', + }, + ]; + + const bundle = await bundleRelease(steps, tempDir, { + minify: false, + aliases: { + '@emails/*': './src/*', + }, + }); + + expect(bundle.size).toBeGreaterThan(0); + expect(bundle.code).toContain('Hello from alias'); + }); + + it('should show actionable unresolved import error when alias is missing', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-bundler-error-')); + const stepDir = path.join(tempDir, 'novu'); + fs.mkdirSync(stepDir, { recursive: true }); + + const stepFilePath = path.join(stepDir, 'missing-alias.step.ts'); + fs.writeFileSync( + stepFilePath, + ` +import { body } from '@emails/utils'; + +export const stepId = 'missing-alias'; +export const workflowId = 'onboarding'; +export const type = 'email'; + +export default async function () { + return { + subject: 'Welcome', + body + }; +} + `.trim(), + 'utf8' + ); + + const steps: DiscoveredStep[] = [ + { + stepId: 'missing-alias', + workflowId: 'onboarding', + type: 'email', + filePath: stepFilePath, + relativePath: 'missing-alias.step.ts', + }, + ]; + + try { + await bundleRelease(steps, tempDir); + throw new Error('Expected bundling to fail'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain('Unresolved imports'); + expect(message).toContain('aliases field'); + } + }); +}); diff --git a/packages/novu/src/commands/email/bundler/bundler.ts b/packages/novu/src/commands/email/bundler/bundler.ts new file mode 100644 index 00000000000..02929fbcc70 --- /dev/null +++ b/packages/novu/src/commands/email/bundler/bundler.ts @@ -0,0 +1,122 @@ +import * as esbuild from 'esbuild'; +import { generateWorkerWrapper } from '../templates/worker-wrapper'; +import type { DiscoveredStep, StepResolverReleaseBundle } from '../types'; +import { getBundlerConfig } from './config'; + +const MAX_BUNDLE_SIZE = 10 * 1024 * 1024; // 10MB in bytes +const BUNDLE_LABEL = 'step-resolver-release'; + +interface BundleBuildOptions { + minify?: boolean; + aliases?: Record; +} + +export async function bundleRelease( + steps: DiscoveredStep[], + rootDir: string, + options: BundleBuildOptions = {} +): Promise { + return bundleSteps(BUNDLE_LABEL, steps, rootDir, options); +} + +export function formatBundleSize(size: number): string { + if (size < 1024) { + return `${size} B`; + } else if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(2)} KB`; + } else { + return `${(size / 1024 / 1024).toFixed(2)} MB`; + } +} + +function formatBundlingError(bundleLabel: string, error: unknown): Error { + if (isBuildFailure(error)) { + const unresolvedImports = error.errors.filter((entry) => entry.text.includes('Could not resolve')); + if (unresolvedImports.length > 0) { + const details = unresolvedImports + .map((entry) => { + if (!entry.location) { + return entry.text; + } + + return `${entry.text} (${entry.location.file}:${entry.location.line}:${entry.location.column})`; + }) + .join('\n • '); + + return new Error( + `Failed to bundle release: ${bundleLabel}\n\n` + + `Unresolved imports:\n` + + ` • ${details}\n\n` + + `Hints:\n` + + ` • Add custom path aliases in novu.config.ts under the aliases field\n` + + ` • Or define aliases in tsconfig/jsconfig paths and run publish from the matching project root` + ); + } + + return new Error(`Failed to bundle release: ${bundleLabel}\n${error.message}`); + } + + if (error instanceof Error) { + return new Error(`Failed to bundle release: ${bundleLabel}\n${error.message}`); + } + + return new Error(`Failed to bundle release: ${bundleLabel}`); +} + +function isBuildFailure(error: unknown): error is esbuild.BuildFailure { + return typeof error === 'object' && error !== null && 'errors' in error && Array.isArray(error.errors); +} + +async function bundleSteps( + bundleId: string, + steps: DiscoveredStep[], + rootDir: string, + options: BundleBuildOptions +): Promise<{ code: string; size: number }> { + const wrapperCode = generateWorkerWrapper(steps, rootDir); + const baseConfig = getBundlerConfig({ + rootDir, + minify: options.minify, + aliases: options.aliases, + }); + + let result: esbuild.BuildResult; + + try { + result = await esbuild.build({ + ...baseConfig, + stdin: { + contents: wrapperCode, + loader: 'tsx', + resolveDir: rootDir, + sourcefile: `${bundleId}-worker.tsx`, + }, + write: false, + metafile: true, + }); + } catch (error) { + throw formatBundlingError(bundleId, error); + } + + const outputFile = result.outputFiles?.[0]; + if (!outputFile) { + throw new Error(`No output from esbuild for bundle: ${bundleId}`); + } + + const code = outputFile.text; + const size = Buffer.byteLength(code, 'utf8'); + + if (size > MAX_BUNDLE_SIZE) { + throw new Error( + `Bundle too large: ${bundleId}\n\n` + + ` Bundle size: ${(size / 1024 / 1024).toFixed(1)} MB\n` + + ` Maximum: ${MAX_BUNDLE_SIZE / 1024 / 1024} MB (Cloudflare limit)\n\n` + + `Suggestions:\n` + + ` • Reduce template complexity\n` + + ` • Remove unused dependencies\n` + + ` • Publish specific workflows with --workflow for targeted updates` + ); + } + + return { code, size }; +} diff --git a/packages/novu/src/commands/email/bundler/config.spec.ts b/packages/novu/src/commands/email/bundler/config.spec.ts new file mode 100644 index 00000000000..13181e82aea --- /dev/null +++ b/packages/novu/src/commands/email/bundler/config.spec.ts @@ -0,0 +1,49 @@ +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { getBundlerConfig } from './config'; + +describe('getBundlerConfig', () => { + it('should normalize wildcard aliases and resolve relative targets from rootDir', () => { + const rootDir = '/tmp/novu-project'; + + const config = getBundlerConfig({ + rootDir, + minify: false, + aliases: { + '@/*': './src/*', + '@emails/*': './emails/*', + '@core/': './core/', + }, + }); + + expect(config.alias).toEqual({ + '@': path.resolve(rootDir, './src'), + '@emails': path.resolve(rootDir, './emails'), + '@core': path.resolve(rootDir, './core'), + }); + }); + + it('should preserve absolute alias targets', () => { + const rootDir = '/tmp/novu-project'; + const absoluteTarget = '/tmp/shared'; + + const config = getBundlerConfig({ + rootDir, + aliases: { + '@shared': absoluteTarget, + }, + }); + + expect(config.alias).toEqual({ + '@shared': absoluteTarget, + }); + }); + + it('should not include alias option when aliases are not provided', () => { + const config = getBundlerConfig({ + rootDir: '/tmp/novu-project', + }); + + expect(config.alias).toBeUndefined(); + }); +}); diff --git a/packages/novu/src/commands/email/bundler/config.ts b/packages/novu/src/commands/email/bundler/config.ts new file mode 100644 index 00000000000..8f4b554d633 --- /dev/null +++ b/packages/novu/src/commands/email/bundler/config.ts @@ -0,0 +1,111 @@ +import type { BuildOptions } from 'esbuild'; +import * as path from 'path'; + +interface BundlerConfigOptions { + rootDir: string; + minify?: boolean; + aliases?: Record; +} + +export function getBundlerConfig(options: BundlerConfigOptions): BuildOptions { + const { rootDir, minify = true, aliases } = options; + const normalizedAliases = normalizeAliases(aliases, rootDir); + + return { + bundle: true, + platform: 'neutral', + format: 'esm', + target: 'es2022', + minify, + sourcemap: false, + jsx: 'automatic', + jsxImportSource: 'react', + conditions: ['worker', 'browser'], + mainFields: ['browser', 'module', 'main'], + alias: normalizedAliases, + external: [], + logLevel: 'warning', + loader: { + '.ts': 'tsx', + '.js': 'jsx', + }, + define: { + 'process.env.NODE_ENV': '"production"', + 'process.env': '{}', + global: 'globalThis', + }, + banner: { + js: ` +// Cloudflare Workers environment shims +globalThis.process = globalThis.process || { env: { NODE_ENV: 'production' } }; +globalThis.global = globalThis.global || globalThis; + +// MessageChannel polyfill for React +globalThis.MessageChannel = globalThis.MessageChannel || class MessageChannel { + constructor() { + this.port1 = { postMessage: () => {}, onmessage: null }; + this.port2 = { postMessage: () => {}, onmessage: null }; + } +}; + `.trim(), + }, + }; +} + +function normalizeAliases( + aliases: Record | undefined, + rootDir: string +): Record | undefined { + if (!aliases) { + return undefined; + } + + const normalizedAliases: Record = {}; + + for (const [rawAlias, rawTarget] of Object.entries(aliases)) { + const alias = normalizeAliasKey(rawAlias); + const target = normalizeAliasTarget(rawTarget); + + if (!alias || !target) { + continue; + } + + normalizedAliases[alias] = path.isAbsolute(target) ? target : path.resolve(rootDir, target); + } + + return Object.keys(normalizedAliases).length > 0 ? normalizedAliases : undefined; +} + +function normalizeAliasKey(alias: string): string { + const trimmed = alias.trim(); + if (!trimmed) { + return ''; + } + + if (trimmed.endsWith('/*')) { + return trimmed.slice(0, -2); + } + + if (trimmed.endsWith('/')) { + return trimmed.slice(0, -1); + } + + return trimmed; +} + +function normalizeAliasTarget(target: string): string { + const trimmed = target.trim(); + if (!trimmed) { + return ''; + } + + if (trimmed.endsWith('/*')) { + return trimmed.slice(0, -2); + } + + if (trimmed.endsWith('/') && trimmed.length > 1) { + return trimmed.slice(0, -1); + } + + return trimmed; +} diff --git a/packages/novu/src/commands/email/bundler/index.ts b/packages/novu/src/commands/email/bundler/index.ts new file mode 100644 index 00000000000..c81281b100b --- /dev/null +++ b/packages/novu/src/commands/email/bundler/index.ts @@ -0,0 +1,2 @@ +export { bundleRelease, formatBundleSize } from './bundler'; +export { getBundlerConfig } from './config'; diff --git a/packages/novu/src/commands/email/config/define-config.ts b/packages/novu/src/commands/email/config/define-config.ts new file mode 100644 index 00000000000..1ae160bba28 --- /dev/null +++ b/packages/novu/src/commands/email/config/define-config.ts @@ -0,0 +1,5 @@ +import type { NovuConfig } from './schema'; + +export function defineConfig(config: NovuConfig): NovuConfig { + return config; +} diff --git a/packages/novu/src/commands/email/config/index.ts b/packages/novu/src/commands/email/config/index.ts new file mode 100644 index 00000000000..5a09dddc5aa --- /dev/null +++ b/packages/novu/src/commands/email/config/index.ts @@ -0,0 +1,4 @@ +export { defineConfig } from './define-config'; +export { loadConfig } from './loader'; +export type { EmailStepConfig, NovuConfig } from './schema'; +export { validateConfig } from './schema'; diff --git a/packages/novu/src/commands/email/config/loader.ts b/packages/novu/src/commands/email/config/loader.ts new file mode 100644 index 00000000000..c5042662963 --- /dev/null +++ b/packages/novu/src/commands/email/config/loader.ts @@ -0,0 +1,90 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { NovuConfig, validateConfig } from './schema'; + +export async function loadConfig(configPath?: string): Promise { + const cwd = process.cwd(); + + const possiblePaths = configPath ? [path.resolve(cwd, configPath)] : await findConfigPaths(cwd); + + for (const filePath of possiblePaths) { + if (fs.existsSync(filePath)) { + try { + const config = await loadConfigFile(filePath); + + return validateConfig(config); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Config file: ${filePath}\n${errorMessage}`); + } + } + } + + return null; +} + +async function findConfigPaths(startDir: string): Promise { + const configNames = ['novu.config.ts', 'novu.config.js', 'novu.config.mjs', 'novu.config.cjs']; + const paths: string[] = []; + + let currentDir = startDir; + let depth = 0; + const maxDepth = 3; + + while (depth <= maxDepth) { + for (const name of configNames) { + paths.push(path.join(currentDir, name)); + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + depth++; + } + + return paths; +} + +async function loadConfigFile(filePath: string): Promise { + const ext = path.extname(filePath); + + if (ext === '.ts') { + return await loadTypeScriptConfig(filePath); + } + + delete require.cache[require.resolve(filePath)]; + + const module = require(filePath) as { default?: unknown }; + + return module.default || module; +} + +async function loadTypeScriptConfig(filePath: string): Promise { + const esbuild = require('esbuild'); + + const result = await esbuild.build({ + entryPoints: [filePath], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + external: ['esbuild'], + logLevel: 'silent', + }); + + if (!result.outputFiles || result.outputFiles.length === 0) { + throw new Error('esbuild produced no output for config file'); + } + + const code = result.outputFiles[0].text; + const tempModule: { exports: { default?: unknown; [key: string]: unknown } } = { + exports: {}, + }; + const func = new Function('module', 'exports', 'require', code); + func(tempModule, tempModule.exports, require); + + return tempModule.exports.default || tempModule.exports; +} diff --git a/packages/novu/src/commands/email/config/schema.spec.ts b/packages/novu/src/commands/email/config/schema.spec.ts new file mode 100644 index 00000000000..ddec84c0bb1 --- /dev/null +++ b/packages/novu/src/commands/email/config/schema.spec.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; +import { validateConfig } from './schema'; + +describe('validateConfig', () => { + describe('valid configs', () => { + it('should accept valid config with all fields', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + subject: 'Welcome!', + }, + }, + }, + }, + }, + outDir: './novu', + apiUrl: 'https://api.novu.co', + aliases: { + '@emails': './src/emails', + }, + }; + + expect(() => validateConfig(config)).not.toThrow(); + const result = validateConfig(config); + expect(result.workflows.onboarding.steps.email['welcome-email'].subject).toBe('Welcome!'); + }); + + it('should accept valid config with optional subject', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).not.toThrow(); + }); + + it('should accept valid config with multiple steps in different workflows', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + billing: { + steps: { + email: { + 'invoice-email': { + template: 'emails/invoice.tsx', + subject: 'Your Invoice', + }, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).not.toThrow(); + }); + + it('should accept duplicate step IDs across different workflows', () => { + const config = { + workflows: { + signup: { + steps: { + email: { + confirmation: { + template: 'emails/signup-confirm.tsx', + subject: 'Confirm Your Signup', + }, + }, + }, + }, + booking: { + steps: { + email: { + confirmation: { + template: 'emails/booking-confirm.tsx', + subject: 'Booking Confirmed', + }, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).not.toThrow(); + }); + }); + + describe('invalid configs', () => { + it('should reject non-object config', () => { + expect(() => validateConfig(null)).toThrow('Invalid config: must be an object'); + expect(() => validateConfig(undefined)).toThrow('Invalid config: must be an object'); + expect(() => validateConfig('string')).toThrow('Invalid config: must be an object'); + expect(() => validateConfig(123)).toThrow('Invalid config: must be an object'); + }); + + it('should reject missing workflows field', () => { + const config = {}; + + expect(() => validateConfig(config)).toThrow('Invalid config: workflows field is required and must be an object'); + }); + + it('should reject missing steps.email field', () => { + const config = { + workflows: { + onboarding: { + steps: {}, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow( + "workflows['onboarding'].steps.email is required and must be an object" + ); + }); + + it('should reject missing template in step config', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': {}, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow( + "workflows['onboarding'].steps.email['welcome-email'].template is required and must be a string" + ); + }); + + it('should reject invalid template type', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 123, + }, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow( + "workflows['onboarding'].steps.email['welcome-email'].template is required and must be a string" + ); + }); + + it('should reject invalid subject type', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + subject: 123, + }, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow( + "workflows['onboarding'].steps.email['welcome-email'].subject must be a string" + ); + }); + + it('should reject invalid outDir type', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + }, + outDir: 123, + }; + + expect(() => validateConfig(config)).toThrow('outDir must be a string'); + }); + + it('should reject invalid apiUrl type', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + }, + apiUrl: true, + }; + + expect(() => validateConfig(config)).toThrow('apiUrl must be a string'); + }); + + it('should reject invalid aliases type', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + }, + aliases: 'invalid', + }; + + expect(() => validateConfig(config)).toThrow('aliases must be an object'); + }); + + it('should reject alias target with non-string value', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + }, + aliases: { + '@emails': 123, + }, + }; + + expect(() => validateConfig(config)).toThrow("aliases['@emails'] must be a string"); + }); + + it('should reject alias target with empty string value', () => { + const config = { + workflows: { + onboarding: { + steps: { + email: { + 'welcome-email': { + template: 'emails/welcome.tsx', + }, + }, + }, + }, + }, + aliases: { + '@emails': ' ', + }, + }; + + expect(() => validateConfig(config)).toThrow("aliases['@emails'] cannot be empty"); + }); + + it('should collect multiple errors', () => { + const config = { + workflows: { + workflow1: { + steps: { + email: { + step1: { + template: 'emails/step1.tsx', + }, + step2: {}, + }, + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow('Configuration validation errors:'); + try { + validateConfig(config); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).toContain("workflows['workflow1'].steps.email['step2'].template"); + } + }); + + it('should reject duplicate step IDs within same workflow', () => { + const emailSteps: Record = {}; + emailSteps['welcome'] = { template: 'emails/welcome1.tsx' }; + emailSteps['welcome'] = { template: 'emails/welcome2.tsx' }; + + const config = { + workflows: { + onboarding: { + steps: { + email: emailSteps, + }, + }, + }, + }; + + // Note: JavaScript objects can't have true duplicate keys, so this test verifies + // that our validation would catch duplicates if they were possible. + // In practice, duplicate keys in config files would be caught at parse time. + // This test just ensures the validation logic is correct. + expect(() => validateConfig(config)).not.toThrow(); + }); + }); +}); diff --git a/packages/novu/src/commands/email/config/schema.ts b/packages/novu/src/commands/email/config/schema.ts new file mode 100644 index 00000000000..cb3cbd7632a --- /dev/null +++ b/packages/novu/src/commands/email/config/schema.ts @@ -0,0 +1,96 @@ +export type EmailStepConfig = { + template: string; + subject?: string; +}; + +export type NovuConfig = { + outDir?: string; + workflows: { + [workflowId: string]: { + steps: { + email: Record; + }; + }; + }; + apiUrl?: string; + aliases?: Record; +}; + +export function validateConfig(config: unknown): NovuConfig { + if (!config || typeof config !== 'object') { + throw new Error('Invalid config: must be an object'); + } + + const novuConfig = config as Partial; + + if (!novuConfig.workflows || typeof novuConfig.workflows !== 'object') { + throw new Error('Invalid config: workflows field is required and must be an object'); + } + + const errors: string[] = []; + + for (const [workflowId, workflow] of Object.entries(novuConfig.workflows)) { + if (!workflow || typeof workflow !== 'object') { + errors.push(`workflows['${workflowId}'] must be an object`); + continue; + } + + if (!workflow.steps || typeof workflow.steps !== 'object') { + errors.push(`workflows['${workflowId}'].steps is required and must be an object`); + continue; + } + + if (!workflow.steps.email || typeof workflow.steps.email !== 'object') { + errors.push(`workflows['${workflowId}'].steps.email is required and must be an object`); + continue; + } + + const stepIds = new Set(); + + for (const [stepId, emailConfig] of Object.entries(workflow.steps.email)) { + if (stepIds.has(stepId)) { + errors.push(`Duplicate step ID '${stepId}' in workflow '${workflowId}'`); + } + stepIds.add(stepId); + + if (!emailConfig.template || typeof emailConfig.template !== 'string') { + errors.push(`workflows['${workflowId}'].steps.email['${stepId}'].template is required and must be a string`); + } + + if (emailConfig.subject !== undefined && typeof emailConfig.subject !== 'string') { + errors.push(`workflows['${workflowId}'].steps.email['${stepId}'].subject must be a string`); + } + } + } + + if (novuConfig.outDir !== undefined && typeof novuConfig.outDir !== 'string') { + errors.push('outDir must be a string'); + } + + if (novuConfig.apiUrl !== undefined && typeof novuConfig.apiUrl !== 'string') { + errors.push('apiUrl must be a string'); + } + + if (novuConfig.aliases !== undefined) { + if (typeof novuConfig.aliases !== 'object' || novuConfig.aliases === null) { + errors.push('aliases must be an object'); + } else { + for (const [alias, target] of Object.entries(novuConfig.aliases)) { + if (typeof target !== 'string') { + errors.push(`aliases['${alias}'] must be a string`); + continue; + } + + if (target.trim().length === 0) { + errors.push(`aliases['${alias}'] cannot be empty`); + } + } + } + } + + if (errors.length > 0) { + throw new Error(`Configuration validation errors:\n • ${errors.join('\n • ')}`); + } + + return novuConfig as NovuConfig; +} diff --git a/packages/novu/src/commands/email/discovery/index.ts b/packages/novu/src/commands/email/discovery/index.ts new file mode 100644 index 00000000000..9e36f974744 --- /dev/null +++ b/packages/novu/src/commands/email/discovery/index.ts @@ -0,0 +1,2 @@ +export { discoverStepFiles } from './step-discovery'; +export { type DiscoveredTemplate, discoverEmailTemplates } from './template-discovery'; diff --git a/packages/novu/src/commands/email/discovery/step-discovery.spec.ts b/packages/novu/src/commands/email/discovery/step-discovery.spec.ts new file mode 100644 index 00000000000..27e0456c451 --- /dev/null +++ b/packages/novu/src/commands/email/discovery/step-discovery.spec.ts @@ -0,0 +1,254 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { discoverStepFiles } from './step-discovery'; + +describe('step-discovery', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('discovers and validates a correct tsx step file', async () => { + writeStepFile( + 'welcome-email.step.tsx', + createStepFileContent({ stepId: 'welcome-email', workflowId: 'onboarding' }) + ); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(true); + expect(result.matchedFiles).toBe(1); + expect(result.errors).toHaveLength(0); + expect(result.steps).toHaveLength(1); + expect(result.steps[0]).toMatchObject({ + stepId: 'welcome-email', + workflowId: 'onboarding', + type: 'email', + relativePath: 'welcome-email.step.tsx', + }); + }); + + it('discovers valid js and jsx step files', async () => { + writeStepFile( + 'plain-js.step.js', + createStepFileContent({ stepId: 'plain-js', workflowId: 'workflow-js', useJsx: false }) + ); + writeStepFile( + 'template-jsx.step.jsx', + createStepFileContent({ stepId: 'template-jsx', workflowId: 'workflow-jsx', useJsx: true }) + ); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(true); + expect(result.matchedFiles).toBe(2); + expect(result.errors).toHaveLength(0); + expect(result.steps.map((step) => step.stepId)).toEqual(['plain-js', 'template-jsx']); + }); + + it('returns valid steps and errors when files are mixed', async () => { + writeStepFile( + 'valid.step.tsx', + createStepFileContent({ stepId: 'valid-step', workflowId: 'workflow-valid', useJsx: true }) + ); + writeStepFile( + 'invalid.step.tsx', + createStepFileContent({ includeStepId: false, workflowId: 'workflow-valid', useJsx: true }) + ); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(false); + expect(result.matchedFiles).toBe(2); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].stepId).toBe('valid-step'); + + const invalidError = result.errors.find((error) => error.filePath.endsWith('invalid.step.tsx')); + expect(invalidError).toBeDefined(); + expect(invalidError?.errors.some((error) => error.includes('stepId'))).toBe(true); + }); + + it.each([ + ['stepId', { includeStepId: false }, "Missing required export: 'stepId' (must be a string literal)"], + ['workflowId', { includeWorkflowId: false }, "Missing required export: 'workflowId' (must be a string literal)"], + ['type', { includeType: false }, "Missing required export: 'type' (must be a string literal)"], + ])('detects missing %s export', async (_name, options, expectedError) => { + writeStepFile('missing-required.step.tsx', createStepFileContent(options)); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(false); + expect(result.steps).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errors).toContain(expectedError); + }); + + it('detects invalid type export', async () => { + writeStepFile('invalid-type.step.tsx', createStepFileContent({ type: 'sms' })); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(false); + expect(result.steps).toHaveLength(0); + expect(result.errors[0].errors.some((error) => error.includes("must be 'email'"))).toBe(true); + }); + + it('detects missing default export', async () => { + writeStepFile('missing-default.step.tsx', createStepFileContent({ includeDefaultExport: false })); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(false); + expect(result.steps).toHaveLength(0); + expect(result.errors[0].errors.some((error) => error.includes('default function'))).toBe(true); + }); + + it('detects missing react-email import', async () => { + writeStepFile('missing-import.step.tsx', createStepFileContent({ includeReactEmailImport: false })); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(false); + expect(result.steps).toHaveLength(0); + expect(result.errors[0].errors.some((error) => error.includes('@react-email'))).toBe(true); + }); + + it('allows duplicate step IDs across different workflows', async () => { + writeStepFile('first.step.tsx', createStepFileContent({ stepId: 'confirmation', workflowId: 'signup' })); + writeStepFile('second.step.tsx', createStepFileContent({ stepId: 'confirmation', workflowId: 'booking' })); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(true); + expect(result.matchedFiles).toBe(2); + expect(result.steps).toHaveLength(2); + expect(result.errors).toHaveLength(0); + }); + + it('detects duplicate step IDs within same workflow', async () => { + writeStepFile('first.step.tsx', createStepFileContent({ stepId: 'duplicate-step', workflowId: 'onboarding' })); + writeStepFile('second.step.tsx', createStepFileContent({ stepId: 'duplicate-step', workflowId: 'onboarding' })); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(false); + expect(result.matchedFiles).toBe(2); + expect(result.steps).toHaveLength(0); + expect(result.errors).toHaveLength(2); + expect( + result.errors.every((error) => + error.errors.some((message) => message.includes("Duplicate stepId: 'duplicate-step' for workflow 'onboarding'")) + ) + ).toBe(true); + }); + + it('returns empty result when no step files are found', async () => { + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(true); + expect(result.matchedFiles).toBe(0); + expect(result.steps).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('returns discovered steps in deterministic path order', async () => { + writeStepFile('z-last.step.ts', createStepFileContent({ stepId: 'z-last', workflowId: 'wf-z', useJsx: false })); + writeStepFile( + 'nested/m-middle.step.ts', + createStepFileContent({ stepId: 'm-middle', workflowId: 'wf-m', useJsx: false }) + ); + writeStepFile('a-first.step.ts', createStepFileContent({ stepId: 'a-first', workflowId: 'wf-a', useJsx: false })); + + const result = await discoverStepFiles(tempDir); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.steps.map((step) => step.relativePath.replace(/\\/g, '/'))).toEqual([ + 'a-first.step.ts', + 'nested/m-middle.step.ts', + 'z-last.step.ts', + ]); + }); + + function writeStepFile(relativePath: string, content: string) { + const absolutePath = path.join(tempDir, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content); + } + + function createStepFileContent({ + stepId = 'welcome-email', + workflowId = 'onboarding', + type = 'email', + includeStepId = true, + includeWorkflowId = true, + includeType = true, + includeDefaultExport = true, + includeReactEmailImport = true, + useJsx = true, + }: { + stepId?: string; + workflowId?: string; + type?: string; + includeStepId?: boolean; + includeWorkflowId?: boolean; + includeType?: boolean; + includeDefaultExport?: boolean; + includeReactEmailImport?: boolean; + useJsx?: boolean; + } = {}): string { + const lines: string[] = []; + + if (includeReactEmailImport) { + lines.push("import { render } from '@react-email/components';"); + } + + if (useJsx) { + lines.push("import EmailTemplate from '../emails/welcome';"); + } + + lines.push(''); + + if (includeStepId) { + lines.push(`export const stepId = '${stepId}';`); + } + + if (includeWorkflowId) { + lines.push(`export const workflowId = '${workflowId}';`); + } + + if (includeType) { + lines.push(`export const type = '${type}';`); + } + + lines.push(''); + + if (includeDefaultExport) { + lines.push('export default async function({ payload }) {'); + lines.push(' return {'); + lines.push(" subject: payload?.subject || 'Welcome',"); + + if (useJsx) { + lines.push(' body: await render(),'); + } else { + lines.push(" body: await render('Hello'),"); + } + + lines.push(' };'); + lines.push('}'); + } + + lines.push(''); + + return lines.join('\n'); + } +}); diff --git a/packages/novu/src/commands/email/discovery/step-discovery.ts b/packages/novu/src/commands/email/discovery/step-discovery.ts new file mode 100644 index 00000000000..aaf9d2e2532 --- /dev/null +++ b/packages/novu/src/commands/email/discovery/step-discovery.ts @@ -0,0 +1,277 @@ +import fg from 'fast-glob'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; +import type { DiscoveredStep, StepDiscoveryResult, ValidationError } from '../types'; + +interface StepMetadata { + stepId?: string; + workflowId?: string; + type?: string; +} + +interface AnalyzedStepFile { + filePath: string; + relativePath: string; + metadata: StepMetadata; + hasDefaultExport: boolean; + hasReactEmailImport: boolean; + parseErrors: string[]; +} + +const STEP_FILE_PATTERN = '**/*.step.{ts,tsx,js,jsx}'; + +export async function discoverStepFiles(stepsDir: string): Promise { + const matchedStepFiles = await fg([STEP_FILE_PATTERN], { + cwd: stepsDir, + absolute: false, + onlyFiles: true, + }); + + const relativeStepFiles = matchedStepFiles.sort((a, b) => a.localeCompare(b)); + const analyses = relativeStepFiles.map((relativePath) => + analyzeStepFile(path.resolve(stepsDir, relativePath), relativePath) + ); + const duplicateStepIdErrors = buildDuplicateStepIdErrors(analyses); + + const steps: DiscoveredStep[] = []; + const errors: ValidationError[] = []; + + for (const analysis of analyses) { + const fileErrors = [...buildValidationErrors(analysis), ...(duplicateStepIdErrors.get(analysis.filePath) ?? [])]; + if (fileErrors.length > 0) { + errors.push({ + filePath: path.relative(process.cwd(), analysis.filePath), + errors: fileErrors, + }); + continue; + } + + const { stepId, workflowId, type } = analysis.metadata; + if (stepId && workflowId && type) { + steps.push({ + stepId, + workflowId, + type, + filePath: analysis.filePath, + relativePath: analysis.relativePath, + }); + } + } + + return { + valid: errors.length === 0, + matchedFiles: relativeStepFiles.length, + steps, + errors, + }; +} + +function analyzeStepFile(filePath: string, relativePath: string): AnalyzedStepFile { + try { + const sourceCode = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.Latest, true, getScriptKind(filePath)); + + return { + filePath, + relativePath, + metadata: extractStepMetadata(sourceFile), + hasDefaultExport: hasDefaultExportInFile(sourceFile), + hasReactEmailImport: hasReactEmailImportInFile(sourceFile), + parseErrors: extractParseDiagnostics(sourceFile), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + filePath, + relativePath, + metadata: {}, + hasDefaultExport: false, + hasReactEmailImport: false, + parseErrors: [`Failed to read or parse file: ${errorMessage}`], + }; + } +} + +function extractStepMetadata(sourceFile: ts.SourceFile): StepMetadata { + const metadata: StepMetadata = {}; + + function visit(node: ts.Node) { + if (ts.isVariableStatement(node) && hasModifier(node.modifiers, ts.SyntaxKind.ExportKeyword)) { + extractExportedStringLiterals(node, metadata); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return metadata; +} + +function extractExportedStringLiterals(node: ts.VariableStatement, metadata: StepMetadata): void { + for (const declaration of node.declarationList.declarations) { + if ( + !ts.isIdentifier(declaration.name) || + !declaration.initializer || + !ts.isStringLiteral(declaration.initializer) + ) { + continue; + } + + const exportName = declaration.name.text; + const exportValue = declaration.initializer.text; + + if (exportName === 'stepId') metadata.stepId = exportValue; + if (exportName === 'workflowId') metadata.workflowId = exportValue; + if (exportName === 'type') metadata.type = exportValue; + } +} + +function hasDefaultExportInFile(sourceFile: ts.SourceFile): boolean { + let hasExport = false; + + function visit(node: ts.Node) { + if (ts.isFunctionDeclaration(node) && hasModifier(node.modifiers, ts.SyntaxKind.DefaultKeyword)) { + hasExport = true; + } + if (ts.isExportAssignment(node) && !node.isExportEquals) { + hasExport = true; + } + if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) { + if (node.exportClause.elements.some((el) => el.name.text === 'default')) { + hasExport = true; + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return hasExport; +} + +function hasReactEmailImportInFile(sourceFile: ts.SourceFile): boolean { + let hasImport = false; + + function visit(node: ts.Node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const moduleText = node.moduleSpecifier.text; + if (moduleText === 'react-email' || moduleText.startsWith('@react-email/')) { + hasImport = true; + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return hasImport; +} + +function extractParseDiagnostics(sourceFile: ts.SourceFile): string[] { + const parseDiagnostics = (sourceFile as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }) + .parseDiagnostics; + + return (parseDiagnostics ?? []).map((diagnostic) => formatParseDiagnostic(sourceFile, diagnostic)); +} + +function buildValidationErrors(analysis: AnalyzedStepFile): string[] { + const errors: string[] = [...analysis.parseErrors]; + + if (!analysis.metadata.stepId) { + errors.push("Missing required export: 'stepId' (must be a string literal)"); + } + + if (!analysis.metadata.workflowId) { + errors.push("Missing required export: 'workflowId' (must be a string literal)"); + } + + if (!analysis.metadata.type) { + errors.push("Missing required export: 'type' (must be a string literal)"); + } else if (analysis.metadata.type !== 'email') { + errors.push(`Invalid type: '${analysis.metadata.type}' (must be 'email')`); + } + + if (!analysis.hasDefaultExport) { + errors.push('Missing default function export'); + } + + if (!analysis.hasReactEmailImport) { + errors.push("Missing import from '@react-email'"); + } + + return errors; +} + +function buildDuplicateStepIdErrors(analyses: AnalyzedStepFile[]): Map { + const filesByCompositeKey = groupAnalysesByCompositeKey(analyses); + return buildErrorsForDuplicates(filesByCompositeKey); +} + +function groupAnalysesByCompositeKey(analyses: AnalyzedStepFile[]): Map { + const grouped = new Map(); + + for (const analysis of analyses) { + if (!analysis.metadata.stepId || !analysis.metadata.workflowId) { + continue; + } + + const key = `${analysis.metadata.workflowId}:${analysis.metadata.stepId}`; + const files = grouped.get(key) ?? []; + files.push(analysis); + grouped.set(key, files); + } + + return grouped; +} + +function buildErrorsForDuplicates(filesByKey: Map): Map { + const errors = new Map(); + + for (const [compositeKey, files] of filesByKey) { + if (files.length <= 1) { + continue; + } + + const firstColonIndex = compositeKey.indexOf(':'); + const workflowId = firstColonIndex >= 0 ? compositeKey.substring(0, firstColonIndex) : compositeKey; + const stepId = firstColonIndex >= 0 ? compositeKey.substring(firstColonIndex + 1) : ''; + const relativePaths = files.map((file) => path.relative(process.cwd(), file.filePath)); + + for (const file of files) { + const currentFilePath = path.relative(process.cwd(), file.filePath); + const duplicateLocations = relativePaths.filter((candidate) => candidate !== currentFilePath); + const entryErrors = errors.get(file.filePath) ?? []; + entryErrors.push( + `Duplicate stepId: '${stepId}' for workflow '${workflowId}' is also defined in ${duplicateLocations.join(', ')}` + ); + errors.set(file.filePath, entryErrors); + } + } + + return errors; +} + +function getScriptKind(filePath: string): ts.ScriptKind { + const extension = path.extname(filePath).toLowerCase(); + + switch (extension) { + case '.ts': + return ts.ScriptKind.TS; + case '.tsx': + return ts.ScriptKind.TSX; + case '.js': + return ts.ScriptKind.JS; + case '.jsx': + return ts.ScriptKind.JSX; + default: + return ts.ScriptKind.Unknown; + } +} + +function hasModifier(modifiers: readonly ts.ModifierLike[] | undefined, kind: ts.SyntaxKind): boolean { + return (modifiers ?? []).some((modifier) => modifier.kind === kind); +} + +function formatParseDiagnostic(sourceFile: ts.SourceFile, diagnostic: ts.DiagnosticWithLocation): string { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + const position = sourceFile.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + return `Syntax error at ${position.line + 1}:${position.character + 1}: ${message}`; +} diff --git a/packages/novu/src/commands/email/discovery/template-discovery.spec.ts b/packages/novu/src/commands/email/discovery/template-discovery.spec.ts new file mode 100644 index 00000000000..b317848b67c --- /dev/null +++ b/packages/novu/src/commands/email/discovery/template-discovery.spec.ts @@ -0,0 +1,51 @@ +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { discoverEmailTemplates } from './template-discovery'; + +const fixturesDir = path.join(__dirname, '../__fixtures__/templates'); + +describe('discoverEmailTemplates', () => { + it('should find templates with default export and React Email imports', async () => { + const templates = await discoverEmailTemplates(fixturesDir); + + const validTemplate = templates.find((t) => t.relativePath.includes('valid-template')); + expect(validTemplate).toBeDefined(); + }); + + it('should ignore test files', async () => { + const templates = await discoverEmailTemplates(fixturesDir); + + const testFile = templates.find((t) => t.relativePath.includes('test-file.test')); + expect(testFile).toBeUndefined(); + }); + + it('should ignore files without default export', async () => { + const templates = await discoverEmailTemplates(fixturesDir); + + const noDefault = templates.find((t) => t.relativePath.includes('no-default-export')); + expect(noDefault).toBeUndefined(); + }); + + it('should ignore files without React Email imports', async () => { + const templates = await discoverEmailTemplates(fixturesDir); + + const noReactEmail = templates.find((t) => t.relativePath.includes('no-react-email')); + expect(noReactEmail).toBeUndefined(); + }); + + it('should return templates with correct structure', async () => { + const templates = await discoverEmailTemplates(fixturesDir); + + expect(templates.length).toBeGreaterThan(0); + const template = templates[0]; + expect(template).toHaveProperty('filePath'); + expect(template).toHaveProperty('relativePath'); + }); + + it('should handle empty directory', async () => { + const tempDir = path.join(__dirname, '../__fixtures__/empty'); + const templates = await discoverEmailTemplates(tempDir); + + expect(Array.isArray(templates)).toBe(true); + }); +}); diff --git a/packages/novu/src/commands/email/discovery/template-discovery.ts b/packages/novu/src/commands/email/discovery/template-discovery.ts new file mode 100644 index 00000000000..db8c44533dd --- /dev/null +++ b/packages/novu/src/commands/email/discovery/template-discovery.ts @@ -0,0 +1,155 @@ +import fg from 'fast-glob'; +import fs from 'fs/promises'; +import path from 'path'; +import ts from 'typescript'; + +export type DiscoveredTemplate = { + filePath: string; + relativePath: string; +}; + +const DEFAULT_IGNORES = [ + '**/node_modules/**', + '**/.git/**', + '**/.next/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/coverage/**', + '**/.turbo/**', + '**/.vercel/**', + '**/.cache/**', + '**/tmp/**', + '**/*.test.{ts,tsx,js,jsx}', + '**/*.spec.{ts,tsx,js,jsx}', + '**/__tests__/**', + '**/__mocks__/**', + '**/test/**', + '**/tests/**', + '**/*.stories.{ts,tsx,js,jsx}', + '**/*.story.{ts,tsx,js,jsx}', + '**/.storybook/**', + '**/*.config.{ts,js}', + '**/*.d.ts', +]; + +export async function discoverEmailTemplates(rootDir: string = process.cwd()): Promise { + const files = await fg(['**/*.{tsx,jsx,ts,js}'], { + cwd: rootDir, + dot: true, + absolute: false, + ignore: DEFAULT_IGNORES, + followSymbolicLinks: true, + }); + + const out: DiscoveredTemplate[] = []; + const CONCURRENCY = 32; + + for (let i = 0; i < files.length; i += CONCURRENCY) { + const batch = files.slice(i, i + CONCURRENCY); + const batchRes = await Promise.all( + batch.map(async (relativePath) => { + const filePath = path.join(rootDir, relativePath); + const exportNames = await findReactEmailExportsTsAst(filePath); + if (!exportNames.length) return null; + if (!exportNames.includes('default')) return null; + + return { filePath, relativePath } satisfies DiscoveredTemplate; + }) + ); + for (const r of batchRes) if (r) out.push(r); + } + + return out; +} + +async function findReactEmailExportsTsAst(filePath: string): Promise { + let text: string; + try { + text = await fs.readFile(filePath, 'utf8'); + } catch { + return []; + } + + // Quick check to skip files without React Email imports + if (!text.includes('@react-email/') && !text.includes('react-email')) { + return []; + } + + const scriptKind = getScriptKind(filePath); + + const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, /*setParentNodes*/ true, scriptKind); + + let hasReactEmailImport = false; + let hasJsx = false; + + const exports: string[] = []; + + function visit(node: ts.Node) { + // imports + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const s = node.moduleSpecifier.text; + if (s === '@react-email/components' || s.startsWith('@react-email/') || s === 'react-email') { + hasReactEmailImport = true; + } + } + + // JSX usage + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) { + hasJsx = true; + } + + // export default ... + if (ts.isExportAssignment(node) && !node.isExportEquals) { + exports.push('default'); + } + + // export named declarations: export const Foo = ..., export function Foo() ... + if (ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + for (const d of node.declarationList.declarations) { + if (ts.isIdentifier(d.name)) exports.push(d.name.text); + } + } + + if (ts.isFunctionDeclaration(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + const hasDefault = node.modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword); + if (hasDefault) { + exports.push('default'); + } + if (node.name) { + exports.push(node.name.text); + } + } + + // export { Foo, Bar as Baz } + if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const el of node.exportClause.elements) { + exports.push(el.name.text); + } + } + + ts.forEachChild(node, visit); + } + + visit(sf); + + if (!hasReactEmailImport || !hasJsx || exports.length === 0) return []; + return Array.from(new Set(exports)); +} + +function getScriptKind(filePath: string): ts.ScriptKind { + const extension = path.extname(filePath).toLowerCase(); + + switch (extension) { + case '.tsx': + return ts.ScriptKind.TSX; + case '.ts': + return ts.ScriptKind.TS; + case '.jsx': + return ts.ScriptKind.JSX; + case '.js': + return ts.ScriptKind.JS; + default: + return ts.ScriptKind.JS; + } +} diff --git a/packages/novu/src/commands/email/index.ts b/packages/novu/src/commands/email/index.ts new file mode 100644 index 00000000000..7b5db695027 --- /dev/null +++ b/packages/novu/src/commands/email/index.ts @@ -0,0 +1,2 @@ +export { emailInit } from './init'; +export { emailPublish } from './publish'; diff --git a/packages/novu/src/commands/email/init.ts b/packages/novu/src/commands/email/init.ts new file mode 100644 index 00000000000..c2ef119ad62 --- /dev/null +++ b/packages/novu/src/commands/email/init.ts @@ -0,0 +1,350 @@ +import * as fs from 'fs'; +import ora from 'ora'; +import * as path from 'path'; +import { cyan, green, red, yellow } from 'picocolors'; +import prompts from 'prompts'; +import { loadConfig } from './config/loader'; +import type { NovuConfig } from './config/schema'; +import { discoverEmailTemplates } from './discovery'; +import { generateStepFile } from './templates/step-file'; +import { + buildWorkflowsFromSteps, + type ConfiguredStep, + flattenConfigToSteps, + groupStepsByWorkflow, +} from './utils/data-transforms'; +import { isInteractive } from './utils/environment'; +import { StepFilePathResolver } from './utils/file-paths'; +import { renderTable } from './utils/table'; +import { + generateStepIdFromFilename, + generateWorkflowIdFromStepId, + validateStepId, + validateWorkflowId, +} from './utils/validation'; + +interface InitOptions { + config?: string; + force?: boolean; + out?: string; + dryRun?: boolean; +} + +export async function emailInit(options: InitOptions): Promise { + try { + const config = await loadConfig(options.config); + + if (config) { + await runInitWithConfig(config, options); + } else { + if (!isInteractive()) { + console.error(red('❌ No novu.config.ts found')); + console.error(''); + console.error('In CI/non-interactive mode, you must provide a config file.'); + console.error(''); + console.error('Create novu.config.ts with your step definitions:'); + console.error(''); + console.error(cyan(' export default {')); + console.error(cyan(' workflows: {')); + console.error(cyan(" 'onboarding': {")); + console.error(cyan(' steps: {')); + console.error(cyan(' email: {')); + console.error(cyan(" 'welcome-email': {")); + console.error(cyan(" template: 'emails/welcome.tsx',")); + console.error(cyan(' },')); + console.error(cyan(' },')); + console.error(cyan(' },')); + console.error(cyan(' },')); + console.error(cyan(' },')); + console.error(cyan(' };')); + console.error(''); + console.error('Or run this command interactively locally first to generate the config.'); + process.exit(1); + } + + await runInitInteractive(options); + } + } catch (error) { + if (error instanceof Error && error.message.includes('Configuration validation errors:')) { + const parts = error.message.split('\n'); + const configPath = parts[0].replace('Config file: ', ''); + const errorsPart = parts.slice(1).join('\n'); + + console.error(''); + console.error(red('✖ Configuration validation failed')); + console.error(''); + console.error(`Config file: ${cyan(configPath)}`); + console.error(''); + console.error(red('Errors:')); + console.error(errorsPart); + console.error(''); + } else { + console.error(''); + console.error(red('❌ Init failed:'), error instanceof Error ? error.message : error); + console.error(''); + } + process.exit(1); + } +} + +async function runInitWithConfig(config: NovuConfig, options: InitOptions): Promise { + const rootDir = process.cwd(); + const configPath = options.config || 'novu.config.ts'; + + console.log(cyan(`\n🔍 Reading configuration from ${configPath}\n`)); + + const spinner = ora('Validating configuration...').start(); + + const allSteps = flattenConfigToSteps(config); + spinner.text = `Found ${allSteps.length} step definition(s)`; + + const errors: string[] = []; + for (const { workflowId, stepId, emailConfig } of allSteps) { + const templateAbsPath = path.resolve(rootDir, emailConfig.template); + if (!fs.existsSync(templateAbsPath)) { + errors.push(`Template not found: ${emailConfig.template} (workflow: ${workflowId}, step: ${stepId})`); + } + } + + if (errors.length > 0) { + spinner.fail('Validation failed'); + console.error(''); + console.error(red('Errors:')); + for (const error of errors) { + console.error(red(` • ${error}`)); + } + console.error(''); + process.exit(1); + } + + spinner.succeed('Configuration valid'); + console.log(''); + + console.log('Steps to generate:'); + renderTable(allSteps, [ + { header: 'Workflow ID', getValue: (s) => s.workflowId }, + { header: 'Step ID', getValue: (s) => s.stepId }, + { header: 'Template', getValue: (s) => s.emailConfig.template }, + ]); + console.log(''); + + if (options.dryRun) { + console.log(yellow('🔍 Dry run mode - no files will be created')); + console.log(''); + return; + } + + const outDir = options.out || config.outDir || './novu'; + const outDirPath = path.resolve(rootDir, outDir); + const pathResolver = new StepFilePathResolver(rootDir, outDirPath); + + console.log(green(`📁 Generating step handlers in ${outDir}\n`)); + + let createdCount = 0; + let skippedCount = 0; + + for (const { workflowId, stepId, emailConfig } of allSteps) { + const workflowDir = pathResolver.getWorkflowDir(workflowId); + + if (!fs.existsSync(workflowDir)) { + fs.mkdirSync(workflowDir, { recursive: true }); + } + + const stepFilePath = pathResolver.getStepFilePath(workflowId, stepId); + const relativeStepPath = pathResolver.getRelativeStepPath(workflowId, stepId); + + if (fs.existsSync(stepFilePath) && !options.force) { + console.log(yellow(` ⊘ ${relativeStepPath}`) + ' (exists, use --force to overwrite)'); + skippedCount++; + continue; + } + + const templateImportPath = pathResolver.getTemplateImportPath(workflowId, emailConfig.template); + const stepFileContent = generateStepFile(stepId, workflowId, templateImportPath, emailConfig); + + fs.writeFileSync(stepFilePath, stepFileContent, 'utf8'); + + console.log(green(` ✓ ${relativeStepPath}`)); + createdCount++; + } + + console.log(''); + console.log(green(`✅ Generated ${createdCount} step handler(s)`)); + if (skippedCount > 0) { + console.log(yellow(` Skipped ${skippedCount} file(s)`)); + } + + console.log(''); + console.log(cyan('📝 Next steps:')); + console.log(' 1. Review the generated files in ' + outDir); + console.log(' 2. Customize handlers if needed'); + console.log(" 3. Run 'npx novu email publish' to deploy"); + console.log(''); +} + +async function runInitInteractive(options: InitOptions): Promise { + console.log(yellow('\n⚠️ No novu.config.ts found\n')); + console.log("Let's create one! I'll scan for email templates and help you set up.\n"); + + const spinner = ora('Scanning for email templates...').start(); + const templates = await discoverEmailTemplates(); + + if (templates.length === 0) { + spinner.fail('No email templates found'); + console.error(''); + console.error(red('Expected React Email templates in:')); + console.error(' • emails/**/*.tsx'); + console.error(' • emails/**/*.jsx'); + console.error(' • src/emails/**/*.tsx'); + console.error(' • src/emails/**/*.jsx'); + console.error(''); + console.error('Create your email templates first, then run init again.'); + console.error(''); + console.error(cyan('Learn more: https://react.email/docs/introduction')); + console.error(''); + process.exit(1); + } + + spinner.succeed(`Found ${templates.length} template(s):`); + for (const template of templates) { + console.log(` • ${template.relativePath}`); + } + console.log(''); + + console.log(cyan("📝 Let's configure the templates:\n")); + + const configuredSteps: ConfiguredStep[] = []; + const existingStepIdsByWorkflow = new Map>(); + + for (const template of templates) { + console.log(green(`\n📧 ${template.relativePath}`)); + + const includeResponse = await prompts({ + type: 'confirm', + name: 'include', + message: 'Include this template?', + initial: true, + }); + + if (includeResponse.include === undefined) { + console.log(yellow('\n⚠️ Setup cancelled\n')); + process.exit(130); + } + + if (includeResponse.include === false) { + console.log(yellow(' Skipped')); + continue; + } + + const filename = path.basename(template.relativePath); + const suggestedStepId = generateStepIdFromFilename(filename); + const suggestedWorkflowId = generateWorkflowIdFromStepId(suggestedStepId); + + const workflowResponse = await prompts({ + type: 'text', + name: 'workflowId', + message: 'Workflow ID:', + initial: suggestedWorkflowId, + validate: validateWorkflowId, + }); + + if (!workflowResponse.workflowId) { + console.log(yellow('\n⚠️ Setup cancelled\n')); + process.exit(130); + } + + const workflowId = workflowResponse.workflowId; + const existingStepIds = existingStepIdsByWorkflow.get(workflowId) || new Set(); + + const stepResponse = await prompts({ + type: 'text', + name: 'stepId', + message: 'Step ID:', + initial: suggestedStepId, + validate: (value) => validateStepId(value, existingStepIds), + }); + + if (!stepResponse.stepId) { + console.log(yellow('\n⚠️ Setup cancelled\n')); + process.exit(130); + } + + const subjectResponse = await prompts({ + type: 'text', + name: 'subject', + message: 'Default subject (optional):', + initial: '', + }); + + if (!subjectResponse || !('subject' in subjectResponse)) { + console.log(yellow('\n⚠️ Setup cancelled\n')); + process.exit(130); + } + + existingStepIds.add(stepResponse.stepId); + existingStepIdsByWorkflow.set(workflowId, existingStepIds); + + configuredSteps.push({ + workflowId, + stepId: stepResponse.stepId, + config: { + template: template.relativePath, + ...(subjectResponse.subject && { subject: subjectResponse.subject }), + }, + }); + + console.log(''); + } + + if (configuredSteps.length === 0) { + console.log(''); + console.log(yellow('⚠️ No templates selected — aborting initialization')); + console.log(''); + return; + } + + console.log(cyan('💾 Saving configuration to novu.config.ts...')); + + const configContent = generateConfigFile(configuredSteps); + fs.writeFileSync('novu.config.ts', configContent, 'utf8'); + + console.log(green(' ✓ Created novu.config.ts\n')); + + const workflows = buildWorkflowsFromSteps(configuredSteps); + const config: NovuConfig = { workflows }; + + await runInitWithConfig(config, options); +} + +function escapeString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function generateConfigFile(steps: ConfiguredStep[]): string { + const workflowsMap = groupStepsByWorkflow(steps); + + let content = 'export default {\n workflows: {\n'; + + for (const [workflowId, workflowSteps] of workflowsMap) { + content += ` '${escapeString(workflowId)}': {\n`; + content += ` steps: {\n`; + content += ` email: {\n`; + + for (const { stepId, config } of workflowSteps) { + content += ` '${escapeString(stepId)}': {\n`; + content += ` template: '${escapeString(config.template)}',\n`; + if (config.subject) { + content += ` subject: '${escapeString(config.subject)}',\n`; + } + content += ` },\n`; + } + + content += ` },\n`; + content += ` },\n`; + content += ` },\n`; + } + + content += ' },\n};\n'; + + return content; +} diff --git a/packages/novu/src/commands/email/publish.ts b/packages/novu/src/commands/email/publish.ts new file mode 100644 index 00000000000..75512a7bc59 --- /dev/null +++ b/packages/novu/src/commands/email/publish.ts @@ -0,0 +1,407 @@ +import * as fs from 'fs/promises'; +import ora from 'ora'; +import * as path from 'path'; +import { green, red, yellow } from 'picocolors'; +import { StepResolverClient } from './api'; +import { bundleRelease, formatBundleSize } from './bundler'; +import { loadConfig } from './config/loader'; +import { discoverStepFiles } from './discovery'; +import type { + DeploymentResult, + DiscoveredStep, + EnvironmentInfo, + StepResolverManifestStep, + StepResolverReleaseBundle, +} from './types'; +import { renderTable, withSpinner } from './utils'; + +interface PublishOptions { + secretKey?: string; + apiUrl?: string; + config?: string; + out?: string; + workflow?: string[] | string; + bundleOutDir?: string | boolean; + dryRun?: boolean; +} + +const DEFAULT_API_URL = 'https://api.novu.co'; +const DEFAULT_STEPS_DIR = './novu'; +const RELEASE_ARTIFACT_BASENAME = 'step-resolver-release'; + +export async function emailPublish(options: PublishOptions): Promise { + try { + const rootDir = process.cwd(); + const config = await loadConfig(options.config); + const apiUrl = options.apiUrl || process.env.NOVU_API_URL || config?.apiUrl || DEFAULT_API_URL; + const secretKey = options.secretKey || process.env.NOVU_SECRET_KEY; + assertSecretKey(secretKey); + + const stepsDirLabel = options.out || config?.outDir || DEFAULT_STEPS_DIR; + const stepsDir = path.resolve(rootDir, stepsDirLabel); + console.log(''); + const client = new StepResolverClient(apiUrl, secretKey); + await authenticate(client, apiUrl); + + const discoveredSteps = await discoverAndValidateSteps(stepsDir, stepsDirLabel); + const selectedSteps = selectStepsByWorkflow(discoveredSteps, options.workflow); + printDiscoveredSteps(selectedSteps, discoveredSteps.length, options.workflow); + + const shouldMinifyBundles = !options.bundleOutDir; + if (!shouldMinifyBundles) { + console.log(yellow('ℹ Debug bundle mode enabled: generating unminified release bundle.')); + console.log(''); + } + + const releaseBundle = await buildReleaseBundle(selectedSteps, rootDir, shouldMinifyBundles, config?.aliases); + const manifestSteps = selectedSteps.map((step) => ({ + workflowId: step.workflowId, + stepId: step.stepId, + })); + + const bundleOutputDir = resolveBundleOutputDir(options.bundleOutDir, rootDir); + if (bundleOutputDir) { + await writeBundleArtifactsWithSpinner(releaseBundle, manifestSteps, bundleOutputDir, rootDir); + } + + if (options.dryRun) { + printDryRunSummary(releaseBundle, selectedSteps, manifestSteps); + return; + } + + const deployment = await deployRelease(client, releaseBundle, manifestSteps); + printSuccessSummary(deployment, selectedSteps); + } catch (error) { + console.error(''); + console.error(red('❌ Publish failed:'), error instanceof Error ? error.message : error); + console.error(''); + process.exit(1); + } +} + +function assertSecretKey(secretKey?: string): asserts secretKey is string { + if (secretKey) { + return; + } + + console.error(''); + console.error(red('❌ Authentication required')); + console.error(''); + console.error('Provide your API key via:'); + console.error(' 1. CLI flag: npx novu email publish --secret-key nv-xxx'); + console.error(' 2. Environment: export NOVU_SECRET_KEY=nv-xxx'); + console.error(' 3. .env file: NOVU_SECRET_KEY=nv-xxx'); + console.error(''); + console.error('Get your API key at: https://dashboard.novu.co/api-keys'); + console.error(''); + process.exit(1); +} + +async function authenticate(client: StepResolverClient, apiUrl: string): Promise { + const envInfo = await withSpinner( + 'Authenticating with Novu...', + async () => { + try { + await client.validateConnection(); + const envInfo = await client.getEnvironmentInfo(); + return envInfo; + } catch (error) { + console.error(`Using API URL: ${apiUrl}`); + console.error('(For EU region, use: --api-url https://eu.api.novu.co)'); + console.error(''); + throw error; + } + }, + { successMessage: 'Authenticated with Novu', failMessage: 'Authentication failed' } + ); + + console.log(` ${green('✓')} Environment: ${envInfo.name} (${envInfo._id})`); + console.log(''); + return envInfo; +} + +async function discoverAndValidateSteps(stepsDir: string, stepsDirLabel: string): Promise { + return withSpinner( + `Discovering steps in ${stepsDirLabel}...`, + async () => { + const discovery = await discoverStepFiles(stepsDir); + + if (discovery.matchedFiles === 0) { + console.error(''); + console.error(red(`❌ No step files found in ${stepsDir}`)); + console.error(''); + console.error('Expected *.step.tsx, *.step.ts, *.step.jsx, or *.step.js files.'); + console.error(''); + console.error("Run 'npx novu email init' first to generate step handlers."); + console.error(''); + throw new Error('No step files found'); + } + + if (!discovery.valid) { + console.error(''); + console.error(red('❌ Step file validation failed')); + console.error(''); + + for (const fileError of discovery.errors) { + console.error(red(`Errors in ${fileError.filePath}:`)); + for (const error of fileError.errors) { + console.error(red(` • ${error}`)); + } + console.error(''); + } + + console.error("Fix these errors and run 'npx novu email init --force' to regenerate step files."); + console.error(''); + throw new Error('Step file validation failed'); + } + + return discovery.steps; + }, + { successMessage: 'Discovered step files', failMessage: 'Discovery failed' } + ); +} + +function selectStepsByWorkflow( + discoveredSteps: DiscoveredStep[], + requestedWorkflowOption?: string[] | string +): DiscoveredStep[] { + const requestedWorkflows = normalizeRequestedWorkflows(requestedWorkflowOption); + if (requestedWorkflows.length === 0) { + return discoveredSteps; + } + + const requestedSet = new Set(requestedWorkflows); + const selectedSteps = discoveredSteps.filter((step) => requestedSet.has(step.workflowId)); + const missingWorkflows = requestedWorkflows.filter( + (workflowId) => !selectedSteps.some((step) => step.workflowId === workflowId) + ); + + if (missingWorkflows.length > 0) { + console.error(red(`❌ Step(s) not found for workflow(s): ${missingWorkflows.join(', ')}`)); + console.error(''); + console.error('Available workflows:'); + const availableWorkflows = Array.from(new Set(discoveredSteps.map((step) => step.workflowId))).sort(); + for (const workflow of availableWorkflows) { + console.error(` • ${workflow}`); + } + console.error(''); + process.exit(1); + } + + return selectedSteps; +} + +function normalizeRequestedWorkflows(requestedWorkflowOption?: string[] | string): string[] { + if (!requestedWorkflowOption) { + return []; + } + + if (Array.isArray(requestedWorkflowOption)) { + return requestedWorkflowOption; + } + + return [requestedWorkflowOption]; +} + +function printDiscoveredSteps( + steps: DiscoveredStep[], + totalDiscoveredSteps: number, + selectedWorkflowOption?: string[] | string +) { + for (const step of steps) { + console.log(` ${green('✓')} ${step.stepId} (workflow: ${step.workflowId})`); + } + + const workflowCount = new Set(steps.map((step) => step.workflowId)).size; + const requestedWorkflows = normalizeRequestedWorkflows(selectedWorkflowOption); + + console.log(''); + if (requestedWorkflows.length > 0) { + console.log( + ` Found ${steps.length} step(s) across ${workflowCount} workflow(s) (filtered from ${totalDiscoveredSteps} total step(s))` + ); + } else { + console.log(` Found ${steps.length} step(s) across ${workflowCount} workflow(s)`); + } + console.log(''); +} + +async function buildReleaseBundle( + selectedSteps: DiscoveredStep[], + rootDir: string, + minify: boolean, + aliases?: Record +): Promise { + const bundle = await withSpinner( + 'Packaging steps...', + async () => { + return bundleRelease(selectedSteps, rootDir, { minify, aliases }); + }, + { successMessage: 'Packaged successfully', failMessage: 'Packaging failed' } + ); + + const workflowCount = new Set(selectedSteps.map((step) => step.workflowId)).size; + console.log( + ` ${green('✓')} ${selectedSteps.length} step(s), ${workflowCount} workflow(s), ${formatBundleSize(bundle.size)}` + ); + console.log(''); + return bundle; +} + +async function deployRelease( + client: StepResolverClient, + releaseBundle: StepResolverReleaseBundle, + manifestSteps: StepResolverManifestStep[] +): Promise { + const deploySpinner = ora('Publishing...').start(); + + try { + const result = await client.deployRelease(releaseBundle, manifestSteps); + deploySpinner.stop(); + + return result; + } catch (error) { + deploySpinner.fail('Publishing failed'); + throw error; + } +} + +function printDryRunSummary( + bundle: StepResolverReleaseBundle, + selectedSteps: DiscoveredStep[], + manifestSteps: StepResolverManifestStep[] +): void { + const workflowCount = new Set(selectedSteps.map((step) => step.workflowId)).size; + + console.log(yellow('🔍 Dry run mode - skipping deployment')); + console.log(''); + console.log('Package summary:'); + console.log(` • Size: ${formatBundleSize(bundle.size)}`); + console.log(` • Steps: ${selectedSteps.length}`); + console.log(` • Workflows: ${workflowCount}`); + console.log(''); + console.log('Included steps:'); + for (const step of manifestSteps) { + console.log(` • ${step.stepId} (workflow: ${step.workflowId})`); + } + console.log(''); + console.log(green('✅ Ready to publish!')); + console.log(''); +} + +function printSuccessSummary(deployment: DeploymentResult, steps: DiscoveredStep[]): void { + console.log(green('✅ Published successfully!')); + console.log(''); + + const workflowCount = new Set(steps.map((step) => step.workflowId)).size; + const stepText = deployment.selectedStepsCount === 1 ? 'step' : 'steps'; + const workflowText = workflowCount === 1 ? 'workflow' : 'workflows'; + console.log( + ` ${deployment.selectedStepsCount} ${stepText} across ${workflowCount} ${workflowText} ${deployment.selectedStepsCount === 1 ? 'is' : 'are'} now live` + ); + console.log(''); + console.log(` Version: ${deployment.stepResolverHash}`); + console.log(` Published: ${formatDeploymentTime(deployment.deployedAt)}`); + console.log(''); + + renderTable( + steps, + [ + { header: 'Step', getValue: (s) => s.stepId }, + { header: 'Workflow', getValue: (s) => s.workflowId }, + { header: 'Status', getValue: () => green('Live') }, + ], + ' ' + ); + console.log(''); +} + +function formatDeploymentTime(isoString: string): string { + const date = new Date(isoString); + const month = date.toLocaleString('en-US', { month: 'short' }); + const day = date.getDate(); + const year = date.getFullYear(); + const time = date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + return `${month} ${day}, ${year} at ${time}`; +} + +interface ReleaseArtifactFiles { + bundlePath: string; + manifestPath: string; + metadataPath: string; +} + +async function writeBundleArtifactsWithSpinner( + bundle: StepResolverReleaseBundle, + manifestSteps: StepResolverManifestStep[], + outputDir: string, + rootDir: string +): Promise { + const outputDirLabel = path.relative(rootDir, outputDir) || '.'; + + return withSpinner( + `Writing bundle artifacts to ${outputDirLabel}...`, + async () => { + const artifacts = await writeBundleArtifacts(bundle, manifestSteps, outputDir); + + console.log(` ${green('✓')} ${path.relative(rootDir, artifacts.bundlePath)}`); + console.log(` ${green('✓')} ${path.relative(rootDir, artifacts.manifestPath)}`); + console.log(` ${green('✓')} ${path.relative(rootDir, artifacts.metadataPath)}`); + console.log(''); + }, + { successMessage: `Saved bundle artifacts to ${outputDirLabel}`, failMessage: 'Failed to write bundle artifacts' } + ); +} + +async function writeBundleArtifacts( + bundle: StepResolverReleaseBundle, + manifestSteps: StepResolverManifestStep[], + outputDir: string +): Promise { + await fs.mkdir(outputDir, { recursive: true }); + + const bundlePath = path.join(outputDir, `${RELEASE_ARTIFACT_BASENAME}.worker.mjs`); + const manifestPath = path.join(outputDir, `${RELEASE_ARTIFACT_BASENAME}.manifest.json`); + const metadataPath = path.join(outputDir, `${RELEASE_ARTIFACT_BASENAME}.meta.json`); + const workflowIds = Array.from(new Set(manifestSteps.map((step) => step.workflowId))).sort((a, b) => + a.localeCompare(b) + ); + const stepIds = manifestSteps.map((step) => step.stepId); + + await fs.writeFile(bundlePath, bundle.code, 'utf8'); + await fs.writeFile(manifestPath, `${JSON.stringify({ steps: manifestSteps }, null, 2)}\n`, 'utf8'); + await fs.writeFile( + metadataPath, + `${JSON.stringify( + { + releaseId: RELEASE_ARTIFACT_BASENAME, + size: bundle.size, + workflowIds, + stepIds, + createdAt: new Date().toISOString(), + }, + null, + 2 + )}\n`, + 'utf8' + ); + + return { + bundlePath, + manifestPath, + metadataPath, + }; +} + +function resolveBundleOutputDir(bundleOutDir: PublishOptions['bundleOutDir'], rootDir: string): string | undefined { + if (!bundleOutDir) { + return undefined; + } + + if (bundleOutDir === true) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return path.resolve(rootDir, '.novu', 'bundles', timestamp); + } + + return path.resolve(rootDir, bundleOutDir); +} diff --git a/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap b/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap new file mode 100644 index 00000000000..e113d47990d --- /dev/null +++ b/packages/novu/src/commands/email/templates/__snapshots__/step-file.spec.ts.snap @@ -0,0 +1,97 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateStepFile > should match snapshot 1`] = ` +"import { render } from '@react-email/components'; +import EmailTemplate from '../emails/welcome'; + +export const stepId = 'welcome-email'; +export const workflowId = 'onboarding'; +export const type = 'email'; + +export default async function({ payload, subscriber, context, steps }) { + return { + subject: payload.subject || 'No Subject', + body: await render( + + ), + }; +} +" +`; + +exports[`generateStepFile > should match snapshot with different import paths > nested-import 1`] = ` +"import { render } from '@react-email/components'; +import EmailTemplate from '../../src/emails/welcome'; + +export const stepId = 'welcome-email'; +export const workflowId = 'onboarding'; +export const type = 'email'; + +export default async function({ payload, subscriber, context, steps }) { + return { + subject: payload.subject || 'No Subject', + body: await render( + + ), + }; +} +" +`; + +exports[`generateStepFile > should match snapshot with different import paths > relative-import 1`] = ` +"import { render } from '@react-email/components'; +import EmailTemplate from './emails/welcome'; + +export const stepId = 'welcome-email'; +export const workflowId = 'onboarding'; +export const type = 'email'; + +export default async function({ payload, subscriber, context, steps }) { + return { + subject: payload.subject || 'No Subject', + body: await render( + + ), + }; +} +" +`; + +exports[`generateStepFile > should match snapshot with subject > with-subject 1`] = ` +"import { render } from '@react-email/components'; +import EmailTemplate from '../emails/welcome'; + +export const stepId = 'welcome-email'; +export const workflowId = 'onboarding'; +export const type = 'email'; + +export default async function({ payload, subscriber, context, steps }) { + return { + subject: payload.subject || 'Welcome to Acme!', + body: await render( + + ), + }; +} +" +`; diff --git a/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap b/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap new file mode 100644 index 00000000000..c8b37791dac --- /dev/null +++ b/packages/novu/src/commands/email/templates/__snapshots__/worker-wrapper.spec.ts.snap @@ -0,0 +1,258 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateWorkerWrapper > should handle empty steps array > empty-steps 1`] = ` +" + +const stepHandlers = new Map([ + +]); + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonResponse(body, status, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { ...JSON_HEADERS, ...extraHeaders }, + }); +} + +export default { + async fetch(request) { + try { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' }); + } + + const url = new URL(request.url); + const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name'); + + if (!stepName) { + return jsonResponse( + { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' }, + 400 + ); + } + + const step = stepHandlers.get(stepName); + if (!step) { + return jsonResponse( + { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) }, + 404 + ); + } + + let body = {}; + const rawBody = await request.text(); + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, 400); + } + } + + if (!isObject(body)) { + return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400); + } + + const payload = body.payload ?? {}; + const subscriber = body.subscriber ?? {}; + const context = body.context ?? {}; + const stepOutputs = body.steps ?? {}; + + if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) { + return jsonResponse( + { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' }, + 400 + ); + } + + const result = await step.handler({ payload, subscriber, context, steps: stepOutputs }); + + return jsonResponse( + { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body }, + 200 + ); + } catch (error) { + console.error('Error executing step handler:', error); + return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500); + } + }, +};" +`; + +exports[`generateWorkerWrapper > should handle single step > single-step 1`] = ` +"import stepHandler0, { stepId as stepId0, workflowId as workflowId0 } from "./novu/welcome-email.step"; + +const stepHandlers = new Map([ + ["welcome-email", { handler: stepHandler0, stepId: stepId0, workflowId: workflowId0 }] +]); + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonResponse(body, status, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { ...JSON_HEADERS, ...extraHeaders }, + }); +} + +export default { + async fetch(request) { + try { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' }); + } + + const url = new URL(request.url); + const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name'); + + if (!stepName) { + return jsonResponse( + { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' }, + 400 + ); + } + + const step = stepHandlers.get(stepName); + if (!step) { + return jsonResponse( + { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) }, + 404 + ); + } + + let body = {}; + const rawBody = await request.text(); + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, 400); + } + } + + if (!isObject(body)) { + return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400); + } + + const payload = body.payload ?? {}; + const subscriber = body.subscriber ?? {}; + const context = body.context ?? {}; + const stepOutputs = body.steps ?? {}; + + if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) { + return jsonResponse( + { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' }, + 400 + ); + } + + const result = await step.handler({ payload, subscriber, context, steps: stepOutputs }); + + return jsonResponse( + { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body }, + 200 + ); + } catch (error) { + console.error('Error executing step handler:', error); + return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500); + } + }, +};" +`; + +exports[`generateWorkerWrapper > should match snapshot 1`] = ` +"import stepHandler0, { stepId as stepId0, workflowId as workflowId0 } from "./novu/welcome-email.step"; +import stepHandler1, { stepId as stepId1, workflowId as workflowId1 } from "./novu/verify-email.step"; + +const stepHandlers = new Map([ + ["welcome-email", { handler: stepHandler0, stepId: stepId0, workflowId: workflowId0 }], + ["verify-email", { handler: stepHandler1, stepId: stepId1, workflowId: workflowId1 }] +]); + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonResponse(body, status, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { ...JSON_HEADERS, ...extraHeaders }, + }); +} + +export default { + async fetch(request) { + try { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' }); + } + + const url = new URL(request.url); + const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name'); + + if (!stepName) { + return jsonResponse( + { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' }, + 400 + ); + } + + const step = stepHandlers.get(stepName); + if (!step) { + return jsonResponse( + { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) }, + 404 + ); + } + + let body = {}; + const rawBody = await request.text(); + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, 400); + } + } + + if (!isObject(body)) { + return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400); + } + + const payload = body.payload ?? {}; + const subscriber = body.subscriber ?? {}; + const context = body.context ?? {}; + const stepOutputs = body.steps ?? {}; + + if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) { + return jsonResponse( + { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' }, + 400 + ); + } + + const result = await step.handler({ payload, subscriber, context, steps: stepOutputs }); + + return jsonResponse( + { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body }, + 200 + ); + } catch (error) { + console.error('Error executing step handler:', error); + return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500); + } + }, +};" +`; diff --git a/packages/novu/src/commands/email/templates/index.ts b/packages/novu/src/commands/email/templates/index.ts new file mode 100644 index 00000000000..8363b974b3d --- /dev/null +++ b/packages/novu/src/commands/email/templates/index.ts @@ -0,0 +1 @@ +export { generateStepFile } from './step-file'; diff --git a/packages/novu/src/commands/email/templates/step-file.spec.ts b/packages/novu/src/commands/email/templates/step-file.spec.ts new file mode 100644 index 00000000000..baeb3d40827 --- /dev/null +++ b/packages/novu/src/commands/email/templates/step-file.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import type { EmailStepConfig } from '../config/schema'; +import { generateStepFile } from './step-file'; + +describe('generateStepFile', () => { + const stepId = 'welcome-email'; + const workflowId = 'onboarding'; + const baseConfig: EmailStepConfig = { + template: 'emails/welcome.tsx', + }; + + it('should match snapshot', () => { + const result = generateStepFile(stepId, workflowId, '../emails/welcome', baseConfig); + expect(result).toMatchSnapshot(); + }); + + it('should match snapshot with subject', () => { + const config: EmailStepConfig = { + ...baseConfig, + subject: 'Welcome to Acme!', + }; + const result = generateStepFile(stepId, workflowId, '../emails/welcome', config); + expect(result).toMatchSnapshot('with-subject'); + }); + + it('should match snapshot with different import paths', () => { + const result1 = generateStepFile(stepId, workflowId, './emails/welcome', baseConfig); + expect(result1).toMatchSnapshot('relative-import'); + + const result2 = generateStepFile(stepId, workflowId, '../../src/emails/welcome', baseConfig); + expect(result2).toMatchSnapshot('nested-import'); + }); +}); diff --git a/packages/novu/src/commands/email/templates/step-file.ts b/packages/novu/src/commands/email/templates/step-file.ts new file mode 100644 index 00000000000..c50b6ba28ef --- /dev/null +++ b/packages/novu/src/commands/email/templates/step-file.ts @@ -0,0 +1,36 @@ +import type { EmailStepConfig } from '../config/schema'; + +function escapeString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +export function generateStepFile( + stepId: string, + workflowId: string, + templateImportPath: string, + emailConfig: EmailStepConfig +): string { + const defaultSubject = emailConfig.subject || 'No Subject'; + + return `import { render } from '@react-email/components'; +import EmailTemplate from '${escapeString(templateImportPath)}'; + +export const stepId = '${escapeString(stepId)}'; +export const workflowId = '${escapeString(workflowId)}'; +export const type = 'email'; + +export default async function({ payload, subscriber, context, steps }) { + return { + subject: payload.subject || '${escapeString(defaultSubject)}', + body: await render( + + ), + }; +} +`; +} diff --git a/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts b/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts new file mode 100644 index 00000000000..3df077a0a84 --- /dev/null +++ b/packages/novu/src/commands/email/templates/worker-wrapper.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { DiscoveredStep } from '../types'; +import { generateWorkerWrapper } from './worker-wrapper'; + +describe('generateWorkerWrapper', () => { + const mockSteps: DiscoveredStep[] = [ + { + stepId: 'welcome-email', + workflowId: 'onboarding', + type: 'email', + filePath: '/root/novu/welcome-email.step.tsx', + relativePath: 'welcome-email.step.tsx', + }, + { + stepId: 'verify-email', + workflowId: 'onboarding', + type: 'email', + filePath: '/root/novu/verify-email.step.tsx', + relativePath: 'verify-email.step.tsx', + }, + ]; + + it('should match snapshot', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + expect(result).toMatchSnapshot(); + }); + + it('should handle empty steps array', () => { + const result = generateWorkerWrapper([], '/root'); + expect(result).toMatchSnapshot('empty-steps'); + }); + + it('should handle single step', () => { + const result = generateWorkerWrapper([mockSteps[0]], '/root'); + expect(result).toMatchSnapshot('single-step'); + }); + + it('should keep workflow IDs in step entries', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('workflowId: workflowId0'); + expect(result).toContain('workflowId: workflowId1'); + }); + + it('should generate map-based dispatch and invalid JSON handling', () => { + const result = generateWorkerWrapper(mockSteps, '/root'); + + expect(result).toContain('const stepHandlers = new Map(['); + expect(result).toContain('function jsonResponse(body, status, extraHeaders = {})'); + expect(result).toContain("Allow: 'POST'"); + expect(result).toContain("error: 'Invalid JSON body'"); + expect(result).toContain("message: 'Internal server error'"); + }); +}); diff --git a/packages/novu/src/commands/email/templates/worker-wrapper.ts b/packages/novu/src/commands/email/templates/worker-wrapper.ts new file mode 100644 index 00000000000..c677b7d858b --- /dev/null +++ b/packages/novu/src/commands/email/templates/worker-wrapper.ts @@ -0,0 +1,133 @@ +import * as path from 'path'; +import type { DiscoveredStep } from '../types'; + +export function generateWorkerWrapper(steps: DiscoveredStep[], rootDir: string): string { + return [ + generateImports(steps, rootDir), + generateStepHandlersMap(steps), + generateWorkerUtilities(), + generateFetchHandler(), + ].join('\n\n'); +} + +function generateImports(steps: DiscoveredStep[], rootDir: string): string { + return steps + .map( + (s, i) => + `import stepHandler${i}, { stepId as stepId${i}, workflowId as workflowId${i} } from ${JSON.stringify(getImportPath(s.filePath, rootDir))};` + ) + .join('\n'); +} + +function generateStepHandlersMap(steps: DiscoveredStep[]): string { + const entries = steps + .map( + (s, i) => + ` [${JSON.stringify(s.stepId)}, { handler: stepHandler${i}, stepId: stepId${i}, workflowId: workflowId${i} }]` + ) + .join(',\n'); + + return `const stepHandlers = new Map([\n${entries}\n]);`; +} + +function generateWorkerUtilities(): string { + return `const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function jsonResponse(body, status, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { ...JSON_HEADERS, ...extraHeaders }, + }); +}`; +} + +function generateFetchHandler(): string { + return `export default { + async fetch(request) { + try { + ${generateRequestHandler()} + } catch (error) { + console.error('Error executing step handler:', error); + return jsonResponse({ error: 'Step execution failed', message: 'Internal server error' }, 500); + } + }, +};`; +} + +function generateRequestHandler(): string { + return `if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, 405, { Allow: 'POST' }); + } + + const url = new URL(request.url); + const stepName = url.searchParams.get('step') || request.headers.get('X-Step-Name'); + + if (!stepName) { + return jsonResponse( + { error: 'Missing step name', message: 'Provide step name via ?step= query param or X-Step-Name header' }, + 400 + ); + } + + const step = stepHandlers.get(stepName); + if (!step) { + return jsonResponse( + { error: 'Step not found', stepName, available: Array.from(stepHandlers.keys()) }, + 404 + ); + } + + ${generateBodyValidation()} + + const result = await step.handler({ payload, subscriber, context, steps: stepOutputs }); + + return jsonResponse( + { stepId: step.stepId, workflowId: step.workflowId, subject: result.subject, body: result.body }, + 200 + );`; +} + +function generateBodyValidation(): string { + return `let body = {}; + const rawBody = await request.text(); + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, 400); + } + } + + if (!isObject(body)) { + return jsonResponse({ error: 'Invalid request body', message: 'Body must be a JSON object' }, 400); + } + + const payload = body.payload ?? {}; + const subscriber = body.subscriber ?? {}; + const context = body.context ?? {}; + const stepOutputs = body.steps ?? {}; + + if (!isObject(payload) || !isObject(subscriber) || !isObject(context) || !isObject(stepOutputs)) { + return jsonResponse( + { error: 'Invalid request body', message: 'payload, subscriber, context, and steps must be JSON objects' }, + 400 + ); + }`; +} + +function getImportPath(filePath: string, rootDir: string): string { + // Use rootDir-relative imports so esbuild can resolve local step handlers. + const withoutExt = filePath.replace(/\.(ts|tsx|js|jsx)$/, ''); + const normalizedRootDir = path.resolve(rootDir); + const relativeImportPath = path.relative(normalizedRootDir, withoutExt).split(path.sep).join('/'); + + if (relativeImportPath.startsWith('.') || relativeImportPath.startsWith('/')) { + return relativeImportPath; + } + + return `./${relativeImportPath}`; +} diff --git a/packages/novu/src/commands/email/types.ts b/packages/novu/src/commands/email/types.ts new file mode 100644 index 00000000000..7a4cc825f16 --- /dev/null +++ b/packages/novu/src/commands/email/types.ts @@ -0,0 +1,42 @@ +export interface DiscoveredStep { + stepId: string; + workflowId: string; + type: string; + filePath: string; + relativePath: string; +} + +export interface ValidationError { + filePath: string; + errors: string[]; +} + +export interface StepDiscoveryResult { + valid: boolean; + matchedFiles: number; + steps: DiscoveredStep[]; + errors: ValidationError[]; +} + +export interface StepResolverReleaseBundle { + code: string; + size: number; +} + +export interface StepResolverManifestStep { + workflowId: string; + stepId: string; +} + +export interface DeploymentResult { + stepResolverHash: string; + workerId: string; + selectedStepsCount: number; + deployedAt: string; +} + +export interface EnvironmentInfo { + _id: string; + name: string; + _organizationId: string; +} diff --git a/packages/novu/src/commands/email/utils/data-transforms.ts b/packages/novu/src/commands/email/utils/data-transforms.ts new file mode 100644 index 00000000000..200435071df --- /dev/null +++ b/packages/novu/src/commands/email/utils/data-transforms.ts @@ -0,0 +1,53 @@ +import type { EmailStepConfig, NovuConfig } from '../config/schema'; + +export interface StepWithMetadata { + workflowId: string; + stepId: string; + emailConfig: EmailStepConfig; +} + +export interface ConfiguredStep { + workflowId: string; + stepId: string; + config: EmailStepConfig; +} + +export function flattenConfigToSteps(config: NovuConfig): StepWithMetadata[] { + const steps: StepWithMetadata[] = []; + + for (const [workflowId, workflow] of Object.entries(config.workflows)) { + for (const [stepId, emailConfig] of Object.entries(workflow.steps.email)) { + steps.push({ workflowId, stepId, emailConfig }); + } + } + + return steps; +} + +export function groupStepsByWorkflow(steps: T[]): Map { + const grouped = new Map(); + + for (const step of steps) { + const existing = grouped.get(step.workflowId); + if (!existing) { + grouped.set(step.workflowId, [step]); + } else { + existing.push(step); + } + } + + return grouped; +} + +export function buildWorkflowsFromSteps(steps: ConfiguredStep[]): NovuConfig['workflows'] { + const workflows: NovuConfig['workflows'] = {}; + + for (const { workflowId, stepId, config } of steps) { + if (!workflows[workflowId]) { + workflows[workflowId] = { steps: { email: {} } }; + } + workflows[workflowId].steps.email[stepId] = config; + } + + return workflows; +} diff --git a/packages/novu/src/commands/email/utils/environment.ts b/packages/novu/src/commands/email/utils/environment.ts new file mode 100644 index 00000000000..ce38ccfeb9a --- /dev/null +++ b/packages/novu/src/commands/email/utils/environment.ts @@ -0,0 +1,17 @@ +export function isCI(): boolean { + return !!( + process.env.CI || + process.env.CONTINUOUS_INTEGRATION || + process.env.GITHUB_ACTIONS || + process.env.GITLAB_CI || + process.env.CIRCLECI || + process.env.TRAVIS || + process.env.JENKINS_URL || + process.env.BUILDKITE || + process.env.DRONE + ); +} + +export function isInteractive(): boolean { + return !isCI() && process.stdin.isTTY === true && process.stdout.isTTY === true; +} diff --git a/packages/novu/src/commands/email/utils/file-paths.ts b/packages/novu/src/commands/email/utils/file-paths.ts new file mode 100644 index 00000000000..fe7c0cea89f --- /dev/null +++ b/packages/novu/src/commands/email/utils/file-paths.ts @@ -0,0 +1,30 @@ +import * as path from 'path'; + +export class StepFilePathResolver { + constructor( + private readonly rootDir: string, + private readonly outDirPath: string + ) {} + + getWorkflowDir(workflowId: string): string { + return path.join(this.outDirPath, workflowId); + } + + getStepFilePath(workflowId: string, stepId: string): string { + return path.join(this.getWorkflowDir(workflowId), `${stepId}.step.tsx`); + } + + getRelativeStepPath(workflowId: string, stepId: string): string { + return path.relative(this.outDirPath, this.getStepFilePath(workflowId, stepId)); + } + + getTemplateImportPath(workflowId: string, templatePath: string): string { + const workflowDir = this.getWorkflowDir(workflowId); + const templateAbsPath = path.resolve(this.rootDir, templatePath); + const relativeImportPath = path.relative(workflowDir, templateAbsPath); + + const importPath = relativeImportPath.replace(/\\/g, '/').replace(/\.(tsx?|jsx?)$/, ''); + + return importPath.startsWith('.') ? importPath : `./${importPath}`; + } +} diff --git a/packages/novu/src/commands/email/utils/index.ts b/packages/novu/src/commands/email/utils/index.ts new file mode 100644 index 00000000000..e24bdc8e3bd --- /dev/null +++ b/packages/novu/src/commands/email/utils/index.ts @@ -0,0 +1,17 @@ +export { + buildWorkflowsFromSteps, + type ConfiguredStep, + flattenConfigToSteps, + groupStepsByWorkflow, + type StepWithMetadata, +} from './data-transforms'; +export { isCI, isInteractive } from './environment'; +export { StepFilePathResolver } from './file-paths'; +export { withSpinner } from './spinner'; +export { renderTable } from './table'; +export { + generateStepIdFromFilename, + generateWorkflowIdFromStepId, + validateStepId, + validateWorkflowId, +} from './validation'; diff --git a/packages/novu/src/commands/email/utils/spinner.ts b/packages/novu/src/commands/email/utils/spinner.ts new file mode 100644 index 00000000000..d7dbec1530a --- /dev/null +++ b/packages/novu/src/commands/email/utils/spinner.ts @@ -0,0 +1,32 @@ +import ora from 'ora'; +import { red } from 'picocolors'; + +export async function withSpinner( + message: string, + fn: () => Promise, + options?: { + successMessage?: string; + failMessage?: string; + exitOnError?: boolean; + } +): Promise { + const spinner = ora(message).start(); + + try { + const result = await fn(); + spinner.succeed(options?.successMessage || message); + return result; + } catch (error) { + spinner.fail(options?.failMessage); + console.error(''); + if (error instanceof Error) { + console.error(red(error.message)); + } + console.error(''); + + if (options?.exitOnError !== false) { + process.exit(1); + } + throw error; + } +} diff --git a/packages/novu/src/commands/email/utils/table.ts b/packages/novu/src/commands/email/utils/table.ts new file mode 100644 index 00000000000..ecf60de1afd --- /dev/null +++ b/packages/novu/src/commands/email/utils/table.ts @@ -0,0 +1,46 @@ +type TableColumn = { + header: string; + getValue: (item: T) => string; +}; + +function stripAnsi(str: string): string { + return str.replace(/\u001b\[\d+m/g, ''); +} + +export function renderTable(items: T[], columns: TableColumn[], indent = ''): void { + if (items.length === 0) { + return; + } + + const widths = columns.map( + (col) => Math.max(col.header.length, ...items.map((item) => stripAnsi(col.getValue(item)).length)) + 2 + ); + + const topBorder = '┌' + widths.map((w) => '─'.repeat(w)).join('┬') + '┐'; + const middleBorder = '├' + widths.map((w) => '─'.repeat(w)).join('┼') + '┤'; + const bottomBorder = '└' + widths.map((w) => '─'.repeat(w)).join('┴') + '┘'; + + console.log(indent + topBorder); + + const headerRow = '│ ' + columns.map((col, i) => col.header.padEnd(widths[i] - 1)).join('│ ') + '│'; + console.log(indent + headerRow); + + console.log(indent + middleBorder); + + for (const item of items) { + const dataRow = + '│ ' + + columns + .map((col, i) => { + const value = col.getValue(item); + const strippedValue = stripAnsi(value); + const padding = widths[i] - 1 - strippedValue.length; + return value + ' '.repeat(Math.max(0, padding)); + }) + .join('│ ') + + '│'; + console.log(indent + dataRow); + } + + console.log(indent + bottomBorder); +} diff --git a/packages/novu/src/commands/email/utils/validation.ts b/packages/novu/src/commands/email/utils/validation.ts new file mode 100644 index 00000000000..1f08b8ebe4d --- /dev/null +++ b/packages/novu/src/commands/email/utils/validation.ts @@ -0,0 +1,55 @@ +const ID_REGEX = /^[a-z0-9-]+$/; +const MAX_LENGTH = 50; + +export function validateWorkflowId(value: string): string | true { + if (!value) { + return 'Workflow ID is required'; + } + + if (!ID_REGEX.test(value)) { + return 'Use lowercase, numbers, and hyphens only (e.g., "my-workflow")'; + } + + if (value.length > MAX_LENGTH) { + return `Maximum ${MAX_LENGTH} characters`; + } + + return true; +} + +export function validateStepId(value: string, existingStepIds: Set = new Set()): string | true { + if (!value) { + return 'Step ID is required'; + } + + if (!ID_REGEX.test(value)) { + return 'Use lowercase, numbers, and hyphens only (e.g., "welcome-email")'; + } + + if (value.length > MAX_LENGTH) { + return `Maximum ${MAX_LENGTH} characters`; + } + + if (existingStepIds.has(value)) { + return `Step ID "${value}" already exists`; + } + + return true; +} + +export function generateStepIdFromFilename(filename: string): string { + return filename + .replace(/\.(tsx?|jsx?)$/, '') + .replace(/([A-Z])/g, '-$1') + .replace(/[_\s]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +export function generateWorkflowIdFromStepId(stepId: string): string { + if (stepId.endsWith('-email')) { + return stepId.slice(0, -6); + } + + return stepId; +} diff --git a/packages/novu/src/index.ts b/packages/novu/src/index.ts index 1750710aa1c..6077d35e3a0 100644 --- a/packages/novu/src/index.ts +++ b/packages/novu/src/index.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import { v4 as uuidv4 } from 'uuid'; import { DevCommandOptions, devCommand } from './commands'; +import { emailInit, emailPublish } from './commands/email'; import { IInitCommandOptions, init } from './commands/init'; import { sync } from './commands/sync'; import { pullTranslations, pushTranslations } from './commands/translations'; @@ -136,4 +137,45 @@ translationsCommand await pushTranslations(options); }); +const emailCommand = program.command('email').description('Manage Novu email step resolvers'); + +emailCommand + .command('init') + .description('Generate step handler files from novu.config.ts') + .option('-c, --config ', 'Path to config file') + .option('--force', 'Overwrite existing handler files') + .option('--out ', 'Output directory for handlers') + .option('--dry-run', 'Show what would be generated') + .action(async (options) => { + analytics.track({ + identity: { + anonymousId, + }, + data: {}, + event: 'Email Init Command', + }); + await emailInit(options); + }); + +emailCommand + .command('publish') + .description('Bundle and deploy step handlers to Novu') + .option('-s, --secret-key ', 'Novu API secret key', NOVU_SECRET_KEY || '') + .option('-a, --api-url ', 'Novu API URL') + .option('-c, --config ', 'Path to config file') + .option('--out ', 'Directory containing step handlers') + .option('--workflow ', 'Deploy only specific workflows') + .option('--bundle-out-dir [path]', 'Write bundled workflow artifacts to a directory for debugging') + .option('--dry-run', 'Bundle without deploying') + .action(async (options) => { + analytics.track({ + identity: { + anonymousId, + }, + data: {}, + event: 'Email Publish Command', + }); + await emailPublish(options); + }); + program.parse(process.argv); diff --git a/packages/novu/tsconfig.json b/packages/novu/tsconfig.json index d2680b83633..dcac6a1f0e8 100644 --- a/packages/novu/tsconfig.json +++ b/packages/novu/tsconfig.json @@ -17,5 +17,5 @@ "types": ["node"] }, "include": ["src/**/*", "src/**/*.d.ts"], - "exclude": ["node_modules", "src/commands/init/templates"] + "exclude": ["node_modules", "src/commands/init/templates", "src/**/__fixtures__"] } diff --git a/packages/novu/vitest.config.ts b/packages/novu/vitest.config.ts new file mode 100644 index 00000000000..767240818fe --- /dev/null +++ b/packages/novu/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: ['**/node_modules/**', '**/dist/**', '**/__fixtures__/**'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22be3116cca..c366d883a12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3334,6 +3334,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.5.0 + esbuild: + specifier: ^0.19.0 + version: 0.19.12 fast-glob: specifier: 3.3.1 version: 3.3.1 @@ -6405,6 +6408,12 @@ packages: engines: {node: '>=4'} deprecated: Support for this package will stop 2025-12-31 + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -6435,6 +6444,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -6465,6 +6480,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -6495,6 +6516,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -6525,6 +6552,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -6555,6 +6588,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -6585,6 +6624,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -6615,6 +6660,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -6645,6 +6696,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -6675,6 +6732,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -6705,6 +6768,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -6735,6 +6804,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -6765,6 +6840,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -6795,6 +6876,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -6825,6 +6912,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -6855,6 +6948,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -6885,6 +6984,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -6927,6 +7032,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -6975,6 +7086,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -7011,6 +7128,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -7041,6 +7164,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -7071,6 +7200,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -7101,6 +7236,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -17615,6 +17756,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -32204,6 +32350,9 @@ snapshots: to-pascal-case: 1.0.0 unescape-js: 1.1.4 + '@esbuild/aix-ppc64@0.19.12': + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -32219,6 +32368,9 @@ snapshots: '@esbuild/android-arm64@0.18.20': optional: true + '@esbuild/android-arm64@0.19.12': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true @@ -32234,6 +32386,9 @@ snapshots: '@esbuild/android-arm@0.18.20': optional: true + '@esbuild/android-arm@0.19.12': + optional: true + '@esbuild/android-arm@0.21.5': optional: true @@ -32249,6 +32404,9 @@ snapshots: '@esbuild/android-x64@0.18.20': optional: true + '@esbuild/android-x64@0.19.12': + optional: true + '@esbuild/android-x64@0.21.5': optional: true @@ -32264,6 +32422,9 @@ snapshots: '@esbuild/darwin-arm64@0.18.20': optional: true + '@esbuild/darwin-arm64@0.19.12': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true @@ -32279,6 +32440,9 @@ snapshots: '@esbuild/darwin-x64@0.18.20': optional: true + '@esbuild/darwin-x64@0.19.12': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true @@ -32294,6 +32458,9 @@ snapshots: '@esbuild/freebsd-arm64@0.18.20': optional: true + '@esbuild/freebsd-arm64@0.19.12': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true @@ -32309,6 +32476,9 @@ snapshots: '@esbuild/freebsd-x64@0.18.20': optional: true + '@esbuild/freebsd-x64@0.19.12': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true @@ -32324,6 +32494,9 @@ snapshots: '@esbuild/linux-arm64@0.18.20': optional: true + '@esbuild/linux-arm64@0.19.12': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true @@ -32339,6 +32512,9 @@ snapshots: '@esbuild/linux-arm@0.18.20': optional: true + '@esbuild/linux-arm@0.19.12': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true @@ -32354,6 +32530,9 @@ snapshots: '@esbuild/linux-ia32@0.18.20': optional: true + '@esbuild/linux-ia32@0.19.12': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true @@ -32369,6 +32548,9 @@ snapshots: '@esbuild/linux-loong64@0.18.20': optional: true + '@esbuild/linux-loong64@0.19.12': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true @@ -32384,6 +32566,9 @@ snapshots: '@esbuild/linux-mips64el@0.18.20': optional: true + '@esbuild/linux-mips64el@0.19.12': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true @@ -32399,6 +32584,9 @@ snapshots: '@esbuild/linux-ppc64@0.18.20': optional: true + '@esbuild/linux-ppc64@0.19.12': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true @@ -32414,6 +32602,9 @@ snapshots: '@esbuild/linux-riscv64@0.18.20': optional: true + '@esbuild/linux-riscv64@0.19.12': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true @@ -32429,6 +32620,9 @@ snapshots: '@esbuild/linux-s390x@0.18.20': optional: true + '@esbuild/linux-s390x@0.19.12': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true @@ -32444,6 +32638,9 @@ snapshots: '@esbuild/linux-x64@0.18.20': optional: true + '@esbuild/linux-x64@0.19.12': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true @@ -32465,6 +32662,9 @@ snapshots: '@esbuild/netbsd-x64@0.18.20': optional: true + '@esbuild/netbsd-x64@0.19.12': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true @@ -32489,6 +32689,9 @@ snapshots: '@esbuild/openbsd-x64@0.18.20': optional: true + '@esbuild/openbsd-x64@0.19.12': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true @@ -32507,6 +32710,9 @@ snapshots: '@esbuild/sunos-x64@0.18.20': optional: true + '@esbuild/sunos-x64@0.19.12': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true @@ -32522,6 +32728,9 @@ snapshots: '@esbuild/win32-arm64@0.18.20': optional: true + '@esbuild/win32-arm64@0.19.12': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true @@ -32537,6 +32746,9 @@ snapshots: '@esbuild/win32-ia32@0.18.20': optional: true + '@esbuild/win32-ia32@0.19.12': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true @@ -32552,6 +32764,9 @@ snapshots: '@esbuild/win32-x64@0.18.20': optional: true + '@esbuild/win32-x64@0.19.12': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true @@ -46445,6 +46660,32 @@ snapshots: '@esbuild/win32-x64': 0.18.20 optional: true + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 From 3e673c66664690292762c25d3b18c23796fc5263 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Feb 2026 14:39:39 +0200 Subject: [PATCH 2/4] fix(dashboard): Forward readonly and disabled to VariableInput (#10048) --- .../src/components/primitives/variable-editor.tsx | 4 +++- .../workflow-editor/control-input/control-input.tsx | 3 +++ .../workflow-editor/steps/controls/text-widget.tsx | 2 ++ .../app/workflow/services/subscriber-process.worker.ts | 8 -------- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/dashboard/src/components/primitives/variable-editor.tsx b/apps/dashboard/src/components/primitives/variable-editor.tsx index e9eeae10b9f..ca2de894219 100644 --- a/apps/dashboard/src/components/primitives/variable-editor.tsx +++ b/apps/dashboard/src/components/primitives/variable-editor.tsx @@ -46,6 +46,7 @@ type VariableEditorProps = { skipContainerClick?: boolean; children?: React.ReactNode; disabled?: boolean; + readOnly?: boolean; } & Pick< EditorProps, | 'className' @@ -97,6 +98,7 @@ export function VariableEditor({ onManageSchemaClick = () => {}, children, disabled = false, + readOnly = false, }: VariableEditorProps) { const containerRef = useRef(null); const track = useTelemetry(); @@ -377,7 +379,7 @@ export function VariableEditor({ onChange={onChange} onBlur={onBlur} tagStyles={tagStyles} - editable={!disabled} + editable={!disabled && !readOnly} /> {isVariablePopoverOpen && ( (null); const lastCompletionRef = useRef(null); @@ -134,6 +136,7 @@ export function ControlInput({ onManageSchemaClick={openSchemaDrawer} onCreateNewVariable={handleCreateNewVariable} disabled={disabled} + readOnly={readOnly} > diff --git a/apps/worker/src/app/workflow/services/subscriber-process.worker.ts b/apps/worker/src/app/workflow/services/subscriber-process.worker.ts index caad5a6331d..ab11f180671 100644 --- a/apps/worker/src/app/workflow/services/subscriber-process.worker.ts +++ b/apps/worker/src/app/workflow/services/subscriber-process.worker.ts @@ -52,14 +52,6 @@ export class SubscriberProcessWorker extends SubscriberProcessWorkerService { return; } - const organizationExists = await this.organizationExist(data); - - if (!organizationExists) { - Logger.log(`Organization not found for organizationId ${data.organizationId}. Skipping job.`, LOG_CONTEXT); - - return; - } - return await new Promise((resolve, reject) => { const _this = this; From 8ecf1fbb10a29d29347600f0a9b684029b4dadd3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Feb 2026 14:51:51 +0200 Subject: [PATCH 3/4] perf(api-service): Conditional preference fetches in GetPreferences for global (#10049) --- .../get-preferences.usecase.ts | 80 +++++++++++-------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts index 47770a9ec10..71083014507 100644 --- a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts @@ -256,30 +256,35 @@ export class GetPreferences { organizationId: command.organizationId, }; - const workflowPreferences = await this.inMemoryLRUCacheService.get( - InMemoryLRUCacheStore.WORKFLOW_PREFERENCES, - `${command.environmentId}:${command.templateId}`, - async (): Promise<[PreferencesEntity | null, PreferencesEntity | null]> => { - const preferences = await this.preferencesRepository.find( - { - ...baseQuery, - _templateId: command.templateId, - type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW] }, - }, - undefined, - queryOptions - ); - - const workflowResourcePreference = - preferences.find((p) => p.type === PreferencesTypeEnum.WORKFLOW_RESOURCE) ?? null; - const workflowUserPreference = preferences.find((p) => p.type === PreferencesTypeEnum.USER_WORKFLOW) ?? null; - - return [workflowResourcePreference, workflowUserPreference]; - }, - cacheOptions - ); + let workflowResourcePreference: PreferencesEntity | null = null; + let workflowUserPreference: PreferencesEntity | null = null; + + if (command.templateId) { + const workflowPreferences = await this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.WORKFLOW_PREFERENCES, + `${command.environmentId}:${command.templateId}`, + async (): Promise<[PreferencesEntity | null, PreferencesEntity | null]> => { + const preferences = await this.preferencesRepository.find( + { + ...baseQuery, + _templateId: command.templateId, + type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW] }, + }, + undefined, + queryOptions + ); + + const workflowResourcePref = + preferences.find((p) => p.type === PreferencesTypeEnum.WORKFLOW_RESOURCE) ?? null; + const workflowUserPref = preferences.find((p) => p.type === PreferencesTypeEnum.USER_WORKFLOW) ?? null; + + return [workflowResourcePref, workflowUserPref]; + }, + cacheOptions + ); - const [workflowResourcePreference, workflowUserPreference] = workflowPreferences; + [workflowResourcePreference, workflowUserPreference] = workflowPreferences; + } let subscriberWorkflowPreference: PreferencesEntity | null = null; let subscriberGlobalPreference: PreferencesEntity | null = null; @@ -295,18 +300,20 @@ export class GetPreferences { enabled: useContextFiltering, }); - [subscriberWorkflowPreference, subscriberGlobalPreference] = await Promise.all([ - this.preferencesRepository.findOne( - { - ...baseQuery, - _subscriberId: command.subscriberId, - _templateId: command.templateId, - type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, - ...contextQuery, - }, - undefined, - queryOptions - ), + const [workflowPref, globalPref] = await Promise.all([ + command.templateId + ? this.preferencesRepository.findOne( + { + ...baseQuery, + _subscriberId: command.subscriberId, + _templateId: command.templateId, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ...contextQuery, + }, + undefined, + queryOptions + ) + : Promise.resolve(null), this.preferencesRepository.findOne( { ...baseQuery, @@ -318,6 +325,9 @@ export class GetPreferences { queryOptions ), ]); + + subscriberWorkflowPreference = workflowPref; + subscriberGlobalPreference = globalPref; } const result: PreferenceSet = {}; From 13a6f45f6b8cfb085189fc5fd9d9646c0f2faa78 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 16 Feb 2026 14:55:25 +0200 Subject: [PATCH 4/4] fix(dashboard): Email layout name saving issue fixes NV-7084 (#10045) Co-authored-by: Cursor Agent --- .../layouts/layout-editor-settings-drawer.tsx | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx b/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx index be2fef322b0..eaec8bdbf6c 100644 --- a/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx +++ b/apps/dashboard/src/components/layouts/layout-editor-settings-drawer.tsx @@ -4,7 +4,7 @@ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; import { EnvironmentTypeEnum, PermissionsEnum, ResourceOriginEnum } from '@novu/shared'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { formatDistanceToNow } from 'date-fns'; -import { forwardRef, useCallback, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { RiDeleteBin2Line, RiSettings4Line } from 'react-icons/ri'; import { useBlocker, useNavigate } from 'react-router-dom'; @@ -81,13 +81,20 @@ export const LayoutEditorSettingsDrawer = forwardRef { + if (isOpen && !wasOpenRef.current && layout) { + form.reset({ + name: layout.name || '', + layoutId: layout.layoutId || '', + isTranslationEnabled: layout.isTranslationEnabled || false, + }); + } + wasOpenRef.current = isOpen; + }, [isOpen, layout, form]); + const hasUnsavedChanges = form.formState.isDirty; useBeforeUnload(hasUnsavedChanges); @@ -105,7 +112,12 @@ export const LayoutEditorSettingsDrawer = forwardRef { + onSuccess: (data) => { + form.reset({ + name: data.name || '', + layoutId: data.layoutId || '', + isTranslationEnabled: data.isTranslationEnabled || false, + }); showSuccessToast('Layout updated successfully', '', toastOptions); onOpenChange(false); }, @@ -240,7 +252,10 @@ export const LayoutEditorSettingsDrawer = forwardRef { + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} className="flex h-full flex-col" >