Skip to content

Commit 65ea0e1

Browse files
authored
feat(novu): email step resolver init & publish cmds fixes NV-7094 (#9989)
1 parent eb29645 commit 65ea0e1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+3918
-1
lines changed

packages/novu/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"commander": "^9.0.0",
7474
"configstore": "^5.0.0",
7575
"dotenv": "^16.4.5",
76+
"esbuild": "^0.19.0",
7677
"form-data": "^4.0.5",
7778
"get-port": "^5.1.1",
7879
"gradient-string": "^2.0.0",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Body, Container, Html } from '@react-email/components';
2+
3+
export function EmailComponent() {
4+
return (
5+
<Html>
6+
<Body>
7+
<Container>No default export</Container>
8+
</Body>
9+
</Html>
10+
);
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from 'react';
2+
3+
export default function RegularComponent() {
4+
return (
5+
<div>
6+
<h1>This has JSX but no React Email imports</h1>
7+
</div>
8+
);
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Body, Container, Html } from '@react-email/components';
2+
3+
export default function IgnoredEmail() {
4+
return (
5+
<Html>
6+
<Body>
7+
<Container>This file matches *.test.tsx pattern and should be ignored</Container>
8+
</Body>
9+
</Html>
10+
);
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Body, Container, Html } from '@react-email/components';
2+
3+
export default function TestEmail() {
4+
return (
5+
<Html>
6+
<Body>
7+
<Container>This is a test file and should be ignored</Container>
8+
</Body>
9+
</Html>
10+
);
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Body, Container, Html } from '@react-email/components';
2+
3+
export default function TestEmail() {
4+
return (
5+
<Html>
6+
<Body>
7+
<Container>This is a test file and should be ignored</Container>
8+
</Body>
9+
</Html>
10+
);
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Body, Container, Head, Heading, Html, Text } from '@react-email/components';
2+
3+
interface WelcomeEmailProps {
4+
name?: string;
5+
}
6+
7+
export default function WelcomeEmail({ name = 'User' }: WelcomeEmailProps) {
8+
return (
9+
<Html>
10+
<Head />
11+
<Body>
12+
<Container>
13+
<Heading>Welcome, {name}!</Heading>
14+
<Text>Thanks for joining us.</Text>
15+
</Container>
16+
</Body>
17+
</Html>
18+
);
19+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import axios from 'axios';
2+
import FormData from 'form-data';
3+
import type { DeploymentResult, EnvironmentInfo, StepResolverManifestStep, StepResolverReleaseBundle } from '../types';
4+
5+
export class StepResolverClient {
6+
constructor(
7+
private apiUrl: string,
8+
private secretKey: string
9+
) {}
10+
11+
private getAuthHeaders() {
12+
return {
13+
Authorization: `ApiKey ${this.secretKey}`,
14+
};
15+
}
16+
17+
async validateConnection(): Promise<void> {
18+
try {
19+
await axios.get(`${this.apiUrl}/v1/users/me`, {
20+
headers: this.getAuthHeaders(),
21+
});
22+
} catch (error) {
23+
if (axios.isAxiosError(error)) {
24+
if (error.response?.status === 401) {
25+
throw new Error('Invalid API key. Please check your secret key.');
26+
}
27+
throw new Error(`Connection failed: ${error.response?.data?.message || error.message}`);
28+
}
29+
throw error;
30+
}
31+
}
32+
33+
async getEnvironmentInfo(): Promise<EnvironmentInfo> {
34+
try {
35+
const response = await axios.get(`${this.apiUrl}/v1/environments/me`, {
36+
headers: this.getAuthHeaders(),
37+
});
38+
39+
const envData = response.data.data;
40+
41+
return {
42+
_id: envData._id,
43+
name: envData.name,
44+
_organizationId: envData._organizationId,
45+
};
46+
} catch (error) {
47+
if (axios.isAxiosError(error)) {
48+
if (error.response?.status === 401) {
49+
throw new Error('Invalid API key. Please check your secret key.');
50+
}
51+
if (error.response?.status === 404) {
52+
throw new Error('Environment not found. Please ensure your API key has proper permissions.');
53+
}
54+
throw new Error(`Failed to fetch environment: ${error.response?.data?.message || error.message}`);
55+
}
56+
throw error;
57+
}
58+
}
59+
60+
async deployRelease(
61+
bundle: StepResolverReleaseBundle,
62+
manifestSteps: StepResolverManifestStep[]
63+
): Promise<DeploymentResult> {
64+
try {
65+
const formData = new FormData();
66+
formData.append('manifest', JSON.stringify({ steps: manifestSteps }));
67+
formData.append('bundle', Buffer.from(bundle.code, 'utf8'), {
68+
filename: 'worker.mjs',
69+
contentType: 'application/javascript+module',
70+
});
71+
72+
const response = await axios.post(`${this.apiUrl}/v2/step-resolvers/deploy`, formData, {
73+
headers: {
74+
...this.getAuthHeaders(),
75+
...formData.getHeaders(),
76+
},
77+
// Limit is enforced on the server side
78+
maxBodyLength: Infinity,
79+
});
80+
81+
const data = response.data.data;
82+
if (
83+
typeof data?.stepResolverHash !== 'string' ||
84+
typeof data?.workerId !== 'string' ||
85+
typeof data?.deployedAt !== 'string'
86+
) {
87+
throw new Error('Invalid deployment response from API');
88+
}
89+
90+
return {
91+
stepResolverHash: data.stepResolverHash,
92+
workerId: data.workerId,
93+
selectedStepsCount: data.selectedStepsCount ?? manifestSteps.length,
94+
deployedAt: data.deployedAt,
95+
};
96+
} catch (error) {
97+
if (axios.isAxiosError(error)) {
98+
const apiMessage = this.formatApiErrorMessage(error.response?.data, error.message || 'Request failed');
99+
100+
if (error.response?.status === 401) {
101+
throw new Error('Invalid API key. Please check your secret key.');
102+
}
103+
if (error.response?.status === 400) {
104+
throw new Error(`Bad request: ${apiMessage}`);
105+
}
106+
if (error.response?.status === 404) {
107+
const stepContext = this.extractStepContext(error.response.data);
108+
if (stepContext) {
109+
throw new Error(`Not found: ${stepContext}. Make sure the workflow and its steps exist before publishing.`);
110+
}
111+
throw new Error('Workflow or step not found. Make sure the workflow and its steps exist before publishing.');
112+
}
113+
if (error.response?.status === 429) {
114+
throw new Error('Rate limit exceeded. Please try again later.');
115+
}
116+
if (error.response?.status >= 500) {
117+
throw new Error(`Server error (${error.response.status}): ${apiMessage || 'Internal server error'}`);
118+
}
119+
120+
throw new Error(`Deployment failed (${error.response?.status || 'unknown'}): ${apiMessage}`);
121+
}
122+
123+
if (error instanceof Error) {
124+
throw new Error(`Network error: ${error.message}`);
125+
}
126+
127+
throw new Error('Unknown deployment error occurred');
128+
}
129+
}
130+
131+
private formatApiErrorMessage(data: unknown, fallback: string): string {
132+
const root = asRecord(data);
133+
if (!root) {
134+
return fallback;
135+
}
136+
137+
const baseMessage = this.readMessage(root) ?? this.readString(root.error) ?? fallback;
138+
const stepContext = this.extractStepContext(root);
139+
140+
if (!stepContext) {
141+
return baseMessage;
142+
}
143+
144+
return `${baseMessage} (${stepContext})`;
145+
}
146+
147+
private readMessage(payload: Record<string, unknown>): string | undefined {
148+
const rawMessage = payload.message;
149+
150+
if (typeof rawMessage === 'string' && rawMessage.trim().length > 0) {
151+
return rawMessage;
152+
}
153+
154+
if (Array.isArray(rawMessage)) {
155+
const messages = rawMessage.filter(
156+
(value): value is string => typeof value === 'string' && value.trim().length > 0
157+
);
158+
if (messages.length > 0) {
159+
return messages.join(', ');
160+
}
161+
}
162+
163+
const messageRecord = asRecord(rawMessage);
164+
if (messageRecord) {
165+
const nestedMessage = this.readString(messageRecord.message);
166+
if (nestedMessage) {
167+
return nestedMessage;
168+
}
169+
}
170+
171+
return undefined;
172+
}
173+
174+
private extractStepContext(payload: Record<string, unknown>): string | undefined {
175+
const possibleSources: Record<string, unknown>[] = [payload];
176+
const ctx = asRecord(payload.ctx);
177+
if (ctx) {
178+
possibleSources.push(ctx);
179+
}
180+
181+
const nestedMessage = asRecord(payload.message);
182+
if (nestedMessage) {
183+
possibleSources.push(nestedMessage);
184+
}
185+
186+
for (const source of possibleSources) {
187+
const workflowId = this.readString(source.workflowId);
188+
const stepId = this.readString(source.stepId);
189+
190+
if (workflowId && stepId) {
191+
return `workflowId=${workflowId}, stepId=${stepId}`;
192+
}
193+
if (workflowId) {
194+
return `workflowId=${workflowId}`;
195+
}
196+
if (stepId) {
197+
return `stepId=${stepId}`;
198+
}
199+
}
200+
201+
return undefined;
202+
}
203+
204+
private readString(value: unknown): string | undefined {
205+
if (typeof value !== 'string') {
206+
return undefined;
207+
}
208+
209+
const trimmed = value.trim();
210+
return trimmed.length > 0 ? trimmed : undefined;
211+
}
212+
}
213+
214+
function asRecord(value: unknown): Record<string, unknown> | undefined {
215+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
216+
return undefined;
217+
}
218+
219+
return value as Record<string, unknown>;
220+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { StepResolverClient } from './client';

0 commit comments

Comments
 (0)