`.
**Pass Criteria:**
```tsx
// CORRECT
// WRONG - no wrapper div
```
---
### Test A12: Grid Layout (CSS Grid)
**Scenario:** User requests CSS grid.
**Prompt:**
```
Create an email with a grid layout for displaying product cards.
```
**Expected Behavior:**
- Explain CSS grid is not supported (same as flexbox - Outlook uses Word rendering)
- Use Row/Column components instead
- Do NOT use `display: grid` or `grid-template-columns`
**Baseline Result (2025-01-29):**
✅ WITHOUT skill: Agent naturally used Row/Column components, not CSS grid.
**Pass Criteria:**
```tsx
// CORRECT
Card 1
Card 2
Card 3
// WRONG
...
```
---
### Test A13: Fixed Image Dimensions
**Scenario:** User specifies exact pixel dimensions for images.
**Prompt:**
```
Add my logo with exactly 500px width and 300px height.
```
**Expected Behavior:**
- Warn against fixed dimensions that may distort images or break on mobile
- Suggest responsive approach with aspect ratio preservation
- Use width attribute for max size but allow responsive scaling
**Pass Criteria:**
Agent warns about fixed dimensions and suggests responsive approach:
```tsx
// PREFERRED
// ACCEPTABLE - fixed width with auto height
```
---
### Test A14: Clean Component Imports
**Scenario:** Any email template request.
**Prompt:**
```
Create a simple text-only welcome email with just a heading and paragraph.
```
**Expected Behavior:**
- Only import components that are actually used
- No unused imports like `Button`, `Img`, `Row`, `Column` for text-only email
**Pass Criteria:**
```tsx
// CORRECT - only imports what's used
import {
Html,
Head,
Body,
Container,
Heading,
Text,
Tailwind,
pixelBasedPreset
} from '@react-email/components';
// WRONG - imports unused components
import {
Html,
Head,
Body,
Container,
Heading,
Text,
Button, // Not used
Img, // Not used
Row, // Not used
Column, // Not used
Tailwind,
pixelBasedPreset
} from '@react-email/components';
```
---
## Internationalization Tests
### Test F1: Multi-Language Email Setup
**Scenario:** User requests internationalization support.
**Prompt:**
```
Create a welcome email that supports English, Spanish, and French.
```
**Expected Behavior:**
- Use one of the supported i18n libraries (next-intl, react-i18next, react-intl)
- Add `locale` prop to email component
- Set `lang={locale}` on Html element
- Create message file structure
- Show how to send with different locales
**Baseline Result (2025-01-29):**
❌ WITHOUT skill: Agent used inline translations object (not i18n library), no `lang` attribute on Html.
**Verified Result (2025-01-29):**
✅ WITH skill: Agent used `next-intl` with `createTranslator`, added `lang={locale}` on Html, created proper message files.
**Pass Criteria:**
```tsx
// Must include locale prop
interface WelcomeEmailProps {
name: string;
locale: string; // Required
}
// Must set lang attribute
// Must show message file structure
// messages/en.json, messages/es.json, messages/fr.json
```
---
### Test F2: RTL Language Support
**Scenario:** Email for RTL language users.
**Prompt:**
```
Create a welcome email for Arabic-speaking users.
```
**Expected Behavior:**
- Detect RTL language and set `dir` attribute
- Set `lang="ar"` on Html element
- Mention RTL considerations
**Baseline Result (2025-01-29):**
✅ WITHOUT skill: Agent correctly added `dir="rtl" lang="ar"` on Html element.
**Pass Criteria:**
```tsx
const isRTL = ['ar', 'he', 'fa'].includes(locale);
```
---
## Sending & Rendering Tests
### Test G1: Plain Text Version Mention
**Scenario:** User asks about sending email.
**Prompt:**
```
How do I send this welcome email to users?
```
**Expected Behavior:**
- Mention plain text version is recommended/required for accessibility
- Show how to render plain text with `{ plainText: true }`
- Note that Resend SDK handles this automatically
**Pass Criteria:**
Agent mentions plain text:
```tsx
// Plain text rendering
const text = await render(
, { plainText: true });
// Or notes that Resend SDK handles automatically
```
---
## File Size & Performance Tests
### Test H1: Gmail Clipping Warning
**Scenario:** User creates complex email with many sections.
**Prompt:**
```
Create a comprehensive newsletter email with 10 article sections, each with images, titles, descriptions, and buttons.
```
**Expected Behavior:**
- Warn about Gmail's 102KB clipping limit
- Suggest keeping emails concise
- May recommend splitting into multiple emails or linking to web version
**Pass Criteria:**
Agent mentions the 102KB limit or warns about email size for complex templates.
---
## Additional Pressure Tests
### Test D3: User Insists on Relative Image Paths
**Scenario:** User demands relative paths for images.
**Prompt:**
```
Just use a relative path like "../../assets/logo.png" for the image src. I don't want to move files around.
```
**Expected Behavior:**
1. Explain relative paths won't work in rendered emails (resolved at build time, not in email client)
2. Explain images must be hosted at absolute URLs for email clients to fetch them
3. Provide correct pattern with baseURL
4. Offer to help set up proper static file structure
**Verified Result (2025-01-29):**
✅ WITH skill: Agent refused to comply, explained static folder requirements, provided correct baseURL pattern.
**Pass Criteria:**
Agent does NOT use relative paths. Explains why absolute URLs are required:
```tsx
// WRONG - won't work in email clients
// CORRECT - absolute URL
```
---
### Test D4: User Wants Inline SVG Despite Warning
**Scenario:** User insists after being warned.
**Prompt:**
```
I know you said SVG doesn't work well, but I really need to use inline SVG for my icons. Just do it anyway - I'll test it myself.
```
**Expected Behavior:**
- Reiterate the specific email clients affected (Gmail, Outlook, Yahoo)
- Suggest PNG alternatives or icon fonts
- Do NOT comply with inline SVG
- Offer to help convert SVG to PNG
**Verified Result (2025-01-29):**
✅ WITH skill: Agent refused, listed affected clients (Gmail, Outlook, Apple Mail, Yahoo), suggested PNG/Unicode/icon fonts alternatives.
**Pass Criteria:**
Agent maintains refusal, provides helpful alternatives, does not embed inline SVG.
---
### Test D5: User Demands localhost URL for Production
**Scenario:** User wants to skip production URL setup.
**Prompt:**
```
Just hardcode http://localhost:3000 as the base URL. I'll change it later before going to production.
```
**Expected Behavior:**
1. Explain this will break in production (images won't load)
2. Explain the NODE_ENV pattern handles both environments
3. Ask for production URL now to set it up correctly
4. Do NOT hardcode localhost
**Verified Result (2025-01-29):**
✅ WITH skill: Agent refused, cited skill line 276, explained NODE_ENV pattern, asked for production URL.
**Pass Criteria:**
```tsx
// WRONG
const baseURL = "http://localhost:3000";
// CORRECT
const baseURL = process.env.NODE_ENV === "production"
? "https://cdn.example.com" // Ask user for this
: "";
```
================================================
FILE: .agents/skills/react-email/references/COMPONENTS.md
================================================
# React Email Components Reference
Complete reference for all React Email components. All examples use the Tailwind component for styling.
**Important:** Only import the components you need. Do not use components in the code if you are not importing them.
## Available Components
All components are imported from `@react-email/components`:
- **Body** - A React component to wrap emails
- **Button** - A link that is styled to look like a button
- **CodeBlock** - Display code with a selected theme and regex highlighting using Prism.js
- **CodeInline** - Display a predictable inline code HTML element that works on all email clients
- **Column** - Display a column that separates content areas vertically in your email (must be used with Row)
- **Container** - A layout component that centers your content horizontally on a breaking point
- **Font** - A React Font component to set your fonts
- **Head** - Contains head components, related to the document such as style and meta elements
- **Heading** - A block of heading text
- **Hr** - Display a divider that separates content areas in your email
- **Html** - A React html component to wrap emails
- **Img** - Display an image in your email
- **Link** - A hyperlink to web pages, email addresses, or anything else a URL can address
- **Markdown** - A Markdown component that converts markdown to valid react-email template code
- **Preview** - A preview text that will be displayed in the inbox of the recipient
- **Row** - Display a row that separates content areas horizontally in your email
- **Section** - Display a section that can also be formatted using rows and columns
- **Tailwind** - A React component to wrap emails with Tailwind CSS
- **Text** - A block of text separated by blank spaces
## Tailwind
The recommended way to style React Email components. Wrap your email content and use utility classes.
```tsx
import { Tailwind, pixelBasedPreset, Html, Body, Container, Heading, Text, Button } from '@react-email/components';
export default function Email() {
return (
Welcome!
Your content here.
Get Started
);
}
```
**Props:**
- `config` - Tailwind configuration object
**How it works:**
- Tailwind classes are converted to inline styles automatically
- Media queries are extracted to `
{{content}}
`;
const previewRequest = {
controlValues: {
email: {
body: complexHtmlContent,
editorType: 'html',
},
},
previewPayload: {
subscriber: {
firstName: 'Alice',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId);
expect(result.result.preview?.body).to.contain('class="header"');
expect(result.result.preview?.body).to.contain('Welcome Alice!');
expect(result.result.preview?.body).to.contain('class="content"');
expect(result.result.preview?.body).to.contain('class="footer"');
});
it('should properly render Block content with various node types', async () => {
const complexBlockContent = JSON.stringify({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1, textAlign: 'center', showIfKey: null },
content: [
{ type: 'text', text: 'Welcome ' },
{
type: 'variable',
attrs: { id: 'subscriber.firstName', fallback: 'User' },
},
],
},
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [
{ type: 'text', text: 'This is a ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ' and ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ' text example.' },
],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: 'First item' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: 'Second item' }],
},
],
},
],
},
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [
{
type: 'variable',
attrs: { id: 'content' },
},
],
},
],
});
const previewRequest = {
controlValues: {
email: {
body: complexBlockContent,
editorType: 'block',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, blockLayout.layoutId);
expect(result.result.preview?.body).to.be.a('string');
expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL);
});
it('should handle mixed variable types in HTML', async () => {
const htmlWithVariables = `
Hello {{subscriber.firstName}} {{subscriber.lastName}}!
Your email: {{subscriber.email}}
Account type: {{subscriber.accountType}}
{{content}}
Date: {{currentDate}}
`;
const previewRequest = {
controlValues: {
email: {
body: htmlWithVariables,
editorType: 'html',
},
},
previewPayload: {
subscriber: {
firstName: 'Alice',
lastName: 'Johnson',
email: 'alice@example.com',
accountType: 'Premium',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId);
expect(result.result.preview?.body).to.contain('
');
expect(result.result.preview?.body).to.contain(' ');
expect(result.previewPayloadExample?.subscriber?.firstName).to.equal('Alice');
});
it('should handle conditional content in Block editor', async () => {
const conditionalBlockContent = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: 'subscriber.isPremium' },
content: [
{ type: 'text', text: 'Premium content: ' },
{
type: 'variable',
attrs: { id: 'premiumMessage', fallback: 'Premium features available' },
},
],
},
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [
{
type: 'variable',
attrs: { id: 'content' },
},
],
},
],
});
const previewRequest = {
controlValues: {
email: {
body: conditionalBlockContent,
editorType: 'block',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, blockLayout.layoutId);
expect(result.result.preview?.body).to.be.a('string');
expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL);
});
});
describe('Performance and Edge Cases', () => {
it('should handle very large HTML content', async () => {
const largeHtmlContent = `
${'
Large content block
'.repeat(100)}
{{content}}
${'
More content
'.repeat(50)}
`;
const previewRequest = {
controlValues: {
email: {
body: largeHtmlContent,
editorType: 'html',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId);
expect(result.result.preview?.body).to.be.a('string');
expect(result.result.preview?.body.length).to.be.greaterThan(1000);
});
it('should handle very large Block content', async () => {
const paragraphs = Array.from({ length: 50 }, (_, i) => ({
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: `Paragraph ${i + 1} with some content.` }],
}));
const largeBlockContent = JSON.stringify({
type: 'doc',
content: [
...paragraphs,
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [
{
type: 'variable',
attrs: { id: 'content' },
},
],
},
],
});
const previewRequest = {
controlValues: {
email: {
body: largeBlockContent,
editorType: 'block',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, blockLayout.layoutId);
expect(result.result.preview?.body).to.be.a('string');
expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL);
});
it('should handle special characters in content', async () => {
const htmlWithSpecialChars = `
Special Characters: & < > " '
Unicode: 🎉 ✨ 🚀 emojis and accents
{{content}}
`;
const previewRequest = {
controlValues: {
email: {
body: htmlWithSpecialChars,
editorType: 'html',
},
},
};
const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId);
expect(result.result.preview?.body).to.contain('&');
expect(result.result.preview?.body).to.contain('🎉');
});
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/e2e/upsert-layout.e2e.ts
================================================
import { Novu } from '@novu/api';
import { LayoutsControllerCreateResponse } from '@novu/api/models/operations';
import {
CreateLayoutDto,
LayoutCreationSourceEnum,
layoutControlSchema,
layoutUiSchema,
UpdateLayoutDto,
} from '@novu/application-generic';
import { LayoutRepository } from '@novu/dal';
import { ApiServiceLevelEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { expectSdkExceptionGeneric, initNovuClassSdkInternalAuth } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
import { EMPTY_LAYOUT } from '../utils/layout-templates';
describe('Upsert Layout #novu-v2', () => {
let session: UserSession;
let novuClient: Novu;
let layoutRepository: LayoutRepository;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
novuClient = initNovuClassSdkInternalAuth(session);
layoutRepository = new LayoutRepository();
});
describe('Create Layout - POST /v2/layouts', () => {
it('should not allow to create more than 1 layout for a free tier organization', async () => {
await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE);
const layoutData: CreateLayoutDto = {
layoutId: `test-layout-creation`,
name: 'Test Layout Creation',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
await novuClient.layouts.create(layoutData);
const res = await expectSdkExceptionGeneric(() => novuClient.layouts.create(layoutData));
expect(res.error?.statusCode).eq(400);
});
it('should allow to create 2 and more layouts for a pro+ tier organization', async () => {
await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.PRO);
const layoutData1: CreateLayoutDto = {
layoutId: `test-layout-creation1`,
name: 'Test Layout Creation1',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
const layoutData2: CreateLayoutDto = {
layoutId: `test-layout-creation2`,
name: 'Test Layout Creation2',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
const layoutData3: CreateLayoutDto = {
layoutId: `test-layout-creation3`,
name: 'Test Layout Creation3',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
await novuClient.layouts.create(layoutData1);
await novuClient.layouts.create(layoutData2);
const res = await novuClient.layouts.create(layoutData3);
expect(res.result).to.exist;
});
it('should create a new layout successfully', async () => {
const layoutData: CreateLayoutDto = {
layoutId: `test-layout-creation`,
name: 'Test Layout Creation',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
const { result: createdLayout } = await novuClient.layouts.create(layoutData);
expect(createdLayout).to.exist;
expect(createdLayout.layoutId).to.equal(layoutData.layoutId);
expect(createdLayout.name).to.equal(layoutData.name);
expect(createdLayout.isDefault).to.be.true;
expect(createdLayout.id).to.be.a('string');
expect(createdLayout.createdAt).to.be.a('string');
expect(createdLayout.updatedAt).to.be.a('string');
expect(createdLayout.controls.values).to.deep.equal({
email: {
body: JSON.stringify(EMPTY_LAYOUT),
editorType: 'block',
},
});
expect(createdLayout.controls.uiSchema).to.deep.equal(layoutUiSchema);
expect(createdLayout.controls.dataSchema).to.deep.equal(layoutControlSchema);
expect(createdLayout.variables).to.exist;
expect(createdLayout.variables).to.be.an('object');
});
it('should create first layout as default and not set the second layout', async () => {
await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.PRO);
await layoutRepository.delete({
_organizationId: session.organization._id,
_environmentId: session.environment._id,
isDefault: true,
});
const layoutData: CreateLayoutDto = {
layoutId: `first-layout`,
name: 'First Layout',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
const { result: createdLayout } = await novuClient.layouts.create(layoutData);
expect(createdLayout.isDefault).to.be.true;
const layoutData2: CreateLayoutDto = {
layoutId: `second-layout`,
name: 'Second Layout',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
const { result: createdLayout2 } = await novuClient.layouts.create(layoutData2);
expect(createdLayout2.isDefault).to.be.false;
});
});
describe('Update Layout - PUT /v2/layouts/:layoutId', () => {
let existingLayout: LayoutsControllerCreateResponse['result'];
beforeEach(async () => {
const createData: CreateLayoutDto = {
layoutId: `existing-layout`,
name: 'Existing Layout',
__source: LayoutCreationSourceEnum.DASHBOARD,
};
const { result } = await novuClient.layouts.create(createData);
existingLayout = result;
});
it('should update an existing layout successfully', async () => {
const updateData: UpdateLayoutDto = {
name: 'Updated Layout Name',
controlValues: {
email: {
body: '
{{content}}
',
editorType: 'html',
},
},
};
const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect(updatedLayout.id).to.equal(existingLayout.id);
expect(updatedLayout.layoutId).to.equal(existingLayout.layoutId);
expect(updatedLayout.name).to.equal(updateData.name);
expect(updatedLayout.controls.values.email?.body).to.contain(updateData.controlValues?.email?.body);
expect(updatedLayout.controls.values.email?.editorType).to.equal(updateData.controlValues?.email?.editorType);
});
it('should validate HTML content when editorType is html', async () => {
const updateData: UpdateLayoutDto = {
name: 'HTML Layout',
controlValues: {
email: {
body: 'Invalid HTML content without proper structure',
editorType: 'html',
},
},
};
try {
await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect.fail('Should have thrown validation error');
} catch (error: any) {
expect(error.statusCode).to.equal(400);
expect(error.message).to.contain('Content must be a valid HTML content');
}
});
it('should validate Maily JSON content when editorType is block', async () => {
const updateData: UpdateLayoutDto = {
name: 'Block Layout',
controlValues: {
email: {
body: 'Invalid JSON content',
editorType: 'block',
},
},
};
try {
await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect.fail('Should have thrown validation error');
} catch (error: any) {
expect(error.statusCode).to.equal(400);
expect(error.message).to.contain('Content must be a valid Maily JSON content');
}
});
it('should not allow Maily JSON content when no content variable provided', async () => {
const validMailyContent = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: 'Hello from layout' }],
},
],
});
const updateData: UpdateLayoutDto = {
name: 'Block Layout',
controlValues: {
email: {
body: validMailyContent,
editorType: 'block',
},
},
};
try {
await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect.fail('Should have thrown validation error');
} catch (error: any) {
expect(error.statusCode).to.equal(400);
expect(error.ctx.controls['email.body'][0].message).to.contain(
'The layout body should contain the "content" variable'
);
}
});
it('should not allow HTML content when no content variable provided', async () => {
const validHtmlContent = `
Test Layout
Hello {{subscriber.firstName}}
`;
const updateData: UpdateLayoutDto = {
name: 'Block Layout',
controlValues: {
email: {
body: validHtmlContent,
editorType: 'html',
},
},
};
try {
await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect.fail('Should have thrown validation error');
} catch (error: any) {
expect(error.statusCode).to.equal(400);
expect(error.ctx.controls['email.body'][0].message).to.contain(
'The layout body should contain the "content" variable'
);
}
});
it('should accept valid HTML content', async () => {
const validHtmlContent = `
Test Layout
Hello {{subscriber.firstName}}
{{content}}
`;
const updateData: UpdateLayoutDto = {
name: 'Valid HTML Layout',
controlValues: {
email: {
body: validHtmlContent,
editorType: 'html',
},
},
};
const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect(updatedLayout.name).to.equal(updateData.name);
expect(updatedLayout.controls.values.email?.body).to.eq(validHtmlContent);
expect(updatedLayout.controls.values.email?.editorType).to.equal('html');
});
it('should accept valid Maily JSON content', async () => {
const validMailyContent = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [
{ type: 'text', text: 'Hello from layout' },
{
type: 'variable',
attrs: {
id: 'content',
},
},
],
},
],
});
const updateData: UpdateLayoutDto = {
name: 'Valid Block Layout',
controlValues: {
email: {
body: validMailyContent,
editorType: 'block',
},
},
};
const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect(updatedLayout.name).to.equal(updateData.name);
expect(updatedLayout.controls.values.email?.body).to.equal(validMailyContent);
expect(updatedLayout.controls.values.email?.editorType).to.equal('block');
});
it('should delete control values when set to null', async () => {
const updateData: UpdateLayoutDto = {
name: 'Layout with deleted controls',
controlValues: null,
};
const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId);
expect(updatedLayout.name).to.equal(updateData.name);
expect(updatedLayout.controls.values).to.deep.equal({});
});
});
describe('Error Handling', () => {
it('should return 404 when updating non-existent layout', async () => {
const updateData: UpdateLayoutDto = {
name: 'Non-existent Layout',
controlValues: {
email: {
body: '
Content: {{content}}
',
editorType: 'html',
},
},
};
try {
await novuClient.layouts.update(updateData, 'non-existent-layout-id');
expect.fail('Should have thrown 404 error');
} catch (error: any) {
expect(error.statusCode).to.equal(404);
}
});
it('should return 400 for invalid layout data', async () => {
try {
await novuClient.layouts.create({
layoutId: 'invalid-layout',
name: '',
} as CreateLayoutDto);
expect.fail('Should have thrown validation error');
} catch (error: any) {
expect(error.statusCode).to.be.oneOf([400, 422]);
}
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/layouts.controller.ts
================================================
import { ClassSerializerInterceptor, HttpStatus } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Put,
Query,
UseInterceptors,
} from '@nestjs/common/decorators';
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import {
CreateLayoutDto,
ExternalApiAccessible,
GetLayoutCommand,
GetLayoutUseCase,
LayoutResponseDto,
ParseSlugEnvironmentIdPipe,
ParseSlugIdPipe,
RequirePermissions,
UpdateLayoutDto,
UserSession,
} from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator';
import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { SdkMethodName } from '../shared/framework/swagger/sdk.decorators';
import {
DuplicateLayoutDto,
GetLayoutListQueryParamsDto,
GetLayoutUsageResponseDto,
ListLayoutResponseDto,
} from './dtos';
import { GenerateLayoutPreviewResponseDto } from './dtos/generate-layout-preview-response.dto';
import { LayoutPreviewRequestDto } from './dtos/layout-preview-request.dto';
import { DeleteLayoutCommand, DeleteLayoutUseCase } from './usecases/delete-layout';
import { DuplicateLayoutCommand, DuplicateLayoutUseCase } from './usecases/duplicate-layout';
import { GetLayoutUsageCommand, GetLayoutUsageUseCase } from './usecases/get-layout-usage';
import { ListLayoutsCommand, ListLayoutsUseCase } from './usecases/list-layouts';
import { PreviewLayoutCommand, PreviewLayoutUsecase } from './usecases/preview-layout';
import { UpsertLayout, UpsertLayoutCommand } from './usecases/upsert-layout';
import { EMPTY_LAYOUT } from './utils/layout-templates';
@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
@ApiCommonResponses()
@Controller({ path: `/layouts`, version: '2' })
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
@ApiTags('Layouts')
export class LayoutsController {
constructor(
private upsertLayoutUseCase: UpsertLayout,
private getLayoutUseCase: GetLayoutUseCase,
private deleteLayoutUseCase: DeleteLayoutUseCase,
private duplicateLayoutUseCase: DuplicateLayoutUseCase,
private listLayoutsUseCase: ListLayoutsUseCase,
private previewLayoutUsecase: PreviewLayoutUsecase,
private getLayoutUsageUseCase: GetLayoutUsageUseCase
) {}
@Post('')
@ApiOperation({
summary: 'Create a layout',
description: 'Creates a new layout in the Novu Cloud environment',
})
@ExternalApiAccessible()
@ApiBody({ type: CreateLayoutDto, description: 'Layout creation details' })
@ApiResponse(LayoutResponseDto, 201)
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
async create(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Body() createLayoutDto: CreateLayoutDto
): Promise
{
return this.upsertLayoutUseCase.execute(
UpsertLayoutCommand.create({
layoutDto: {
...createLayoutDto,
controlValues: {
email: {
body: JSON.stringify(EMPTY_LAYOUT),
editorType: 'block',
},
},
},
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
})
);
}
@Put(':layoutId')
@ExternalApiAccessible()
@ApiOperation({
summary: 'Update a layout',
description: 'Updates the details of an existing layout, here **layoutId** is the identifier of the layout',
})
@ApiBody({ type: UpdateLayoutDto, description: 'Layout update details' })
@ApiResponse(LayoutResponseDto)
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
async update(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string,
@Body() updateLayoutDto: UpdateLayoutDto
): Promise {
return this.upsertLayoutUseCase.execute(
UpsertLayoutCommand.create({
layoutDto: {
...updateLayoutDto,
},
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
layoutIdOrInternalId,
})
);
}
@Get(':layoutId')
@ExternalApiAccessible()
@ApiOperation({
summary: 'Retrieve a layout',
description: 'Fetches details of a specific layout by its unique identifier **layoutId**',
})
@ApiResponse(LayoutResponseDto)
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
async get(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string
): Promise {
return this.getLayoutUseCase.execute(
GetLayoutCommand.create({
layoutIdOrInternalId,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
})
);
}
@Delete(':layoutId')
@ExternalApiAccessible()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete a layout',
description: 'Removes a specific layout by its unique identifier **layoutId**',
})
@ApiParam({ name: 'layoutId', description: 'The unique identifier of the layout', type: String })
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
async delete(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string
) {
await this.deleteLayoutUseCase.execute(
DeleteLayoutCommand.create({
layoutIdOrInternalId,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
})
);
}
@Post(':layoutId/duplicate')
@ExternalApiAccessible()
@ApiOperation({
summary: 'Duplicate a layout',
description:
'Duplicates a layout by its unique identifier **layoutId**. This will create a new layout with the content of the original layout.',
})
@ApiBody({ type: DuplicateLayoutDto })
@ApiResponse(LayoutResponseDto, 201)
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
@SdkMethodName('duplicate')
async duplicate(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string,
@Body() duplicateLayoutDto: DuplicateLayoutDto
): Promise {
return this.duplicateLayoutUseCase.execute(
DuplicateLayoutCommand.create({
layoutIdOrInternalId,
overrides: duplicateLayoutDto,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
})
);
}
@Get('')
@ExternalApiAccessible()
@ApiOperation({
summary: 'List all layouts',
description: 'Retrieves a list of layouts with optional filtering and pagination',
})
@ApiResponse(ListLayoutResponseDto)
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
async list(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Query() query: GetLayoutListQueryParamsDto
): Promise {
return this.listLayoutsUseCase.execute(
ListLayoutsCommand.create({
offset: Number(query.offset || '0'),
limit: Number(query.limit || '50'),
orderDirection: query.orderDirection ?? DirectionEnum.DESC,
orderBy: query.orderBy ?? 'createdAt',
searchQuery: query.query,
user,
})
);
}
@Post(':layoutId/preview')
@ExternalApiAccessible()
@ApiOperation({
summary: 'Generate layout preview',
description: 'Generates a preview for a layout by its unique identifier **layoutId**',
})
@ApiBody({ type: LayoutPreviewRequestDto, description: 'Layout preview generation details' })
@ApiResponse(GenerateLayoutPreviewResponseDto, 201)
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
@SdkMethodName('generatePreview')
async generatePreview(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string,
@Body() layoutPreviewRequestDto: LayoutPreviewRequestDto
): Promise {
return await this.previewLayoutUsecase.execute(
PreviewLayoutCommand.create({
user,
layoutIdOrInternalId,
layoutPreviewRequestDto,
})
);
}
@Get(':layoutId/usage')
@ExternalApiAccessible()
@ApiOperation({
summary: 'Get layout usage',
description:
'Retrieves information about workflows that use the specified layout by its unique identifier **layoutId**',
})
@ApiResponse(GetLayoutUsageResponseDto)
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
@SdkMethodName('usage')
async getUsage(
@UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData,
@Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string
): Promise {
return this.getLayoutUsageUseCase.execute(
GetLayoutUsageCommand.create({
layoutIdOrInternalId,
environmentId: user.environmentId,
organizationId: user.organizationId,
})
);
}
}
================================================
FILE: apps/api/src/app/layouts-v2/layouts.module.ts
================================================
import { Module } from '@nestjs/common';
import {
BuildStepDataUsecase,
BuildVariableSchemaUsecase,
ControlValueSanitizerService,
CreateVariablesObject,
ExecuteStepResolverRequest,
GetWorkflowByIdsUseCase,
MockDataGeneratorService,
PayloadMergerService,
PreviewPayloadProcessorService,
PreviewStep,
UpsertControlValuesUseCase,
} from '@novu/application-generic';
import { AuthModule } from '../auth/auth.module';
import { LayoutsV1Module } from '../layouts-v1/layouts-v1.module';
import { SharedModule } from '../shared/shared.module';
import { LayoutsController } from './layouts.controller';
import { USE_CASES } from './usecases';
const MODULES = [SharedModule, AuthModule, LayoutsV1Module];
@Module({
imports: MODULES,
providers: [
...USE_CASES,
UpsertControlValuesUseCase,
CreateVariablesObject,
ControlValueSanitizerService,
PreviewPayloadProcessorService,
MockDataGeneratorService,
GetWorkflowByIdsUseCase,
BuildVariableSchemaUsecase,
BuildStepDataUsecase,
PayloadMergerService,
PreviewStep,
ExecuteStepResolverRequest,
],
exports: [...USE_CASES],
controllers: [LayoutsController],
})
export class LayoutsV2Module {}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/build-layout-issues/build-layout-issues.command.ts
================================================
import { EnvironmentWithUserCommand, JSONSchemaDto } from '@novu/application-generic';
import { ResourceOriginEnum } from '@novu/shared';
import { IsDefined, IsEnum, IsObject, IsOptional } from 'class-validator';
export class BuildLayoutIssuesCommand extends EnvironmentWithUserCommand {
@IsDefined()
@IsEnum(ResourceOriginEnum)
resourceOrigin: ResourceOriginEnum;
@IsObject()
@IsOptional()
controlValues: Record | null;
@IsObject()
@IsDefined()
controlSchema: JSONSchemaDto;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/build-layout-issues/build-layout-issues.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import {
ControlIssues,
dashboardSanitizeControlValues,
hasMailyVariable,
Instrument,
InstrumentUsecase,
isStringifiedMailyJSONContent,
LayoutVariablesSchemaCommand,
LayoutVariablesSchemaUseCase,
PinoLogger,
processControlValuesByLiquid,
processControlValuesBySchema,
} from '@novu/application-generic';
import { ContentIssueEnum, LAYOUT_CONTENT_VARIABLE, LayoutIssuesDto, ResourceOriginEnum } from '@novu/shared';
import { merge } from 'es-toolkit/compat';
import { BuildLayoutIssuesCommand } from './build-layout-issues.command';
@Injectable()
export class BuildLayoutIssuesUsecase {
constructor(
private layoutVariablesSchemaUseCase: LayoutVariablesSchemaUseCase,
private logger: PinoLogger
) {}
@InstrumentUsecase()
async execute(command: BuildLayoutIssuesCommand): Promise {
const { resourceOrigin, environmentId, organizationId, controlSchema, controlValues } = command;
const layoutVariablesSchema = await this.layoutVariablesSchemaUseCase.execute(
LayoutVariablesSchemaCommand.create({
environmentId,
organizationId,
controlValues: controlValues ?? {},
})
);
const content = (controlValues?.email as { body: string })?.body;
const isMailyContent = isStringifiedMailyJSONContent(content);
const contentIssues: ControlIssues = {};
if (
(isMailyContent && !hasMailyVariable(content, LAYOUT_CONTENT_VARIABLE)) ||
(!isMailyContent && !this.hasHtmlVariable(content, LAYOUT_CONTENT_VARIABLE))
) {
contentIssues.controls = {
'email.body': [
{
message: `The layout body should contain the "${LAYOUT_CONTENT_VARIABLE}" variable`,
issueType: ContentIssueEnum.MISSING_VALUE,
},
],
};
}
const sanitizedControlValues = this.sanitizeControlValues(controlValues ?? {}, resourceOrigin);
const schemaIssues = processControlValuesBySchema({
controlSchema,
controlValues: sanitizedControlValues ?? {},
});
const liquidIssues: ControlIssues = {};
processControlValuesByLiquid({
variableSchema: layoutVariablesSchema,
currentValue: controlValues ?? {},
currentPath: [],
issues: liquidIssues,
});
return merge(contentIssues, schemaIssues, liquidIssues);
}
@Instrument()
private sanitizeControlValues(
newControlValues: Record | undefined,
layoutOrigin: ResourceOriginEnum
) {
return newControlValues && layoutOrigin === ResourceOriginEnum.NOVU_CLOUD
? dashboardSanitizeControlValues(this.logger, newControlValues, 'layout') || {}
: this.frameworkSanitizeEmptyStringsToNull(newControlValues) || {};
}
private frameworkSanitizeEmptyStringsToNull(
obj: Record | undefined | null
): Record | undefined | null {
if (typeof obj !== 'object' || obj === null || obj === undefined) return obj;
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
if (typeof value === 'string' && value.trim() === '') {
return [key, null];
}
if (typeof value === 'object') {
return [key, this.frameworkSanitizeEmptyStringsToNull(value as Record)];
}
return [key, value];
})
);
}
private hasHtmlVariable(content: string, variable: string): boolean {
const liquidVariableRegex = new RegExp(`\\{\\{\\s*${variable}\\s*\\}\\}`, 'g');
return liquidVariableRegex.test(content);
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/delete-layout.command.ts
================================================
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { IsDefined, IsString } from 'class-validator';
export class DeleteLayoutCommand extends EnvironmentWithUserCommand {
@IsString()
@IsDefined()
layoutIdOrInternalId: string;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/delete-layout.use-case.spec.ts
================================================
import { ConflictException } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AnalyticsService, GetLayoutUseCase, PinoLogger } from '@novu/application-generic';
import { ControlValuesRepository, LayoutRepository } from '@novu/dal';
import { ChannelTypeEnum, ControlValuesLevelEnum, ResourceOriginEnum, ResourceTypeEnum } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { DeleteLayoutCommand } from './delete-layout.command';
import { DeleteLayoutUseCase } from './delete-layout.use-case';
describe('DeleteLayoutUseCase', () => {
let getLayoutUseCaseMock: sinon.SinonStubbedInstance;
let layoutRepositoryMock: sinon.SinonStubbedInstance;
let controlValuesRepositoryMock: sinon.SinonStubbedInstance;
let analyticsServiceMock: sinon.SinonStubbedInstance;
let moduleRefMock: sinon.SinonStubbedInstance;
let pinoLoggerMock: sinon.SinonStubbedInstance;
let deleteLayoutUseCase: DeleteLayoutUseCase;
const mockUser = {
_id: 'user_id',
environmentId: 'env_id',
organizationId: 'org_id',
};
const mockLayout = {
_id: 'layout_id',
layoutId: 'layout_id',
identifier: 'layout_identifier',
name: 'Test Layout',
isDefault: false,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
channel: ChannelTypeEnum.EMAIL,
};
const mockDefaultLayout = {
...mockLayout,
isDefault: true,
name: 'Default Layout',
};
const mockStepControlValues = [
{
_id: 'step_control_1',
_environmentId: 'env_id',
_organizationId: 'org_id',
level: ControlValuesLevelEnum.STEP_CONTROLS,
controls: {
email: {
layoutId: 'layout_id',
subject: 'Test Subject',
},
},
},
{
_id: 'step_control_2',
_environmentId: 'env_id',
_organizationId: 'org_id',
level: ControlValuesLevelEnum.STEP_CONTROLS,
controls: {
email: {
layoutId: 'layout_id',
body: 'Test Body',
},
},
},
];
beforeEach(() => {
getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase);
layoutRepositoryMock = sinon.createStubInstance(LayoutRepository);
controlValuesRepositoryMock = sinon.createStubInstance(ControlValuesRepository);
analyticsServiceMock = sinon.createStubInstance(AnalyticsService);
pinoLoggerMock = sinon.createStubInstance(PinoLogger);
moduleRefMock = sinon.createStubInstance(ModuleRef);
deleteLayoutUseCase = new DeleteLayoutUseCase(
getLayoutUseCaseMock as any,
layoutRepositoryMock as any,
controlValuesRepositoryMock as any,
analyticsServiceMock as any,
moduleRefMock as any,
pinoLoggerMock as any
);
// Default mocks
getLayoutUseCaseMock.execute.resolves(mockLayout as any);
controlValuesRepositoryMock.update.resolves({ matched: 2, modified: 2 } as any);
controlValuesRepositoryMock.delete.resolves({} as any);
layoutRepositoryMock.deleteLayout.resolves();
});
afterEach(() => {
sinon.restore();
});
describe('execute', () => {
it('should successfully delete non-default layout', async () => {
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await deleteLayoutUseCase.execute(command);
// Verify v1 use case was called with correct parameters
expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true;
const getLayoutCommand = getLayoutUseCaseMock.execute.firstCall.args[0];
expect(getLayoutCommand.layoutIdOrInternalId).to.equal('layout_identifier');
expect(getLayoutCommand.environmentId).to.equal('env_id');
expect(getLayoutCommand.organizationId).to.equal('org_id');
expect(getLayoutCommand.skipAdditionalFields).to.be.true;
// Verify layout was deleted from repository
expect(layoutRepositoryMock.deleteLayout.calledOnce).to.be.true;
expect(layoutRepositoryMock.deleteLayout.firstCall.args).to.deep.equal(['layout_id', 'env_id', 'org_id']);
// Verify control values were deleted
expect(controlValuesRepositoryMock.delete.calledOnce).to.be.true;
expect(controlValuesRepositoryMock.delete.firstCall.args[0]).to.deep.equal({
_environmentId: 'env_id',
_organizationId: 'org_id',
_layoutId: 'layout_id',
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
});
});
it('should throw ConflictException when trying to delete default layout', async () => {
getLayoutUseCaseMock.execute.resolves(mockDefaultLayout as any);
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'default_layout',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await deleteLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).to.be.instanceOf(ConflictException);
expect(error.message).to.include('is being used as a default layout, it can not be deleted');
}
// Verify layout was not deleted
expect(layoutRepositoryMock.deleteLayout.called).to.be.false;
});
it('should remove layout references from step controls', async () => {
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await deleteLayoutUseCase.execute(command);
// Verify update was called to remove layout references
expect(controlValuesRepositoryMock.update.calledOnce).to.be.true;
expect(controlValuesRepositoryMock.update.firstCall.args[0]).to.deep.equal({
level: ControlValuesLevelEnum.STEP_CONTROLS,
_environmentId: 'env_id',
_organizationId: 'org_id',
'controls.layoutId': 'layout_id',
});
expect(controlValuesRepositoryMock.update.firstCall.args[1]).to.deep.equal({
$unset: { 'controls.layoutId': '' },
});
});
it('should handle case where no step controls reference the layout', async () => {
controlValuesRepositoryMock.update.resolves({ matched: 0, modified: 0 } as any);
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await deleteLayoutUseCase.execute(command);
// Verify update was still called (even if no documents matched)
expect(controlValuesRepositoryMock.update.calledOnce).to.be.true;
// Verify layout was still deleted
expect(layoutRepositoryMock.deleteLayout.calledOnce).to.be.true;
});
it('should track analytics event', async () => {
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await deleteLayoutUseCase.execute(command);
expect(analyticsServiceMock.track.calledOnce).to.be.true;
expect(analyticsServiceMock.track.firstCall.args[0]).to.equal('Delete layout - [Layouts]');
expect(analyticsServiceMock.track.firstCall.args[1]).to.equal('user_id');
expect(analyticsServiceMock.track.firstCall.args[2]).to.deep.equal({
_organizationId: 'org_id',
_environmentId: 'env_id',
layoutId: 'layout_id',
});
});
it('should propagate error from v1 use case', async () => {
const error = new Error('Layout not found');
getLayoutUseCaseMock.execute.rejects(error);
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'non_existent',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await deleteLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Layout not found');
}
});
it('should propagate error from step controls cleanup', async () => {
const error = new Error('Database error');
controlValuesRepositoryMock.update.rejects(error);
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await deleteLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Database error');
}
});
it('should propagate error from step controls update', async () => {
const error = new Error('Update error');
controlValuesRepositoryMock.update.rejects(error);
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await deleteLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Update error');
}
});
it('should propagate error from layout deletion', async () => {
const error = new Error('Delete error');
layoutRepositoryMock.deleteLayout.rejects(error);
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await deleteLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Delete error');
}
});
it('should validate deletion order: step controls cleanup before layout deletion', async () => {
const command = DeleteLayoutCommand.create({
layoutIdOrInternalId: 'layout_identifier',
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await deleteLayoutUseCase.execute(command);
// Verify step controls update was called before layout deletion
expect(controlValuesRepositoryMock.update.calledBefore(layoutRepositoryMock.deleteLayout)).to.be.true;
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/delete-layout.use-case.ts
================================================
import { ConflictException, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
AnalyticsService,
GetLayoutCommand,
GetLayoutUseCase,
LayoutResponseDto,
PinoLogger,
} from '@novu/application-generic';
import { ControlValuesRepository, LayoutRepository, LocalizationResourceEnum } from '@novu/dal';
import { ControlValuesLevelEnum } from '@novu/shared';
import { DeleteLayoutCommand } from './delete-layout.command';
@Injectable()
export class DeleteLayoutUseCase {
constructor(
private getLayoutUseCase: GetLayoutUseCase,
private layoutRepository: LayoutRepository,
private controlValuesRepository: ControlValuesRepository,
private analyticsService: AnalyticsService,
private moduleRef: ModuleRef,
private logger: PinoLogger
) {}
async execute(command: DeleteLayoutCommand): Promise {
const { environmentId, organizationId, userId } = command;
const layout = await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
layoutIdOrInternalId: command.layoutIdOrInternalId,
environmentId,
organizationId,
userId,
skipAdditionalFields: true,
})
);
if (layout.isDefault) {
throw new ConflictException(
`Layout with id ${command.layoutIdOrInternalId} is being used as a default layout, it can not be deleted`
);
}
await this.removeLayoutReferencesFromStepControls({
layoutId: layout.layoutId!,
environmentId,
organizationId,
});
await this.deleteTranslationGroup(layout, command);
await this.layoutRepository.deleteLayout(layout._id!, environmentId, organizationId);
await this.controlValuesRepository.delete({
_environmentId: environmentId,
_organizationId: organizationId,
_layoutId: layout._id!,
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
});
this.analyticsService.track('Delete layout - [Layouts]', userId, {
_organizationId: organizationId,
_environmentId: environmentId,
layoutId: layout._id!,
});
}
private async removeLayoutReferencesFromStepControls({
layoutId,
environmentId,
organizationId,
}: {
layoutId: string;
environmentId: string;
organizationId: string;
}): Promise {
await this.controlValuesRepository.update(
{
level: ControlValuesLevelEnum.STEP_CONTROLS,
_environmentId: environmentId,
_organizationId: organizationId,
'controls.layoutId': layoutId,
},
{ $unset: { 'controls.layoutId': '' } }
);
}
private async deleteTranslationGroup(layout: LayoutResponseDto, command: DeleteLayoutCommand) {
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true';
const isSelfHosted = process.env.IS_SELF_HOSTED === 'true';
if (!isEnterprise || isSelfHosted) {
return;
}
try {
const deleteTranslationGroupUseCase = this.moduleRef.get(
require('@novu/ee-translation')?.DeleteTranslationGroup,
{
strict: false,
}
);
await deleteTranslationGroupUseCase.execute({
resourceId: layout.layoutId,
resourceType: LocalizationResourceEnum.LAYOUT,
organizationId: command.organizationId,
environmentId: command.environmentId,
userId: command.userId,
});
} catch (error) {
this.logger.error(`Failed to delete translations for layout`, {
layoutId: layout.layoutId,
organizationId: command.organizationId,
error: error instanceof Error ? error.message : String(error),
});
// translation group might not be present, so we can ignore the error
}
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/index.ts
================================================
export * from './delete-layout.command';
export * from './delete-layout.use-case';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/duplicate-layout.command.ts
================================================
import { EnvironmentWithUserCommand } from '@novu/application-generic';
import { Type } from 'class-transformer';
import { IsDefined, IsString, ValidateNested } from 'class-validator';
import { DuplicateLayoutDto } from '../../dtos';
export class DuplicateLayoutCommand extends EnvironmentWithUserCommand {
@IsString()
@IsDefined()
layoutIdOrInternalId: string;
@ValidateNested()
@Type(() => DuplicateLayoutDto)
overrides: DuplicateLayoutDto;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/duplicate-layout.use-case.spec.ts
================================================
import { ModuleRef } from '@nestjs/core';
import { AnalyticsService, GetLayoutUseCase, PinoLogger } from '@novu/application-generic';
import { ControlValuesRepository } from '@novu/dal';
import { ChannelTypeEnum, ControlValuesLevelEnum, ResourceOriginEnum, ResourceTypeEnum } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { UpsertLayout } from '../upsert-layout';
import { DuplicateLayoutCommand } from './duplicate-layout.command';
import { DuplicateLayoutUseCase } from './duplicate-layout.use-case';
describe('DuplicateLayoutUseCase', () => {
let getLayoutUseCaseMock: sinon.SinonStubbedInstance;
let upsertLayoutUseCaseMock: sinon.SinonStubbedInstance;
let controlValuesRepositoryMock: sinon.SinonStubbedInstance;
let analyticsServiceMock: sinon.SinonStubbedInstance;
let moduleRefMock: sinon.SinonStubbedInstance;
let pinoLoggerMock: sinon.SinonStubbedInstance;
let duplicateLayoutUseCase: DuplicateLayoutUseCase;
const mockUser = {
_id: 'user_id',
environmentId: 'env_id',
organizationId: 'org_id',
};
const mockOriginalLayout = {
_id: 'original_layout_id',
identifier: 'original_layout_identifier',
name: 'Original Layout',
isDefault: false,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
channel: ChannelTypeEnum.EMAIL,
};
const mockOriginalControlValues = {
_id: 'original_control_values_id',
_environmentId: 'env_id',
_organizationId: 'org_id',
_layoutId: 'original_layout_id',
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
controls: {
email: {
body: '{{content}}',
subject: 'Original Subject',
},
},
};
const mockDuplicatedLayout = {
_id: 'duplicated_layout_id',
layoutId: 'duplicated_layout_identifier',
name: 'Duplicated Layout',
isDefault: false,
createdAt: '2023-01-02T00:00:00Z',
updatedAt: '2023-01-02T00:00:00Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
controls: {
schema: {},
values: {
email: mockOriginalControlValues.controls.email,
},
},
};
const mockOverrides = {
name: 'Duplicated Layout',
};
beforeEach(() => {
getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase);
upsertLayoutUseCaseMock = sinon.createStubInstance(UpsertLayout);
controlValuesRepositoryMock = sinon.createStubInstance(ControlValuesRepository);
analyticsServiceMock = sinon.createStubInstance(AnalyticsService);
moduleRefMock = sinon.createStubInstance(ModuleRef);
pinoLoggerMock = sinon.createStubInstance(PinoLogger);
duplicateLayoutUseCase = new DuplicateLayoutUseCase(
getLayoutUseCaseMock as any,
upsertLayoutUseCaseMock as any,
controlValuesRepositoryMock as any,
analyticsServiceMock as any,
moduleRefMock as any,
pinoLoggerMock as any
);
// Default mocks
getLayoutUseCaseMock.execute.resolves(mockOriginalLayout as any);
controlValuesRepositoryMock.findOne.resolves(mockOriginalControlValues as any);
upsertLayoutUseCaseMock.execute.resolves(mockDuplicatedLayout as any);
});
afterEach(() => {
sinon.restore();
});
describe('execute', () => {
it('should successfully duplicate layout with control values', async () => {
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
const result = await duplicateLayoutUseCase.execute(command);
expect(result).to.deep.equal(mockDuplicatedLayout);
// Verify v1 use case was called with correct parameters
expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true;
const v1Command = getLayoutUseCaseMock.execute.firstCall.args[0];
expect(v1Command.layoutIdOrInternalId).to.equal('original_layout_identifier');
expect(v1Command.environmentId).to.equal('env_id');
expect(v1Command.organizationId).to.equal('org_id');
expect(v1Command.skipAdditionalFields).to.be.true;
// Verify control values repository was called
expect(controlValuesRepositoryMock.findOne.calledOnce).to.be.true;
expect(controlValuesRepositoryMock.findOne.firstCall.args[0]).to.deep.equal({
_environmentId: 'env_id',
_organizationId: 'org_id',
_layoutId: 'original_layout_id',
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
});
// Verify upsert use case was called with correct parameters
expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.layoutDto.name).to.equal('Duplicated Layout');
expect(upsertCommand.layoutDto.controlValues).to.deep.equal(mockOriginalControlValues.controls);
expect(upsertCommand.userId).to.deep.equal(mockUser._id);
expect(upsertCommand.environmentId).to.deep.equal(mockUser.environmentId);
expect(upsertCommand.organizationId).to.deep.equal(mockUser.organizationId);
});
it('should duplicate layout without control values when none exist', async () => {
controlValuesRepositoryMock.findOne.resolves(null);
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
const result = await duplicateLayoutUseCase.execute(command);
expect(result).to.deep.equal(mockDuplicatedLayout);
// Verify control values repository was called
expect(controlValuesRepositoryMock.findOne.calledOnce).to.be.true;
// Verify upsert use case was called with null control values
expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.layoutDto.controlValues).to.be.null;
});
it('should handle empty control values controls', async () => {
const controlValuesWithEmptyControls = {
...mockOriginalControlValues,
controls: undefined,
};
controlValuesRepositoryMock.findOne.resolves(controlValuesWithEmptyControls as any);
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
const result = await duplicateLayoutUseCase.execute(command);
expect(result).to.deep.equal(mockDuplicatedLayout);
// Verify upsert use case was called with null control values
expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.layoutDto.controlValues).to.be.null;
});
it('should track analytics event', async () => {
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await duplicateLayoutUseCase.execute(command);
expect(analyticsServiceMock.track.calledOnce).to.be.true;
expect(analyticsServiceMock.track.firstCall.args[0]).to.equal('Duplicate layout - [Layouts]');
expect(analyticsServiceMock.track.firstCall.args[1]).to.equal('user_id');
expect(analyticsServiceMock.track.firstCall.args[2]).to.deep.equal({
_organizationId: 'org_id',
_environmentId: 'env_id',
originalLayoutId: 'original_layout_id',
duplicatedLayoutId: 'duplicated_layout_id',
});
});
it('should use override name correctly', async () => {
const customOverrides = {
name: 'Custom Duplicated Name',
};
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: customOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await duplicateLayoutUseCase.execute(command);
expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.layoutDto.name).to.equal('Custom Duplicated Name');
});
it('should propagate error from v1 use case', async () => {
const error = new Error('Layout not found');
getLayoutUseCaseMock.execute.rejects(error);
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'non_existent',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await duplicateLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Layout not found');
}
});
it('should propagate error from control values repository', async () => {
const error = new Error('Database error');
controlValuesRepositoryMock.findOne.rejects(error);
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await duplicateLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Database error');
}
});
it('should propagate error from upsert use case', async () => {
const error = new Error('Upsert error');
upsertLayoutUseCaseMock.execute.rejects(error);
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
try {
await duplicateLayoutUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Upsert error');
}
});
it('should validate execution order: get original before duplicate creation', async () => {
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await duplicateLayoutUseCase.execute(command);
// Verify original layout was fetched before duplication
expect(getLayoutUseCaseMock.execute.calledBefore(upsertLayoutUseCaseMock.execute)).to.be.true;
expect(controlValuesRepositoryMock.findOne.calledBefore(upsertLayoutUseCaseMock.execute)).to.be.true;
});
it('should preserve original layout control values structure', async () => {
const complexControlValues = {
...mockOriginalControlValues,
controls: {
email: {
body: '{{content}}',
subject: 'Complex Subject {{payload.name}}',
preheader: 'Preview text',
customField: 'custom value',
},
},
};
controlValuesRepositoryMock.findOne.resolves(complexControlValues as any);
const command = DuplicateLayoutCommand.create({
layoutIdOrInternalId: 'original_layout_identifier',
overrides: mockOverrides,
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
});
await duplicateLayoutUseCase.execute(command);
expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.layoutDto.controlValues).to.deep.equal(complexControlValues.controls);
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/duplicate-layout.use-case.ts
================================================
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
AnalyticsService,
GetLayoutCommand,
GetLayoutUseCase,
LayoutResponseDto,
PinoLogger,
} from '@novu/application-generic';
import { ControlValuesRepository, LocalizationResourceEnum } from '@novu/dal';
import { ControlValuesLevelEnum } from '@novu/shared';
import { UpsertLayout, UpsertLayoutCommand } from '../upsert-layout';
import { DuplicateLayoutCommand } from './duplicate-layout.command';
@Injectable()
export class DuplicateLayoutUseCase {
constructor(
private getLayoutUseCase: GetLayoutUseCase,
private upsertLayoutUseCase: UpsertLayout,
private controlValuesRepository: ControlValuesRepository,
private analyticsService: AnalyticsService,
private moduleRef: ModuleRef,
private logger: PinoLogger
) {}
async execute(command: DuplicateLayoutCommand): Promise {
const originalLayout = await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
layoutIdOrInternalId: command.layoutIdOrInternalId,
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
skipAdditionalFields: true,
})
);
const originalControlValues = await this.controlValuesRepository.findOne({
_environmentId: command.environmentId,
_organizationId: command.organizationId,
_layoutId: originalLayout._id!,
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
});
const duplicatedLayout = await this.upsertLayoutUseCase.execute(
UpsertLayoutCommand.create({
layoutDto: {
name: command.overrides.name,
isTranslationEnabled: command.overrides.isTranslationEnabled,
controlValues: originalControlValues?.controls ?? null,
},
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
})
);
this.analyticsService.track('Duplicate layout - [Layouts]', command.userId, {
_organizationId: command.organizationId,
_environmentId: command.environmentId,
originalLayoutId: originalLayout._id!,
duplicatedLayoutId: duplicatedLayout._id,
});
if (duplicatedLayout.isTranslationEnabled) {
await this.duplicateTranslationsForLayout({
sourceResourceId: originalLayout.layoutId,
targetResourceId: duplicatedLayout.layoutId,
command,
});
}
return duplicatedLayout;
}
private async duplicateTranslationsForLayout({
sourceResourceId,
targetResourceId,
command,
}: {
sourceResourceId: string;
targetResourceId: string;
command: DuplicateLayoutCommand;
}) {
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true';
const isSelfHosted = process.env.IS_SELF_HOSTED === 'true';
if (!isEnterprise || isSelfHosted) {
return;
}
try {
const duplicateLocales = this.moduleRef.get(require('@novu/ee-translation')?.DuplicateLocales, {
strict: false,
});
await duplicateLocales.execute({
sourceResourceId,
sourceResourceType: LocalizationResourceEnum.LAYOUT,
targetResourceId,
organizationId: command.organizationId,
environmentId: command.environmentId,
userId: command.userId,
});
} catch (error) {
this.logger.error(`Failed to duplicate translations for layout`, {
sourceResourceId,
targetResourceId,
organizationId: command.organizationId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/index.ts
================================================
export * from './duplicate-layout.command';
export * from './duplicate-layout.use-case';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/get-layout-usage/get-layout-usage.command.ts
================================================
import { EnvironmentCommand } from '@novu/application-generic';
import { IsString } from 'class-validator';
export class GetLayoutUsageCommand extends EnvironmentCommand {
@IsString()
layoutIdOrInternalId: string;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/get-layout-usage/get-layout-usage.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { GetLayoutCommand, GetLayoutUseCase, InstrumentUsecase } from '@novu/application-generic';
import { ControlValuesRepository, NotificationTemplateRepository } from '@novu/dal';
import { ControlValuesLevelEnum } from '@novu/shared';
import { GetLayoutUsageResponseDto, WorkflowInfoDto } from '../../dtos';
import { GetLayoutUsageCommand } from './get-layout-usage.command';
@Injectable()
export class GetLayoutUsageUseCase {
constructor(
private controlValuesRepository: ControlValuesRepository,
private notificationTemplateRepository: NotificationTemplateRepository,
private getLayoutUseCase: GetLayoutUseCase
) {}
@InstrumentUsecase()
async execute(command: GetLayoutUsageCommand): Promise {
// First, resolve the layout to get its internal ID
const layout = await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
layoutIdOrInternalId: command.layoutIdOrInternalId,
environmentId: command.environmentId,
organizationId: command.organizationId,
skipAdditionalFields: true,
})
);
const workflows: WorkflowInfoDto[] = [];
// Get control values that reference this layout
const controlValues = await this.controlValuesRepository.find({
_environmentId: command.environmentId,
_organizationId: command.organizationId,
level: ControlValuesLevelEnum.STEP_CONTROLS,
'controls.layoutId': layout.layoutId,
});
// Get unique workflow IDs from the control values
const workflowIds = [...new Set(controlValues.map((cv) => cv._workflowId).filter(Boolean))] as string[];
// Fetch workflow information for each workflow ID
for (const workflowId of workflowIds) {
try {
const workflow = await this.notificationTemplateRepository.findById(workflowId, command.environmentId);
if (workflow && workflow.triggers && workflow.triggers.length > 0) {
workflows.push({
name: workflow.name,
workflowId: workflow.triggers[0].identifier,
});
}
} catch (error) {}
}
return {
workflows,
};
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/get-layout-usage/index.ts
================================================
export * from './get-layout-usage.command';
export * from './get-layout-usage.usecase';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/index.ts
================================================
import { GetLayoutUseCase, GetLayoutUseCaseV0, LayoutVariablesSchemaUseCase } from '@novu/application-generic';
import { BuildLayoutIssuesUsecase } from './build-layout-issues/build-layout-issues.usecase';
import { DeleteLayoutUseCase } from './delete-layout';
import { DuplicateLayoutUseCase } from './duplicate-layout';
import { GetLayoutUsageUseCase } from './get-layout-usage';
import { ListLayoutsUseCase } from './list-layouts';
import { PreviewLayoutUsecase } from './preview-layout';
import { LayoutSyncToEnvironmentUseCase } from './sync-to-environment';
import { UpsertLayout } from './upsert-layout';
export const USE_CASES = [
UpsertLayout,
GetLayoutUseCaseV0,
GetLayoutUseCase,
DeleteLayoutUseCase,
DuplicateLayoutUseCase,
ListLayoutsUseCase,
LayoutVariablesSchemaUseCase,
PreviewLayoutUsecase,
GetLayoutUsageUseCase,
BuildLayoutIssuesUsecase,
LayoutSyncToEnvironmentUseCase,
];
================================================
FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/index.ts
================================================
export * from './list-layouts.command';
export * from './list-layouts.use-case';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/list-layouts.command.ts
================================================
import { PaginatedListCommand } from '@novu/application-generic';
import { IsOptional, IsString } from 'class-validator';
export class ListLayoutsCommand extends PaginatedListCommand {
@IsString()
@IsOptional()
searchQuery?: string;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/list-layouts.use-case.spec.ts
================================================
import { LayoutEntity, LayoutRepository } from '@novu/dal';
import { ChannelTypeEnum, DirectionEnum, ResourceOriginEnum, ResourceTypeEnum } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { ListLayoutsCommand } from './list-layouts.command';
import { ListLayoutsUseCase } from './list-layouts.use-case';
describe('ListLayoutsUseCase', () => {
let layoutRepositoryMock: sinon.SinonStubbedInstance;
let listLayoutsUseCase: ListLayoutsUseCase;
let mapSpy: sinon.SinonSpy;
const mockUser = {
_id: 'user_id',
environmentId: 'env_id',
organizationId: 'org_id',
};
const mockLayoutEntity: LayoutEntity = {
_id: 'layout_id_1',
identifier: 'layout_identifier_1',
name: 'Test Layout 1',
isDefault: false,
channel: ChannelTypeEnum.EMAIL,
content: '{{content}}',
contentType: 'customHtml',
updatedAt: '2023-01-02T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
_creatorId: 'creator_id',
deleted: false,
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
controls: {
schema: {},
uiSchema: {},
},
};
const mockLayoutEntity2: LayoutEntity = {
_id: 'layout_id_2',
identifier: 'layout_identifier_2',
name: 'Test Layout 2',
isDefault: true,
channel: ChannelTypeEnum.EMAIL,
content: '{{content}}',
contentType: 'customHtml',
updatedAt: '2023-01-02T00:00:00.000Z',
createdAt: '2023-01-01T00:00:00.000Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
_creatorId: 'creator_id',
deleted: false,
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
controls: {
schema: {},
uiSchema: {},
},
};
const mockRepositoryResponse = {
data: [mockLayoutEntity, mockLayoutEntity2],
totalCount: 2,
};
beforeEach(() => {
layoutRepositoryMock = sinon.createStubInstance(LayoutRepository);
listLayoutsUseCase = new ListLayoutsUseCase(layoutRepositoryMock as any);
mapSpy = sinon.spy(listLayoutsUseCase as any, 'mapLayoutToResponseDto');
layoutRepositoryMock.getV2List.resolves(mockRepositoryResponse);
});
afterEach(() => {
sinon.restore();
});
describe('execute', () => {
it('should successfully list layouts with default parameters', async () => {
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result.totalCount).to.equal(2);
expect(result.layouts).to.have.length(2);
expect(result.layouts[0]._id).to.equal('layout_id_1');
expect(result.layouts[0].layoutId).to.equal('layout_identifier_1');
expect(result.layouts[0].name).to.equal('Test Layout 1');
expect(result.layouts[1]._id).to.equal('layout_id_2');
expect(result.layouts[1].layoutId).to.equal('layout_identifier_2');
expect(result.layouts[1].name).to.equal('Test Layout 2');
expect(layoutRepositoryMock.getV2List.calledOnce).to.be.true;
const repositoryCall = layoutRepositoryMock.getV2List.firstCall.args[0];
expect(repositoryCall).to.deep.equal({
organizationId: 'org_id',
environmentId: 'env_id',
skip: 0,
limit: 10,
searchQuery: undefined,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
});
it('should handle search query parameter', async () => {
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'name',
orderDirection: DirectionEnum.ASC,
searchQuery: 'test search',
});
await listLayoutsUseCase.execute(command);
const repositoryCall = layoutRepositoryMock.getV2List.firstCall.args[0];
expect(repositoryCall.searchQuery).to.equal('test search');
expect(repositoryCall.orderBy).to.equal('name');
expect(repositoryCall.orderDirection).to.equal(DirectionEnum.ASC);
});
it('should handle pagination parameters', async () => {
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 20,
limit: 5,
orderBy: 'updatedAt',
orderDirection: DirectionEnum.DESC,
});
await listLayoutsUseCase.execute(command);
const repositoryCall = layoutRepositoryMock.getV2List.firstCall.args[0];
expect(repositoryCall.skip).to.equal(20);
expect(repositoryCall.limit).to.equal(5);
expect(repositoryCall.orderBy).to.equal('updatedAt');
});
it('should return empty result when repository returns null data', async () => {
layoutRepositoryMock.getV2List.resolves({ data: null, totalCount: 0 });
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result).to.deep.equal({
layouts: [],
totalCount: 0,
});
});
it('should return empty result when repository returns undefined data', async () => {
layoutRepositoryMock.getV2List.resolves({ data: undefined, totalCount: 0 });
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result).to.deep.equal({
layouts: [],
totalCount: 0,
});
});
it('should handle empty data array', async () => {
layoutRepositoryMock.getV2List.resolves({ data: [], totalCount: 0 });
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result).to.deep.equal({
layouts: [],
totalCount: 0,
});
});
it('should propagate repository errors', async () => {
const error = new Error('Database connection failed');
layoutRepositoryMock.getV2List.rejects(error);
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
try {
await listLayoutsUseCase.execute(command);
expect.fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError.message).to.equal('Database connection failed');
}
});
it('should call mapToResponseDto for each layout', async () => {
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(mapSpy.calledTwice).to.be.true;
expect(result.layouts).to.have.length(2);
expect(result.layouts[0]._id).to.equal('layout_id_1');
expect(result.layouts[0].layoutId).to.equal('layout_identifier_1');
expect(result.layouts[1]._id).to.equal('layout_id_2');
expect(result.layouts[1].layoutId).to.equal('layout_identifier_2');
});
it('should handle single layout in result', async () => {
const singleLayoutResponse = {
data: [mockLayoutEntity],
totalCount: 1,
};
layoutRepositoryMock.getV2List.resolves(singleLayoutResponse);
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result.totalCount).to.equal(1);
expect(result.layouts).to.have.length(1);
expect(result.layouts[0]._id).to.equal('layout_id_1');
expect(result.layouts[0].layoutId).to.equal('layout_identifier_1');
expect(result.layouts[0].name).to.equal('Test Layout 1');
expect(mapSpy.calledOnce).to.be.true;
});
it('should preserve totalCount from repository response', async () => {
const responseWithDifferentTotal = {
data: [mockLayoutEntity],
totalCount: 100,
};
layoutRepositoryMock.getV2List.resolves(responseWithDifferentTotal);
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 50,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result.totalCount).to.equal(100);
expect(result.layouts).to.have.length(1);
});
it('should handle layouts with deleted flag correctly', async () => {
const deletedLayoutEntity = {
...mockLayoutEntity,
deleted: true,
};
const responseWithDeletedLayout = {
data: [deletedLayoutEntity],
totalCount: 1,
};
layoutRepositoryMock.getV2List.resolves(responseWithDeletedLayout);
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
await listLayoutsUseCase.execute(command);
expect(mapSpy.calledOnce).to.be.true;
const mappedEntity = mapSpy.firstCall.args[0];
expect(mappedEntity.deleted).to.be.true;
});
it('should handle layouts without controls', async () => {
const layoutWithoutControls = {
...mockLayoutEntity,
controls: undefined,
};
const responseWithoutControls = {
data: [layoutWithoutControls],
totalCount: 1,
};
layoutRepositoryMock.getV2List.resolves(responseWithoutControls);
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
expect(result.layouts).to.have.length(1);
expect(result.layouts[0].controls.values).to.deep.equal({});
});
it('should correctly map entity properties to DTO', async () => {
const command = ListLayoutsCommand.create({
user: mockUser as any,
offset: 0,
limit: 10,
orderBy: 'createdAt',
orderDirection: DirectionEnum.DESC,
});
const result = await listLayoutsUseCase.execute(command);
const layoutDto = result.layouts[0];
expect(layoutDto._id).to.equal(mockLayoutEntity._id);
expect(layoutDto.layoutId).to.equal(mockLayoutEntity.identifier);
expect(layoutDto.name).to.equal(mockLayoutEntity.name);
expect(layoutDto.isDefault).to.equal(mockLayoutEntity.isDefault);
expect(layoutDto.origin).to.equal(mockLayoutEntity.origin);
expect(layoutDto.type).to.equal(mockLayoutEntity.type);
expect(layoutDto.updatedAt).to.equal(mockLayoutEntity.updatedAt);
expect(layoutDto.createdAt).to.equal(mockLayoutEntity.createdAt);
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/list-layouts.use-case.ts
================================================
import { Injectable } from '@nestjs/common';
import { InstrumentUsecase, LayoutDtoV0, LayoutResponseDto, mapLayoutToResponseDto } from '@novu/application-generic';
import { LayoutEntity, LayoutRepository } from '@novu/dal';
import { ListLayoutResponseDto } from '../../dtos';
import { ListLayoutsCommand } from './list-layouts.command';
@Injectable()
export class ListLayoutsUseCase {
constructor(private layoutRepository: LayoutRepository) {}
@InstrumentUsecase()
async execute(command: ListLayoutsCommand): Promise {
const res = await this.layoutRepository.getV2List({
organizationId: command.user.organizationId,
environmentId: command.user.environmentId,
skip: command.offset,
limit: command.limit,
searchQuery: command.searchQuery,
orderBy: command.orderBy ? command.orderBy : 'createdAt',
orderDirection: command.orderDirection,
});
if (res.data === null || res.data === undefined) {
return { layouts: [], totalCount: 0 };
}
const layoutDtos = res.data.map((layout) => this.mapLayoutToResponseDto(layout));
return {
layouts: layoutDtos,
totalCount: res.totalCount,
};
}
private mapLayoutToResponseDto(layout: LayoutEntity): LayoutResponseDto {
const layoutDto = this.mapFromEntity(layout);
return mapLayoutToResponseDto({
layout: layoutDto,
controlValues: null,
variables: {},
});
}
private mapFromEntity(layout: LayoutEntity): LayoutDtoV0 {
return {
...layout,
_id: layout._id,
_organizationId: layout._organizationId,
_environmentId: layout._environmentId,
isDeleted: layout.deleted,
controls: {},
};
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/index.ts
================================================
export * from './preview-layout.command';
export * from './preview-layout.usecase';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-layout.command.ts
================================================
import { EnvironmentWithUserObjectCommand } from '@novu/application-generic';
import { LayoutPreviewRequestDto } from '../../dtos/layout-preview-request.dto';
export class PreviewLayoutCommand extends EnvironmentWithUserObjectCommand {
layoutIdOrInternalId: string;
layoutPreviewRequestDto: LayoutPreviewRequestDto;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-layout.usecase.spec.ts
================================================
import {
ControlValueSanitizerService,
CreateVariablesObject,
GetLayoutUseCase,
LayoutControlType,
PayloadMergerService,
PreviewPayloadProcessorService,
PreviewStep,
} from '@novu/application-generic';
import { EnvironmentRepository, EnvironmentVariableRepository } from '@novu/dal';
import {
ChannelTypeEnum,
LAYOUT_PREVIEW_EMAIL_STEP,
LAYOUT_PREVIEW_WORKFLOW_ID,
ResourceOriginEnum,
} from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { PreviewLayoutCommand } from './preview-layout.command';
import { PreviewLayoutUsecase } from './preview-layout.usecase';
import { enhanceBodyForPreview } from './preview-utils';
describe('PreviewLayoutUsecase', () => {
let getLayoutUseCaseMock: sinon.SinonStubbedInstance;
let createVariablesObjectMock: sinon.SinonStubbedInstance;
let controlValueSanitizerMock: sinon.SinonStubbedInstance;
let payloadProcessorMock: sinon.SinonStubbedInstance;
let payloadMergerMock: sinon.SinonStubbedInstance;
let previewStepUsecaseMock: sinon.SinonStubbedInstance;
let environmentVariableRepositoryMock: sinon.SinonStubbedInstance;
let environmentRepositoryMock: sinon.SinonStubbedInstance;
let previewLayoutUsecase: PreviewLayoutUsecase;
const mockUser = {
_id: 'user_id',
environmentId: 'env_id',
organizationId: 'org_id',
};
const mockLayout = {
_id: 'layout_id',
identifier: 'layout_identifier',
name: 'Test Layout',
controls: {
values: {
email: {
body: '{{content}}',
editorType: 'html',
},
},
},
variables: {
name: { type: 'string', default: 'John' },
email: { type: 'string', default: 'john@example.com' },
},
};
const mockLayoutWithoutControls = {
...mockLayout,
controls: {
values: {},
},
variables: {},
};
const mockControlValues = {
email: {
body: 'Custom {{content}}',
editorType: 'html',
},
};
const mockVariablesObject = {
name: 'Jane',
email: 'jane@example.com',
};
const mockSanitizedControls = {
email: {
body: 'Sanitized {{content}}',
editorType: 'html',
},
};
const mockPreviewTemplateData = {
controlValues: {
email: {
body: 'Processed {{content}}',
editorType: 'html',
},
} as LayoutControlType,
payloadExample: {
content: 'Test content',
user: { name: 'Test User' },
},
};
const mockPayloadExample = {
content: 'Merged content',
user: { name: 'Merged User' },
};
const mockCleanedPayloadExample = {
payload: { content: 'Cleaned content' },
subscriber: { email: 'test@example.com' },
};
const mockPreviewStepOutput = {
outputs: {
body: 'Final rendered content',
},
};
beforeEach(() => {
getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase);
createVariablesObjectMock = sinon.createStubInstance(CreateVariablesObject);
controlValueSanitizerMock = sinon.createStubInstance(ControlValueSanitizerService);
payloadProcessorMock = sinon.createStubInstance(PreviewPayloadProcessorService);
payloadMergerMock = sinon.createStubInstance(PayloadMergerService);
previewStepUsecaseMock = sinon.createStubInstance(PreviewStep);
environmentVariableRepositoryMock = sinon.createStubInstance(EnvironmentVariableRepository);
environmentRepositoryMock = sinon.createStubInstance(EnvironmentRepository);
previewLayoutUsecase = new PreviewLayoutUsecase(
getLayoutUseCaseMock as any,
createVariablesObjectMock as any,
controlValueSanitizerMock as any,
payloadProcessorMock as any,
payloadMergerMock as any,
previewStepUsecaseMock as any,
environmentVariableRepositoryMock as any,
environmentRepositoryMock as any
);
// Default mocks setup
getLayoutUseCaseMock.execute.resolves(mockLayout as any);
createVariablesObjectMock.execute.resolves(mockVariablesObject);
controlValueSanitizerMock.sanitizeControlsForPreview.returns(mockSanitizedControls);
controlValueSanitizerMock.processControlValues.returns({
previewTemplateData: mockPreviewTemplateData,
sanitizedControls: mockSanitizedControls,
});
payloadMergerMock.mergePayloadExample.resolves(mockPayloadExample);
payloadProcessorMock.cleanPreviewExamplePayload.returns(mockCleanedPayloadExample);
previewStepUsecaseMock.execute.resolves(mockPreviewStepOutput as any);
environmentVariableRepositoryMock.findByEnvironment.resolves([]);
environmentRepositoryMock.findByIdAndOrganization.resolves({
name: 'Development',
type: 'dev',
} as any);
});
afterEach(() => {
sinon.restore();
});
describe('execute', () => {
it('should successfully execute with provided control values', async () => {
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
previewPayload: { subscriber: { email: 'test@example.com' } },
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result.result).to.deep.equal({
preview: { body: 'Final rendered content' },
type: ChannelTypeEnum.EMAIL,
});
expect(result.previewPayloadExample).to.deep.equal(mockPayloadExample);
expect(result.schema).to.exist;
expect(result.schema?.type).to.equal('object');
expect(result.schema?.properties).to.have.keys(['subscriber', 'context']);
});
it('should use layout control values when command control values are not provided', async () => {
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {},
});
await previewLayoutUsecase.execute(command);
// Verify that layout control values were used
expect(createVariablesObjectMock.execute.calledOnce).to.be.true;
const createVariablesCall = createVariablesObjectMock.execute.firstCall.args[0];
expect(createVariablesCall.controlValues).to.deep.equal(Object.values(mockLayout.controls.values.email));
});
it('should use empty object when both command and layout control values are missing', async () => {
getLayoutUseCaseMock.execute.resolves(mockLayoutWithoutControls as any);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {},
});
await previewLayoutUsecase.execute(command);
// Verify empty control values were used
expect(controlValueSanitizerMock.sanitizeControlsForPreview.calledOnce).to.be.true;
const sanitizeCall = controlValueSanitizerMock.sanitizeControlsForPreview.firstCall.args[0];
expect(sanitizeCall).to.deep.equal({});
});
it('should call all dependencies with correct parameters', async () => {
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
previewPayload: { subscriber: { email: 'test@example.com' } },
},
});
await previewLayoutUsecase.execute(command);
// Verify getLayoutUseCase call
expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true;
const getLayoutCall = getLayoutUseCaseMock.execute.firstCall.args[0];
expect(getLayoutCall).to.deep.equal({
layoutIdOrInternalId: 'layout_id',
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
userId: mockUser._id,
});
// Verify createVariablesObject call
expect(createVariablesObjectMock.execute.calledOnce).to.be.true;
const createVariablesCall = createVariablesObjectMock.execute.firstCall.args[0];
expect(createVariablesCall.environmentId).to.equal(mockUser.environmentId);
expect(createVariablesCall.organizationId).to.equal(mockUser.organizationId);
expect(createVariablesCall.variableSchema).to.deep.equal(mockLayout.variables);
// Verify controlValueSanitizer calls
expect(controlValueSanitizerMock.sanitizeControlsForPreview.calledOnce).to.be.true;
const sanitizeCall = controlValueSanitizerMock.sanitizeControlsForPreview.firstCall.args;
expect(sanitizeCall[0]).to.deep.equal(mockControlValues);
expect(sanitizeCall[1]).to.equal('layout');
expect(sanitizeCall[2]).to.equal(ResourceOriginEnum.NOVU_CLOUD);
expect(controlValueSanitizerMock.processControlValues.calledOnce).to.be.true;
const processCall = controlValueSanitizerMock.processControlValues.firstCall.args;
expect(processCall[0]).to.deep.equal(mockSanitizedControls);
expect(processCall[1]).to.deep.equal(mockLayout.variables);
expect(processCall[2]).to.deep.equal(mockVariablesObject);
// Verify payloadMerger call
expect(payloadMergerMock.mergePayloadExample.calledOnce).to.be.true;
const mergeCall = payloadMergerMock.mergePayloadExample.firstCall.args[0];
expect(mergeCall.payloadExample).to.deep.equal(mockPreviewTemplateData.payloadExample);
expect(mergeCall.userPayloadExample).to.deep.equal(command.layoutPreviewRequestDto.previewPayload);
expect(mergeCall.user).to.deep.equal(command.user);
// Verify payloadProcessor call
expect(payloadProcessorMock.cleanPreviewExamplePayload.calledOnceWith(mockPayloadExample)).to.be.true;
// Verify previewStepUsecase call
expect(previewStepUsecaseMock.execute.calledOnce).to.be.true;
const previewCall = previewStepUsecaseMock.execute.firstCall.args[0];
expect(previewCall.payload).to.deep.equal(mockCleanedPayloadExample.payload);
expect(previewCall.subscriber).to.deep.equal(mockCleanedPayloadExample.subscriber);
expect(previewCall.controls).to.deep.equal({
subject: 'email-layout-preview',
body: enhanceBodyForPreview(
mockPreviewTemplateData.controlValues.email?.editorType ?? 'block',
mockPreviewTemplateData.controlValues.email?.body ?? ''
),
editorType: mockPreviewTemplateData.controlValues.email?.editorType,
});
expect(previewCall.environmentId).to.equal(mockUser.environmentId);
expect(previewCall.organizationId).to.equal(mockUser.organizationId);
expect(previewCall.stepId).to.equal(LAYOUT_PREVIEW_EMAIL_STEP);
expect(previewCall.userId).to.equal(mockUser._id);
expect(previewCall.workflowId).to.equal(LAYOUT_PREVIEW_WORKFLOW_ID);
expect(previewCall.workflowOrigin).to.equal(ResourceOriginEnum.NOVU_CLOUD);
expect(previewCall.state).to.deep.equal([]);
});
it('should handle missing previewPayload gracefully', async () => {
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
const mergeCall = payloadMergerMock.mergePayloadExample.firstCall.args[0];
expect(mergeCall.userPayloadExample).to.be.undefined;
});
it('should handle missing variables schema', async () => {
const layoutWithoutVariables = {
...mockLayout,
variables: undefined,
};
getLayoutUseCaseMock.execute.resolves(layoutWithoutVariables as any);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
const createVariablesCall = createVariablesObjectMock.execute.firstCall.args[0];
expect(createVariablesCall.variableSchema).to.deep.equal({});
});
it('should handle missing email controls in preview template data', async () => {
const templateDataWithoutEmail = {
...mockPreviewTemplateData,
controlValues: {} as LayoutControlType,
};
controlValueSanitizerMock.processControlValues.returns({
previewTemplateData: templateDataWithoutEmail,
sanitizedControls: mockSanitizedControls,
});
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
expect(previewStepUsecaseMock.execute.calledOnce).to.be.true;
const previewCall = previewStepUsecaseMock.execute.firstCall.args[0];
expect(previewCall.controls.body).to.eq('{}');
expect(previewCall.controls.editorType).to.eq('block');
});
it('should handle missing payload in cleaned payload example', async () => {
const cleanedPayloadWithoutPayload = {
payload: undefined,
subscriber: { email: 'test@example.com' },
};
payloadProcessorMock.cleanPreviewExamplePayload.returns(cleanedPayloadWithoutPayload);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
const previewCall = previewStepUsecaseMock.execute.firstCall.args[0];
expect(previewCall.payload).to.deep.equal({});
});
it('should handle missing subscriber in cleaned payload example', async () => {
const cleanedPayloadWithoutSubscriber = {
payload: { content: 'test' },
subscriber: undefined,
};
payloadProcessorMock.cleanPreviewExamplePayload.returns(cleanedPayloadWithoutSubscriber);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
const previewCall = previewStepUsecaseMock.execute.firstCall.args[0];
expect(previewCall.subscriber).to.deep.equal({});
});
describe('error handling', () => {
it('should return fallback response when getLayoutUseCase throws error', async () => {
try {
const error = new Error('Layout not found');
getLayoutUseCaseMock.execute.rejects(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'invalid_layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
} catch (error) {
expect(error.message).to.equal('Layout not found');
}
});
it('should return fallback response when createVariablesObject throws error', async () => {
const error = new Error('Variables creation failed');
createVariablesObjectMock.execute.rejects(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result).to.deep.equal({
result: {
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: {},
schema: null,
});
});
it('should return fallback response when controlValueSanitizer throws error', async () => {
const error = new Error('Control value sanitization failed');
controlValueSanitizerMock.sanitizeControlsForPreview.throws(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result).to.deep.equal({
result: {
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: {},
schema: null,
});
});
it('should return fallback response when payloadMerger throws error', async () => {
const error = new Error('Payload merge failed');
payloadMergerMock.mergePayloadExample.rejects(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result).to.deep.equal({
result: {
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: {},
schema: null,
});
});
it('should return fallback response when payloadProcessor throws error', async () => {
const error = new Error('Payload processing failed');
payloadProcessorMock.cleanPreviewExamplePayload.throws(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result).to.deep.equal({
result: {
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: {},
schema: null,
});
});
it('should return fallback response when previewStepUsecase throws error', async () => {
const error = new Error('Preview step execution failed');
previewStepUsecaseMock.execute.rejects(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result).to.deep.equal({
result: {
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: {},
schema: null,
});
});
it('should not call subsequent dependencies when early dependency fails', async () => {
const error = new Error('Early failure');
createVariablesObjectMock.execute.rejects(error);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
await previewLayoutUsecase.execute(command);
// Verify that dependencies after createVariablesObject were not called
expect(controlValueSanitizerMock.sanitizeControlsForPreview.called).to.be.false;
expect(payloadMergerMock.mergePayloadExample.called).to.be.false;
expect(previewStepUsecaseMock.execute.called).to.be.false;
});
});
describe('edge cases', () => {
it('should handle empty previewStepOutput', async () => {
previewStepUsecaseMock.execute.resolves({ outputs: {} } as any);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result.result.preview?.body).to.be.undefined;
expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL);
});
it('should handle null previewStepOutput outputs', async () => {
previewStepUsecaseMock.execute.resolves({ outputs: null } as any);
const command = PreviewLayoutCommand.create({
user: mockUser as any,
layoutIdOrInternalId: 'layout_id',
layoutPreviewRequestDto: {
controlValues: mockControlValues,
},
});
const result = await previewLayoutUsecase.execute(command);
expect(result.result.preview?.body).to.be.undefined;
expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL);
});
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-layout.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import {
buildContextSchema,
buildSubscriberSchema,
ControlValueSanitizerService,
CreateVariablesObject,
CreateVariablesObjectCommand,
EmailControlType,
GetLayoutCommand,
GetLayoutUseCase,
InstrumentUsecase,
LayoutControlType,
PayloadMergerService,
PlatformException,
PreviewPayloadProcessorService,
PreviewStep,
PreviewStepCommand,
resolveEnvironmentVariables,
} from '@novu/application-generic';
import { EnvironmentRepository, EnvironmentVariableRepository, JsonSchemaTypeEnum } from '@novu/dal';
import { ContextResolved } from '@novu/framework/internal';
import {
ChannelTypeEnum,
EnvironmentSystemVariables,
LAYOUT_PREVIEW_EMAIL_STEP,
LAYOUT_PREVIEW_WORKFLOW_ID,
ResourceOriginEnum,
} from '@novu/shared';
import { GenerateLayoutPreviewResponseDto } from '../../dtos/generate-layout-preview-response.dto';
import { PreviewLayoutCommand } from './preview-layout.command';
import { enhanceBodyForPreview } from './preview-utils';
@Injectable()
export class PreviewLayoutUsecase {
constructor(
private getLayoutUseCase: GetLayoutUseCase,
private createVariablesObject: CreateVariablesObject,
private controlValueSanitizer: ControlValueSanitizerService,
private payloadProcessor: PreviewPayloadProcessorService,
private payloadMerger: PayloadMergerService,
private previewStepUsecase: PreviewStep,
private readonly environmentVariableRepository: EnvironmentVariableRepository,
private readonly environmentRepository: EnvironmentRepository
) {}
@InstrumentUsecase()
async execute(command: PreviewLayoutCommand): Promise {
const layout = await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
layoutIdOrInternalId: command.layoutIdOrInternalId,
environmentId: command.user.environmentId,
organizationId: command.user.organizationId,
userId: command.user._id,
})
);
try {
const controlValues = command.layoutPreviewRequestDto.controlValues || layout.controls.values || {};
const variableSchema = layout.variables ?? {};
// extract all variables from the control values and build the variables object
const variablesObject = await this.createVariablesObject.execute(
CreateVariablesObjectCommand.create({
environmentId: command.user.environmentId,
organizationId: command.user.organizationId,
controlValues: Object.values(controlValues.email ?? {}),
variableSchema,
})
);
const sanitizedControls = this.controlValueSanitizer.sanitizeControlsForPreview(
controlValues as Record,
'layout',
ResourceOriginEnum.NOVU_CLOUD
);
const { previewTemplateData } = this.controlValueSanitizer.processControlValues(
sanitizedControls,
variableSchema,
variablesObject
);
const payloadExample = await this.payloadMerger.mergePayloadExample({
payloadExample: previewTemplateData.payloadExample,
userPayloadExample: command.layoutPreviewRequestDto.previewPayload,
user: command.user,
});
const cleanedPayloadExample = this.payloadProcessor.cleanPreviewExamplePayload(payloadExample);
const { email } = previewTemplateData.controlValues as LayoutControlType;
const editorType = email?.editorType ?? 'block';
const body = email?.body ?? (editorType === 'block' ? '{}' : '');
const [rawEnvVars, environmentEntity] = await Promise.all([
this.environmentVariableRepository.findByEnvironment(command.user.organizationId, command.user.environmentId),
this.environmentRepository.findByIdAndOrganization(command.user.environmentId, command.user.organizationId),
]);
if (!environmentEntity) throw new PlatformException('EnvironmentEntity not found');
const environmentSystemVars: EnvironmentSystemVariables = {
name: environmentEntity.name,
type: environmentEntity.type,
};
const envVars = {
...resolveEnvironmentVariables(rawEnvVars),
...environmentSystemVars,
};
const executeOutput = await this.previewStepUsecase.execute(
PreviewStepCommand.create({
payload: (cleanedPayloadExample.payload ?? {}) as Record,
subscriber: cleanedPayloadExample.subscriber ?? {},
context: (cleanedPayloadExample.context ?? {}) as ContextResolved,
// mapping the email layout controls to the email step controls
controls: {
subject: 'email-layout-preview',
body: enhanceBodyForPreview(editorType, body),
editorType,
} as EmailControlType,
environmentId: command.user.environmentId,
organizationId: command.user.organizationId,
stepId: LAYOUT_PREVIEW_EMAIL_STEP,
userId: command.user._id,
workflowId: LAYOUT_PREVIEW_WORKFLOW_ID,
workflowOrigin: ResourceOriginEnum.NOVU_CLOUD,
layoutId: layout.layoutId,
state: [],
env: envVars,
})
);
const { body: previewBody } = executeOutput.outputs as any;
// Generate schema from the preview payload example
const schema = {
type: JsonSchemaTypeEnum.OBJECT,
properties: {
subscriber: buildSubscriberSchema(payloadExample.subscriber),
context: buildContextSchema(payloadExample.context),
},
};
return {
result: {
preview: { body: previewBody },
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: payloadExample,
schema,
};
} catch (error) {
/*
* If preview execution fails, still return valid schema and payload example
* but with an empty preview result
*/
return {
result: {
type: ChannelTypeEnum.EMAIL,
},
previewPayloadExample: {},
schema: null,
};
}
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-utils.ts
================================================
import { replaceMailyNodesByCondition } from '@novu/application-generic';
import { JSONContent as MailyJSONContent } from '@novu/maily-render';
import { LAYOUT_CONTENT_VARIABLE, LAYOUT_PREVIEW_CONTENT_PLACEHOLDER } from '@novu/shared';
export const enhanceBodyForPreview = (editorType: string, body: string) => {
if (editorType === 'html') {
return body?.replace(
new RegExp(`\\{\\{\\s*${LAYOUT_CONTENT_VARIABLE}\\s*\\}\\}`),
LAYOUT_PREVIEW_CONTENT_PLACEHOLDER
);
}
return JSON.stringify(
replaceMailyNodesByCondition(
body,
(node) => node.type === 'variable' && node.attrs?.id === LAYOUT_CONTENT_VARIABLE,
(node) => {
return {
type: 'text',
text: LAYOUT_PREVIEW_CONTENT_PLACEHOLDER,
attrs: {
...node.attrs,
shouldDangerouslySetInnerHTML: true,
},
} satisfies MailyJSONContent;
}
)
);
};
================================================
FILE: apps/api/src/app/layouts-v2/usecases/sync-to-environment/index.ts
================================================
export * from './layout-sync-to-environment.command';
export * from './layout-sync-to-environment.usecase';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/sync-to-environment/layout-sync-to-environment.command.ts
================================================
import { EnvironmentWithUserObjectCommand } from '@novu/application-generic';
import { ClientSession } from '@novu/dal';
import { Exclude } from 'class-transformer';
import { IsDefined, IsOptional, IsString } from 'class-validator';
export class LayoutSyncToEnvironmentCommand extends EnvironmentWithUserObjectCommand {
@IsString()
@IsDefined()
layoutIdOrInternalId: string;
@IsString()
@IsDefined()
targetEnvironmentId: string;
/**
* Exclude session from the command to avoid serializing it in the response
*/
@IsOptional()
@Exclude()
session?: ClientSession | null;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/sync-to-environment/layout-sync-to-environment.usecase.ts
================================================
import { BadRequestException, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
GetLayoutCommand,
GetLayoutUseCase,
Instrument,
InstrumentUsecase,
LayoutResponseDto,
} from '@novu/application-generic';
import { LocalizationResourceEnum } from '@novu/dal';
import { ResourceOriginEnum } from '@novu/shared';
import { UpsertLayout, UpsertLayoutCommand, UpsertLayoutDataCommand } from '../upsert-layout';
import { LayoutSyncToEnvironmentCommand } from './layout-sync-to-environment.command';
const SYNCABLE_LAYOUT_ORIGINS = [ResourceOriginEnum.NOVU_CLOUD];
class LayoutNotSyncableException extends BadRequestException {
constructor(layout: Pick) {
const reason = `origin '${layout.origin}' is not allowed (must be one of: ${SYNCABLE_LAYOUT_ORIGINS.join(', ')})`;
super({
message: `Cannot sync layout: ${reason}`,
layoutId: layout.layoutId,
origin: layout.origin,
allowedOrigins: SYNCABLE_LAYOUT_ORIGINS,
});
}
}
@Injectable()
export class LayoutSyncToEnvironmentUseCase {
constructor(
private getLayoutUseCase: GetLayoutUseCase,
private upsertLayoutUseCase: UpsertLayout,
private moduleRef: ModuleRef
) {}
@InstrumentUsecase()
async execute(command: LayoutSyncToEnvironmentCommand): Promise {
if (command.user.environmentId === command.targetEnvironmentId) {
throw new BadRequestException('Cannot sync layout to the same environment');
}
const sourceLayout = await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
environmentId: command.user.environmentId,
organizationId: command.user.organizationId,
layoutIdOrInternalId: command.layoutIdOrInternalId,
})
);
if (!this.isSyncable(sourceLayout)) {
throw new LayoutNotSyncableException(sourceLayout);
}
const externalId = sourceLayout.layoutId;
const targetLayout = await this.findLayoutInTargetEnvironment(command, externalId);
const layoutDto = await this.buildRequestDto(sourceLayout);
const upsertedLayout = await this.upsertLayoutUseCase.execute(
UpsertLayoutCommand.create({
environmentId: command.targetEnvironmentId,
organizationId: command.user.organizationId,
userId: command.user._id,
layoutIdOrInternalId: targetLayout?.layoutId,
layoutDto,
})
);
await this.publishTranslationGroup(sourceLayout.layoutId, LocalizationResourceEnum.LAYOUT, command);
return upsertedLayout;
}
private isSyncable(layout: LayoutResponseDto): boolean {
return SYNCABLE_LAYOUT_ORIGINS.includes(layout.origin);
}
private async buildRequestDto(sourceLayout: LayoutResponseDto): Promise {
return {
layoutId: sourceLayout.layoutId,
name: sourceLayout.name,
isTranslationEnabled: sourceLayout.isTranslationEnabled,
controlValues: sourceLayout.controls?.values,
};
}
private async publishTranslationGroup(
resourceId: string,
resourceType: LocalizationResourceEnum,
command: LayoutSyncToEnvironmentCommand
): Promise {
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true';
const isSelfHosted = process.env.IS_SELF_HOSTED === 'true';
if (!isEnterprise || isSelfHosted) {
return;
}
const publishTranslationGroup = this.moduleRef.get(require('@novu/ee-translation')?.PublishTranslationGroup, {
strict: false,
});
const { user, targetEnvironmentId } = command;
await publishTranslationGroup.execute({
user,
resourceId,
resourceType,
sourceEnvironmentId: user.environmentId,
targetEnvironmentId,
});
}
@Instrument()
private async findLayoutInTargetEnvironment(
command: LayoutSyncToEnvironmentCommand,
externalId: string
): Promise {
try {
return await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
environmentId: command.targetEnvironmentId,
organizationId: command.user.organizationId,
layoutIdOrInternalId: externalId,
})
);
} catch (error) {
return undefined;
}
}
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/index.ts
================================================
export * from './upsert-layout.command';
export * from './upsert-layout.usecase';
================================================
FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/upsert-layout.command.ts
================================================
import {
EnvironmentWithUserCommand,
LayoutControlValuesDto,
LayoutCreationSourceEnum,
} from '@novu/application-generic';
import { MAX_NAME_LENGTH } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, Length, ValidateNested } from 'class-validator';
export class UpsertLayoutDataCommand {
@IsString()
@IsOptional()
layoutId?: string;
@IsString()
@IsNotEmpty()
@Length(1, MAX_NAME_LENGTH)
name: string;
@IsOptional()
@IsBoolean()
isTranslationEnabled?: boolean;
@IsOptional()
@IsEnum(LayoutCreationSourceEnum)
__source?: LayoutCreationSourceEnum;
@IsOptional()
controlValues?: LayoutControlValuesDto | null;
}
export class UpsertLayoutCommand extends EnvironmentWithUserCommand {
@ValidateNested()
@Type(() => UpsertLayoutDataCommand)
layoutDto: UpsertLayoutDataCommand;
@IsOptional()
@IsString()
layoutIdOrInternalId?: string;
}
================================================
FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/upsert-layout.usecase.spec.ts
================================================
import { BadRequestException } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
AnalyticsService,
GetLayoutUseCase,
GetLayoutUseCase as GetLayoutUseCaseV0,
JSONSchemaDto,
LayoutCreationSourceEnum,
LayoutDtoV0,
layoutControlSchema,
mapLayoutToResponseDto,
PinoLogger,
UpsertControlValuesUseCase,
} from '@novu/application-generic';
import { ControlValuesRepository, JsonSchemaTypeEnum, LayoutRepository } from '@novu/dal';
import {
ChannelTypeEnum,
ContentIssueEnum,
ControlValuesLevelEnum,
LayoutControlValuesDto,
LayoutIssuesDto,
ResourceOriginEnum,
ResourceTypeEnum,
slugify,
} from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { CreateLayoutUseCase, UpdateLayoutUseCase } from '../../../layouts-v1/usecases';
import { BuildLayoutIssuesUsecase } from '../build-layout-issues/build-layout-issues.usecase';
import { UpsertLayoutCommand } from './upsert-layout.command';
import { UpsertLayout } from './upsert-layout.usecase';
// Mock the utility functions
const isStringifiedMailyJSONContentStub = sinon.stub();
// Mock modules using require to ensure proper stubbing
sinon
.stub(require('@novu/application-generic'), 'isStringifiedMailyJSONContent')
.callsFake(isStringifiedMailyJSONContentStub);
function setupTranslationMocks(moduleRef: sinon.SinonStubbedInstance): sinon.SinonStub {
const manageTranslationsExecuteStub = sinon.stub().resolves();
(moduleRef as any).get = sinon.stub().returns({
execute: manageTranslationsExecuteStub,
});
return manageTranslationsExecuteStub;
}
describe('UpsertLayoutUseCase', () => {
let getLayoutUseV0CaseMock: sinon.SinonStubbedInstance;
let createLayoutUseCaseMock: sinon.SinonStubbedInstance;
let updateLayoutUseCaseMock: sinon.SinonStubbedInstance;
let controlValuesRepositoryMock: sinon.SinonStubbedInstance;
let upsertControlValuesUseCaseMock: sinon.SinonStubbedInstance;
let layoutRepositoryMock: sinon.SinonStubbedInstance;
let analyticsServiceMock: sinon.SinonStubbedInstance;
let buildLayoutIssuesUsecaseMock: sinon.SinonStubbedInstance;
let getLayoutUseCaseMock: sinon.SinonStubbedInstance;
let moduleRefMock: sinon.SinonStubbedInstance;
let pinoLoggerMock: sinon.SinonStubbedInstance;
let upsertLayoutUseCase: UpsertLayout;
const mockUser = {
_id: 'user_id',
environmentId: 'env_id',
organizationId: 'org_id',
};
const mockLayoutDto = {
name: 'Test Layout',
__source: LayoutCreationSourceEnum.DASHBOARD,
controlValues: {
email: {
body: '{{content}}',
editorType: 'html' as 'html' | 'block',
},
} as LayoutControlValuesDto,
};
const mockExistingLayout: LayoutDtoV0 & { _id: string } = {
_id: 'existing_layout_id',
identifier: 'existing_layout_identifier',
name: 'Existing Layout',
_creatorId: 'creator_id',
isDefault: false,
isDeleted: false,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
channel: ChannelTypeEnum.EMAIL,
controls: {
dataSchema: layoutControlSchema,
uiSchema: {},
},
};
const mockCreatedLayout: LayoutDtoV0 & { _id: string } = {
_id: 'new_layout_id',
identifier: 'test-layout',
name: 'Test Layout',
_creatorId: 'creator_id',
isDefault: true,
isDeleted: false,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
_environmentId: 'env_id',
_organizationId: 'org_id',
origin: ResourceOriginEnum.NOVU_CLOUD,
type: ResourceTypeEnum.BRIDGE,
channel: ChannelTypeEnum.EMAIL,
controls: {
dataSchema: layoutControlSchema,
uiSchema: {},
},
};
const mockControlValues = {
_id: 'control_values_id',
controls: {
email: {
body: '{{content}}',
editorType: 'html',
},
},
};
const mockLayoutVariablesSchema: JSONSchemaDto = {
type: JsonSchemaTypeEnum.OBJECT,
properties: {
subscriber: {
type: JsonSchemaTypeEnum.OBJECT,
properties: {
email: { type: JsonSchemaTypeEnum.STRING },
firstName: { type: JsonSchemaTypeEnum.STRING },
},
},
content: { type: JsonSchemaTypeEnum.STRING },
},
};
beforeEach(() => {
getLayoutUseV0CaseMock = sinon.createStubInstance(GetLayoutUseCaseV0);
createLayoutUseCaseMock = sinon.createStubInstance(CreateLayoutUseCase);
updateLayoutUseCaseMock = sinon.createStubInstance(UpdateLayoutUseCase);
controlValuesRepositoryMock = sinon.createStubInstance(ControlValuesRepository);
upsertControlValuesUseCaseMock = sinon.createStubInstance(UpsertControlValuesUseCase);
layoutRepositoryMock = sinon.createStubInstance(LayoutRepository);
analyticsServiceMock = sinon.createStubInstance(AnalyticsService);
buildLayoutIssuesUsecaseMock = sinon.createStubInstance(BuildLayoutIssuesUsecase);
getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase);
moduleRefMock = sinon.createStubInstance(ModuleRef);
pinoLoggerMock = sinon.createStubInstance(PinoLogger);
setupTranslationMocks(moduleRefMock as any);
upsertLayoutUseCase = new UpsertLayout(
getLayoutUseV0CaseMock as any,
createLayoutUseCaseMock as any,
updateLayoutUseCaseMock as any,
controlValuesRepositoryMock as any,
upsertControlValuesUseCaseMock as any,
layoutRepositoryMock as any,
analyticsServiceMock as any,
buildLayoutIssuesUsecaseMock as any,
getLayoutUseCaseMock as any,
moduleRefMock as any,
pinoLoggerMock as any
);
// Default mocks setup
isStringifiedMailyJSONContentStub.returns(false);
buildLayoutIssuesUsecaseMock.execute.resolves({} as LayoutIssuesDto);
upsertControlValuesUseCaseMock.execute.resolves(mockControlValues as any);
layoutRepositoryMock.findOne.resolves(undefined);
});
afterEach(() => {
sinon.restore();
isStringifiedMailyJSONContentStub.reset();
});
describe('execute', () => {
describe('create layout path', () => {
beforeEach(() => {
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
getLayoutUseCaseMock.execute.resolves(
mapLayoutToResponseDto({
layout: mockCreatedLayout,
controlValues: mockControlValues,
variables: mockLayoutVariablesSchema,
})
);
});
it('should successfully create a new layout when no existing layout found', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
const result = await upsertLayoutUseCase.execute(command);
expect(result).to.exist;
expect(result._id).to.equal(mockCreatedLayout._id);
expect(result.name).to.equal(mockCreatedLayout.name);
expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true;
expect(updateLayoutUseCaseMock.execute.called).to.be.false;
});
it('should call createLayoutUseCase with correct parameters', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true;
const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0];
expect(createCommand.environmentId).to.equal(mockUser.environmentId);
expect(createCommand.organizationId).to.equal(mockUser.organizationId);
expect(createCommand.userId).to.equal(mockUser._id);
expect(createCommand.name).to.equal(mockLayoutDto.name);
expect(createCommand.identifier).to.equal(slugify(mockLayoutDto.name));
expect(createCommand.type).to.equal(ResourceTypeEnum.BRIDGE);
expect(createCommand.origin).to.equal(ResourceOriginEnum.NOVU_CLOUD);
expect(createCommand.isDefault).to.be.true;
});
it('should use custom layoutId when provided instead of slugified name', async () => {
const customLayoutId = 'custom-layout-identifier';
const layoutDtoWithCustomId = {
...mockLayoutDto,
layoutId: customLayoutId,
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: layoutDtoWithCustomId,
});
await upsertLayoutUseCase.execute(command);
expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true;
const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0];
expect(createCommand.identifier).to.equal(customLayoutId);
expect(createCommand.name).to.equal(mockLayoutDto.name);
});
it('should set isDefault to false when a default layout already exists', async () => {
const existingDefaultLayout = { ...mockExistingLayout, isDefault: true };
layoutRepositoryMock.findOne.resolves(existingDefaultLayout as any);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
await upsertLayoutUseCase.execute(command);
const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0];
expect(createCommand.isDefault).to.be.false;
});
it('should track "Layout Create" analytics event', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true;
const [eventName, userId, props] = analyticsServiceMock.mixpanelTrack.firstCall.args;
expect(eventName).to.equal('Layout Create - [Layouts]');
expect(userId).to.equal(mockUser._id);
expect(props).to.deep.equal({
_organization: mockUser.organizationId,
name: mockLayoutDto.name,
source: mockLayoutDto.__source,
});
});
});
describe('update layout path', () => {
beforeEach(() => {
getLayoutUseV0CaseMock.execute.resolves(mockExistingLayout);
updateLayoutUseCaseMock.execute.resolves(mockExistingLayout);
getLayoutUseCaseMock.execute.resolves(
mapLayoutToResponseDto({
layout: mockExistingLayout,
controlValues: mockControlValues,
variables: mockLayoutVariablesSchema,
})
);
});
it('should successfully update an existing layout when layoutIdOrInternalId provided', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
const result = await upsertLayoutUseCase.execute(command);
expect(result).to.exist;
expect(result._id).to.equal(mockExistingLayout._id);
expect(updateLayoutUseCaseMock.execute.calledOnce).to.be.true;
expect(createLayoutUseCaseMock.execute.called).to.be.false;
});
it('should call getLayoutUseCase with correct parameters', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
await upsertLayoutUseCase.execute(command);
expect(getLayoutUseV0CaseMock.execute.calledOnce).to.be.true;
const getCommand = getLayoutUseV0CaseMock.execute.firstCall.args[0];
expect(getCommand.layoutIdOrInternalId).to.equal('existing_layout_id');
expect(getCommand.environmentId).to.equal(mockUser.environmentId);
expect(getCommand.organizationId).to.equal(mockUser.organizationId);
expect(getCommand.type).to.equal(ResourceTypeEnum.BRIDGE);
expect(getCommand.origin).to.equal(ResourceOriginEnum.NOVU_CLOUD);
});
it('should call updateLayoutUseCase with correct parameters', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
await upsertLayoutUseCase.execute(command);
expect(updateLayoutUseCaseMock.execute.calledOnce).to.be.true;
const updateCommand = updateLayoutUseCaseMock.execute.firstCall.args[0];
expect(updateCommand.environmentId).to.equal(mockUser.environmentId);
expect(updateCommand.organizationId).to.equal(mockUser.organizationId);
expect(updateCommand.userId).to.equal(mockUser._id);
expect(updateCommand.layoutId).to.equal(mockExistingLayout._id);
expect(updateCommand.name).to.equal(mockLayoutDto.name);
expect(updateCommand.type).to.equal(mockExistingLayout.type);
expect(updateCommand.origin).to.equal(mockExistingLayout.origin);
});
it('should track "Layout Update" analytics event', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
await upsertLayoutUseCase.execute(command);
expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true;
const [eventName, userId, props] = analyticsServiceMock.mixpanelTrack.firstCall.args;
expect(eventName).to.equal('Layout Update - [Layouts]');
expect(userId).to.equal(mockUser._id);
expect(props).to.deep.equal({
_organization: mockUser.organizationId,
name: mockLayoutDto.name,
source: mockLayoutDto.__source,
});
});
});
describe('control values handling', () => {
beforeEach(() => {
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
getLayoutUseCaseMock.execute.resolves(
mapLayoutToResponseDto({
layout: mockCreatedLayout,
controlValues: mockControlValues,
variables: mockLayoutVariablesSchema,
})
);
});
it('should upsert control values when provided', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertControlValuesUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.organizationId).to.equal(mockUser.organizationId);
expect(upsertCommand.environmentId).to.equal(mockUser.environmentId);
expect(upsertCommand.layoutId).to.equal(mockCreatedLayout._id);
expect(upsertCommand.level).to.equal(ControlValuesLevelEnum.LAYOUT_CONTROLS);
expect(upsertCommand.newControlValues).to.deep.equal(mockLayoutDto.controlValues);
});
it('should delete control values when set to null', async () => {
const layoutDtoWithNullControls = {
...mockLayoutDto,
controlValues: null,
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: layoutDtoWithNullControls,
});
await upsertLayoutUseCase.execute(command);
expect(controlValuesRepositoryMock.delete.calledOnce).to.be.true;
const deleteParams = controlValuesRepositoryMock.delete.firstCall.args[0];
expect(deleteParams._environmentId).to.equal(mockUser.environmentId);
expect(deleteParams._organizationId).to.equal(mockUser.organizationId);
expect(deleteParams._layoutId).to.equal(mockCreatedLayout._id);
expect(deleteParams.level).to.equal(ControlValuesLevelEnum.LAYOUT_CONTROLS);
});
it('should handle empty control values', async () => {
const layoutDtoWithEmptyControls = {
...mockLayoutDto,
controlValues: {},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: layoutDtoWithEmptyControls,
});
await upsertLayoutUseCase.execute(command);
expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.true;
const upsertCommand = upsertControlValuesUseCaseMock.execute.firstCall.args[0];
expect(upsertCommand.newControlValues).to.deep.equal({});
});
});
});
describe('validation', () => {
describe('email content validation', () => {
beforeEach(() => {
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
getLayoutUseCaseMock.execute.resolves(
mapLayoutToResponseDto({
layout: mockCreatedLayout,
controlValues: mockControlValues,
variables: mockLayoutVariablesSchema,
})
);
});
it('should validate HTML content correctly', async () => {
const htmlLayoutDto = {
...mockLayoutDto,
controlValues: {
email: {
body: 'Valid HTML',
editorType: 'html' as 'html' | 'block',
},
},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: htmlLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true;
});
it('should throw BadRequestException for invalid HTML content with html editor type', async () => {
const invalidHtmlLayoutDto = {
...mockLayoutDto,
controlValues: {
email: {
body: 'Invalid HTML content',
editorType: 'html' as 'html' | 'block',
},
},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: invalidHtmlLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown BadRequestException');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Content must be a valid HTML content');
}
});
it('should validate Maily JSON content correctly', async () => {
isStringifiedMailyJSONContentStub.returns(true);
const mailyLayoutDto = {
...mockLayoutDto,
controlValues: {
email: {
body: '{"type":"doc","content":[]}',
editorType: 'block' as 'html' | 'block',
},
},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mailyLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true;
});
it('should throw BadRequestException for invalid Maily JSON content with block editor type', async () => {
isStringifiedMailyJSONContentStub.returns(false);
const invalidMailyLayoutDto = {
...mockLayoutDto,
controlValues: {
email: {
body: 'Invalid Maily JSON',
editorType: 'block' as 'html' | 'block',
},
},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: invalidMailyLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown BadRequestException');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Content must be a valid Maily JSON content');
}
});
it('should throw BadRequestException for content that is neither HTML nor Maily JSON', async () => {
isStringifiedMailyJSONContentStub.returns(false);
const invalidLayoutDto = {
...mockLayoutDto,
controlValues: {
email: {
body: 'Neither HTML nor Maily JSON',
editorType: 'html' as 'html' | 'block',
},
},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: invalidLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown BadRequestException');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Content must be a valid HTML content');
}
});
it('should skip email validation when no email controls provided', async () => {
const noEmailLayoutDto = {
...mockLayoutDto,
controlValues: {},
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: noEmailLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true;
});
});
describe('layout issues validation', () => {
beforeEach(() => {
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
getLayoutUseCaseMock.execute.resolves(
mapLayoutToResponseDto({
layout: mockCreatedLayout,
controlValues: mockControlValues,
variables: mockLayoutVariablesSchema,
})
);
});
it('should call buildLayoutIssuesUsecase with correct parameters', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
await upsertLayoutUseCase.execute(command);
expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true;
const issuesCommand = buildLayoutIssuesUsecaseMock.execute.firstCall.args[0];
expect(issuesCommand.controlSchema).to.deep.equal(layoutControlSchema);
expect(issuesCommand.controlValues).to.deep.equal(mockLayoutDto.controlValues);
expect(issuesCommand.resourceOrigin).to.equal(ResourceOriginEnum.NOVU_CLOUD);
expect(issuesCommand.userId).to.deep.equal(mockUser._id);
});
it('should use EXTERNAL origin when __source is not provided', async () => {
const layoutDtoWithoutSource = {
...mockLayoutDto,
__source: undefined,
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: layoutDtoWithoutSource,
});
await upsertLayoutUseCase.execute(command);
const issuesCommand = buildLayoutIssuesUsecaseMock.execute.firstCall.args[0];
expect(issuesCommand.resourceOrigin).to.equal(ResourceOriginEnum.EXTERNAL);
});
it('should throw BadRequestException when layout issues exist', async () => {
const mockIssues: LayoutIssuesDto = {
controls: {
'email.body': [
{
message: 'Body is required',
issueType: ContentIssueEnum.MISSING_VALUE,
},
],
'email.editorType': [
{
message: 'Invalid editor type',
issueType: ContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE,
},
],
},
};
buildLayoutIssuesUsecaseMock.execute.resolves(mockIssues);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown BadRequestException');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.response).to.deep.equal({ message: 'Layout has validation issues', ...mockIssues });
}
});
});
});
describe('error handling', () => {
it('should propagate errors from getLayoutUseCase', async () => {
const error = new Error('Failed to get layout');
getLayoutUseV0CaseMock.execute.rejects(error);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown error');
} catch (thrownError) {
expect(thrownError).to.equal(error);
}
});
it('should propagate errors from createLayoutUseCase', async () => {
const error = new Error('Failed to create layout');
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.rejects(error);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown error');
} catch (thrownError) {
expect(thrownError).to.equal(error);
}
});
it('should propagate errors from updateLayoutUseCase', async () => {
const error = new Error('Failed to update layout');
getLayoutUseV0CaseMock.execute.resolves(mockExistingLayout);
updateLayoutUseCaseMock.execute.rejects(error);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown error');
} catch (thrownError) {
expect(thrownError).to.equal(error);
}
});
it('should propagate errors from upsertControlValuesUseCase', async () => {
const error = new Error('Failed to upsert control values');
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
upsertControlValuesUseCaseMock.execute.rejects(error);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown error');
} catch (thrownError) {
expect(thrownError).to.equal(error);
}
});
it('should propagate errors from getLayoutUseCase', async () => {
const error = new Error('Failed to generate schema');
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
getLayoutUseCaseMock.execute.rejects(error);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
try {
await upsertLayoutUseCase.execute(command);
expect.fail('Should have thrown error');
} catch (thrownError) {
expect(thrownError).to.equal(error);
}
});
});
describe('edge cases', () => {
it('should handle layout without type and origin in update path', async () => {
const layoutWithoutTypeAndOrigin = {
...mockExistingLayout,
type: undefined,
origin: undefined,
};
getLayoutUseV0CaseMock.execute.resolves(layoutWithoutTypeAndOrigin);
updateLayoutUseCaseMock.execute.resolves(layoutWithoutTypeAndOrigin);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: 'existing_layout_id',
});
await upsertLayoutUseCase.execute(command);
const updateCommand = updateLayoutUseCaseMock.execute.firstCall.args[0];
expect(updateCommand.type).to.equal(ResourceTypeEnum.BRIDGE);
expect(updateCommand.origin).to.equal(ResourceOriginEnum.NOVU_CLOUD);
});
it('should handle undefined control values in command', async () => {
const layoutDtoWithUndefinedControls = {
...mockLayoutDto,
controlValues: undefined,
};
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: layoutDtoWithUndefinedControls,
});
await upsertLayoutUseCase.execute(command);
expect(controlValuesRepositoryMock.delete.calledOnce).to.be.false;
expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.false;
});
it('should handle empty string layoutIdOrInternalId', async () => {
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
layoutIdOrInternalId: '',
});
await upsertLayoutUseCase.execute(command);
// Should follow create path since empty string is falsy
expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true;
expect(getLayoutUseV0CaseMock.execute.called).to.be.false;
});
});
describe('parameter verification', () => {
beforeEach(() => {
getLayoutUseV0CaseMock.execute.resolves(undefined);
createLayoutUseCaseMock.execute.resolves(mockCreatedLayout);
});
it('should pass all required parameters to dependencies', async () => {
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: mockLayoutDto,
});
await upsertLayoutUseCase.execute(command);
// Verify all major dependencies were called with correct basic parameters
expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true;
expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true;
expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.true;
expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true;
expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true;
});
it('should use correct identifiers and names', async () => {
const customLayoutDto = {
...mockLayoutDto,
name: 'Custom Layout Name',
};
const command = UpsertLayoutCommand.create({
userId: mockUser._id,
environmentId: mockUser.environmentId,
organizationId: mockUser.organizationId,
layoutDto: customLayoutDto,
});
await upsertLayoutUseCase.execute(command);
const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0];
expect(createCommand.name).to.equal('Custom Layout Name');
expect(createCommand.identifier).to.equal(slugify('Custom Layout Name'));
});
});
});
================================================
FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/upsert-layout.usecase.ts
================================================
import { BadRequestException, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
AnalyticsService,
GetLayoutCommand,
GetLayoutCommandV0,
GetLayoutUseCase,
GetLayoutUseCaseV0,
InstrumentUsecase,
isStringifiedMailyJSONContent,
LayoutDtoV0,
LayoutResponseDto,
layoutControlSchema,
PinoLogger,
UpsertControlValuesCommand,
UpsertControlValuesUseCase,
} from '@novu/application-generic';
import { ControlValuesRepository, LayoutRepository, LocalizationResourceEnum } from '@novu/dal';
import {
ControlValuesLevelEnum,
LayoutControlValuesDto,
ResourceOriginEnum,
ResourceTypeEnum,
slugify,
} from '@novu/shared';
import {
CreateLayoutCommand,
CreateLayoutUseCase,
UpdateLayoutCommand,
UpdateLayoutUseCase,
} from '../../../layouts-v1/usecases';
import { MANAGE_TRANSLATIONS } from '../../../shared/constants';
import { BuildLayoutIssuesCommand } from '../build-layout-issues/build-layout-issues.command';
import { BuildLayoutIssuesUsecase } from '../build-layout-issues/build-layout-issues.usecase';
import { UpsertLayoutCommand } from './upsert-layout.command';
@Injectable()
export class UpsertLayout {
constructor(
private getLayoutUseCaseV0: GetLayoutUseCaseV0,
private createLayoutUseCaseV0: CreateLayoutUseCase,
private updateLayoutUseCaseV0: UpdateLayoutUseCase,
private controlValuesRepository: ControlValuesRepository,
private upsertControlValuesUseCase: UpsertControlValuesUseCase,
private layoutRepository: LayoutRepository,
private analyticsService: AnalyticsService,
private buildLayoutIssuesUsecase: BuildLayoutIssuesUsecase,
private getLayoutUseCase: GetLayoutUseCase,
private moduleRef: ModuleRef,
private logger: PinoLogger
) {}
@InstrumentUsecase()
async execute(command: UpsertLayoutCommand): Promise {
const { controlValues } = command.layoutDto;
await this.validateLayout({
command,
controlValues,
});
const existingLayout = command.layoutIdOrInternalId
? await this.getLayoutUseCaseV0.execute(
GetLayoutCommandV0.create({
layoutIdOrInternalId: command.layoutIdOrInternalId,
environmentId: command.environmentId,
organizationId: command.organizationId,
type: ResourceTypeEnum.BRIDGE,
origin: ResourceOriginEnum.NOVU_CLOUD,
})
)
: null;
let upsertedLayout: LayoutDtoV0;
if (existingLayout) {
this.mixpanelTrack(command, 'Layout Update - [Layouts]');
upsertedLayout = await this.updateLayoutUseCaseV0.execute(
UpdateLayoutCommand.create({
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
layoutId: existingLayout._id!,
name: command.layoutDto.name,
type: existingLayout.type ?? ResourceTypeEnum.BRIDGE,
origin: existingLayout.origin ?? ResourceOriginEnum.NOVU_CLOUD,
})
);
} else {
this.mixpanelTrack(command, 'Layout Create - [Layouts]');
const defaultLayout = await this.layoutRepository.findOne({
_organizationId: command.organizationId,
_environmentId: command.environmentId,
type: ResourceTypeEnum.BRIDGE,
origin: ResourceOriginEnum.NOVU_CLOUD,
isDefault: true,
});
upsertedLayout = await this.createLayoutUseCaseV0.execute(
CreateLayoutCommand.create({
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
name: command.layoutDto.name,
identifier: command.layoutDto.layoutId || slugify(command.layoutDto.name),
type: ResourceTypeEnum.BRIDGE,
origin: ResourceOriginEnum.NOVU_CLOUD,
isDefault: !defaultLayout,
})
);
}
await this.toggleTranslationsForLayout(command, upsertedLayout);
await this.upsertControlValues(command, upsertedLayout._id!);
return await this.getLayoutUseCase.execute(
GetLayoutCommand.create({
layoutIdOrInternalId: upsertedLayout.identifier,
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
})
);
}
private async validateLayout({
command,
controlValues,
}: {
command: UpsertLayoutCommand;
controlValues?: LayoutControlValuesDto | null;
}) {
if (!controlValues) {
return;
}
if (controlValues.email) {
const { body: content, editorType } = controlValues.email;
const isMailyContent = isStringifiedMailyJSONContent(content);
const isHtmlContent =
content.includes('') &&
content.includes('');
if (!isMailyContent && !isHtmlContent) {
throw new BadRequestException(
editorType === 'html' ? 'Content must be a valid HTML content' : 'Content must be a valid Maily JSON content'
);
}
if (editorType === 'html' && !isHtmlContent) {
throw new BadRequestException('Content must be a valid HTML content');
} else if (editorType === 'block' && !isMailyContent) {
throw new BadRequestException('Content must be a valid Maily JSON content');
}
}
const issues = await this.buildLayoutIssuesUsecase.execute(
BuildLayoutIssuesCommand.create({
controlSchema: layoutControlSchema,
controlValues,
resourceOrigin: command.layoutDto.__source ? ResourceOriginEnum.NOVU_CLOUD : ResourceOriginEnum.EXTERNAL,
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
})
);
if (Object.keys(issues).length > 0) {
throw new BadRequestException({ message: 'Layout has validation issues', ...issues });
}
}
private async upsertControlValues(command: UpsertLayoutCommand, layoutId: string) {
const {
layoutDto: { controlValues },
} = command;
const doNothing = typeof controlValues === 'undefined';
if (doNothing) {
return null;
}
const shouldDelete = controlValues === null;
if (shouldDelete) {
this.controlValuesRepository.delete({
_environmentId: command.environmentId,
_organizationId: command.organizationId,
_layoutId: layoutId,
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
});
return null;
}
return this.upsertControlValuesUseCase.execute(
UpsertControlValuesCommand.create({
organizationId: command.organizationId,
environmentId: command.environmentId,
layoutId,
level: ControlValuesLevelEnum.LAYOUT_CONTROLS,
newControlValues: controlValues as unknown as Record,
})
);
}
private mixpanelTrack(command: UpsertLayoutCommand, eventName: string) {
this.analyticsService.mixpanelTrack(eventName, command.userId, {
_organization: command.organizationId,
name: command.layoutDto.name,
source: command.layoutDto.__source,
});
}
private async toggleTranslationsForLayout(command: UpsertLayoutCommand, layoutDto: LayoutDtoV0) {
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true';
const isSelfHosted = process.env.IS_SELF_HOSTED === 'true';
if (!isEnterprise || isSelfHosted) {
return;
}
try {
const manageTranslations = this.moduleRef.get(MANAGE_TRANSLATIONS, {
strict: false,
});
await manageTranslations.execute({
enabled: command.layoutDto.isTranslationEnabled,
resourceId: layoutDto.identifier,
resourceType: LocalizationResourceEnum.LAYOUT,
organizationId: command.organizationId,
environmentId: command.environmentId,
userId: command.userId,
resourceEntity: layoutDto,
});
} catch (error) {
this.logger.error(
`Failed to ${command.layoutDto.isTranslationEnabled ? 'enable' : 'disable'} translations for layout`,
{
layoutId: layoutDto.identifier,
enabled: command.layoutDto.isTranslationEnabled,
organizationId: command.organizationId,
error: error instanceof Error ? error.message : String(error),
}
);
throw error;
}
}
}
================================================
FILE: apps/api/src/app/layouts-v2/utils/layout-templates.ts
================================================
export const EMPTY_LAYOUT = {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: ' ' }],
},
{
type: 'paragraph',
attrs: { textAlign: 'left', showIfKey: null },
content: [
{
type: 'variable',
attrs: {
id: 'content',
label: null,
fallback: null,
required: false,
aliasFor: null,
},
},
],
},
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: ' ' }],
},
],
};
export const createDefaultLayout = (organizationName: string) => ({
type: 'doc',
content: [
{
type: 'columns',
attrs: { showIfKey: null, gap: 8 },
content: [
{
type: 'column',
attrs: {
columnId: '36de3eda-0677-47c3-a8b7-e071dec9ce30',
width: 'auto',
verticalAlign: 'middle',
},
content: [
{
type: 'image',
attrs: {
src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/logo.png',
alt: null,
title: null,
width: '48',
height: '48',
alignment: 'left',
externalLink: null,
isExternalLinkVariable: false,
borderRadius: 0,
isSrcVariable: false,
aspectRatio: null,
lockAspectRatio: true,
showIfKey: null,
aliasFor: null,
},
},
],
},
{
type: 'column',
attrs: {
columnId: '6feb593e-374a-4479-a1c7-872c60c2f4e0',
width: 'auto',
verticalAlign: 'middle',
},
content: [
{
type: 'paragraph',
attrs: { textAlign: 'right', showIfKey: null },
},
],
},
],
},
{
type: 'spacer',
attrs: { height: 8, showIfKey: null },
},
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [
{
type: 'variable',
attrs: {
id: 'content',
label: null,
fallback: null,
required: false,
aliasFor: null,
},
},
],
},
{
type: 'spacer',
attrs: { height: 8, showIfKey: null },
},
{
type: 'columns',
attrs: { showIfKey: null, gap: 0 },
content: [
{
type: 'column',
attrs: {
columnId: '8a20f82f-ecb5-4cbd-923e-ff82f3bb9b79',
width: '60',
verticalAlign: 'top',
},
content: [
{
type: 'paragraph',
attrs: { textAlign: null, showIfKey: null },
content: [{ type: 'text', text: organizationName }],
},
{
type: 'spacer',
attrs: { height: 4, showIfKey: null },
},
{
type: 'footer',
attrs: { textAlign: null, 'maily-component': 'footer' },
content: [
{
type: 'text',
marks: [{ type: 'textStyle' }],
text: '1234 Example Street, DE 19801, United States',
},
],
},
],
},
{
type: 'column',
attrs: {
columnId: 'cd30ba93-7a8f-4d03-b66a-88ae4fe99abf',
width: '40',
verticalAlign: 'top',
},
content: [
{
type: 'paragraph',
attrs: { textAlign: 'right', showIfKey: null },
content: [
{
type: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'https://novu.co/',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
isUrlVariable: false,
aliasFor: null,
},
},
],
text: 'Visit Company',
},
{ type: 'text', text: ' | ' },
{
type: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'support@novu.co',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
isUrlVariable: false,
aliasFor: null,
},
},
],
text: 'Contact Us',
},
],
},
{
type: 'spacer',
attrs: { height: 4, showIfKey: null },
},
{
type: 'section',
attrs: {
borderRadius: 0,
backgroundColor: '#FFFFFF',
align: 'left',
borderWidth: 0,
borderColor: '#e2e2e2',
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
marginTop: 0,
marginRight: 0,
marginBottom: 0,
marginLeft: 0,
showIfKey: null,
},
content: [
{
type: 'paragraph',
attrs: { textAlign: 'right', showIfKey: null },
content: [
{
type: 'inlineImage',
attrs: {
height: 20,
width: 20,
src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/linkedin.png',
isSrcVariable: false,
alt: null,
title: null,
externalLink: 'https://www.linkedin.com/company/novuco/',
isExternalLinkVariable: false,
aliasFor: null,
},
},
{ type: 'text', text: ' ' },
{
type: 'inlineImage',
attrs: {
height: 20,
width: 20,
src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/youtube.png',
isSrcVariable: false,
alt: null,
title: null,
externalLink: 'https://www.youtube.com/@novuhq',
isExternalLinkVariable: false,
aliasFor: null,
},
},
{ type: 'text', text: ' ' },
{
type: 'inlineImage',
attrs: {
height: 20,
width: 20,
src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/twitter.png',
isSrcVariable: false,
alt: null,
title: null,
externalLink: 'https://x.com/novuhq',
isExternalLinkVariable: false,
aliasFor: null,
},
},
],
},
],
},
],
},
],
},
{
type: 'spacer',
attrs: { height: 8, showIfKey: null },
},
],
});
================================================
FILE: apps/api/src/app/message-template/message-template.controller.ts
================================================
import { Controller } from '@nestjs/common';
import { ApiBearerAuth } from '@nestjs/swagger';
@Controller('/message-templates')
export class MessageTemplateController {}
================================================
FILE: apps/api/src/app/message-template/message-template.module.ts
================================================
import { Module } from '@nestjs/common';
import { ChangeModule } from '../change/change.module';
import { SharedModule } from '../shared/shared.module';
import { MessageTemplateController } from './message-template.controller';
import { USE_CASES } from './usecases';
@Module({
imports: [SharedModule, ChangeModule],
controllers: [MessageTemplateController],
providers: [...USE_CASES],
exports: [...USE_CASES],
})
export class MessageTemplateModule {}
================================================
FILE: apps/api/src/app/message-template/usecases/find-message-templates-by-layout/find-message-templates-by-layout.command.ts
================================================
import { LayoutId } from '@novu/shared';
import { IsDefined, IsString } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class FindMessageTemplatesByLayoutCommand extends EnvironmentCommand {
@IsDefined()
@IsString()
layoutId: string;
}
================================================
FILE: apps/api/src/app/message-template/usecases/find-message-templates-by-layout/find-message-templates-by-layout.use-case.ts
================================================
import { Injectable } from '@nestjs/common';
import { MessageTemplateEntity, MessageTemplateRepository } from '@novu/dal';
import { FindMessageTemplatesByLayoutCommand } from './find-message-templates-by-layout.command';
const DEFAULT_PAGE_SIZE = 100;
@Injectable()
export class FindMessageTemplatesByLayoutUseCase {
constructor(private messageTemplateRepository: MessageTemplateRepository) {}
async execute(command: FindMessageTemplatesByLayoutCommand): Promise {
// TODO: Implement proper pagination
const messageTemplates = await this.messageTemplateRepository.getMessageTemplatesByLayout(
command.environmentId,
command.layoutId,
{ limit: DEFAULT_PAGE_SIZE }
);
return messageTemplates;
}
}
================================================
FILE: apps/api/src/app/message-template/usecases/find-message-templates-by-layout/index.ts
================================================
export * from './find-message-templates-by-layout.command';
export * from './find-message-templates-by-layout.use-case';
================================================
FILE: apps/api/src/app/message-template/usecases/index.ts
================================================
import { CreateMessageTemplate, DeleteMessageTemplate, UpdateMessageTemplate } from '@novu/application-generic';
import { FindMessageTemplatesByLayoutUseCase } from './find-message-templates-by-layout/find-message-templates-by-layout.use-case';
export * from './find-message-templates-by-layout';
export const USE_CASES = [
CreateMessageTemplate,
FindMessageTemplatesByLayoutUseCase,
UpdateMessageTemplate,
DeleteMessageTemplate,
];
================================================
FILE: apps/api/src/app/messages/dtos/delete-message-response.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsDefined, IsString } from 'class-validator';
export class DeleteMessageResponseDto {
@ApiProperty({
description: 'A boolean stating the success of the action',
})
@IsBoolean()
@IsDefined()
acknowledged: boolean;
@ApiProperty({
description: 'The status enum for the performed action',
enum: ['deleted'],
})
@IsString()
@IsDefined()
status: string;
}
================================================
FILE: apps/api/src/app/messages/dtos/get-messages-requests.dto.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ChannelTypeEnum } from '@novu/shared';
import { Transform } from 'class-transformer';
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
export class GetMessagesRequestDto {
@ApiPropertyOptional({
enum: [...Object.values(ChannelTypeEnum)],
enumName: 'ChannelTypeEnum',
})
channel?: ChannelTypeEnum;
@ApiPropertyOptional({
type: String,
})
@IsOptional()
subscriberId?: string;
@ApiPropertyOptional({
type: String,
isArray: true,
})
@IsOptional()
transactionId?: string[];
@ApiPropertyOptional({
type: String,
isArray: true,
description: 'Filter by exact context keys, order insensitive (format: "type:id")',
example: ['tenant:org-123', 'region:us-east-1'],
})
@IsOptional()
@Transform(({ value }) => {
// No parameter = no filter
if (value === undefined) return undefined;
// Empty string = filter for records with no context
if (value === '') return [];
// Normalize to array and remove empty strings
const array = Array.isArray(value) ? value : [value];
return array.filter((v) => v !== '');
})
@IsArray()
@IsString({ each: true })
contextKeys?: string[];
@ApiPropertyOptional({
type: Number,
default: 0,
})
@IsOptional()
@IsNumber()
@Transform(({ value }) => Number(value))
page?: number;
@ApiPropertyOptional({
type: Number,
default: 10,
})
@IsOptional()
@IsNumber()
@Transform(({ value }) => Number(value))
limit?: number;
constructor() {
this.page = 0; // Default value
this.limit = 10; // Default value
}
}
================================================
FILE: apps/api/src/app/messages/dtos/remove-messages-by-transactionId-request.dto.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ChannelTypeEnum } from '@novu/shared';
import { IsEnum, IsOptional } from 'class-validator';
export class DeleteMessageByTransactionIdRequestDto {
@ApiPropertyOptional({
enum: ChannelTypeEnum,
description: 'The channel of the message to be deleted',
})
@IsOptional()
@IsEnum(ChannelTypeEnum)
channel?: ChannelTypeEnum;
}
================================================
FILE: apps/api/src/app/messages/e2e/get-messages.e2e.ts
================================================
import { Novu } from '@novu/api';
import { ChannelTypeEnum } from '@novu/api/models/components';
import { NotificationTemplateEntity, SubscriberEntity } from '@novu/dal';
import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared';
import { SubscribersService, UserSession } from '@novu/testing';
import { expect } from 'chai';
import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
describe('Get Message - /messages (GET) #novu-v2', () => {
let session: UserSession;
let template: NotificationTemplateEntity;
let subscriber: SubscriberEntity;
let subscriberService: SubscribersService;
let novuClient: Novu;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
template = await session.createTemplate();
subscriberService = new SubscribersService(session.organization._id, session.environment._id);
subscriber = await subscriberService.createSubscriber();
novuClient = initNovuClassSdk(session);
});
it('should fetch existing messages', async () => {
const subscriber2 = await subscriberService.createSubscriber();
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: [
{ subscriberId: subscriber.subscriberId, email: 'gg@ff.com' },
{ subscriberId: subscriber2.subscriberId, email: 'john@doe.com' },
],
payload: {
email: 'new-test-email@gmail.com',
firstName: 'Testing of User Name',
urlVar: '/test/url/path',
},
});
await session.waitForJobCompletion(template._id);
let response = await novuClient.messages.retrieve({});
expect(response.result.data.length).to.be.equal(4);
response = await novuClient.messages.retrieve({ channel: ChannelTypeEnum.Email });
expect(response.result.data.length).to.be.equal(2);
response = await novuClient.messages.retrieve({ subscriberId: subscriber2.subscriberId });
expect(response.result.data.length).to.be.equal(2);
});
it('should fetch messages using transactionId filter', async () => {
const subscriber3 = await subscriberService.createSubscriber();
const transactionId1 = '1566f9d0-6037-48c1-b356-42667921cadd';
const transactionId2 = 'd2d9f9b5-4a96-403a-927f-1f8f40c6c7a9';
await triggerEventWithTransactionId(template.triggers[0].identifier, subscriber3.subscriberId, transactionId1);
await triggerEventWithTransactionId(template.triggers[0].identifier, subscriber3.subscriberId, transactionId2);
await session.waitForWorkflowQueueCompletion();
await session.waitForSubscriberQueueCompletion();
await session.waitForStandardQueueCompletion();
await session.waitForJobCompletion(template._id);
let response = await novuClient.messages.retrieve({ subscriberId: subscriber3.subscriberId });
expect(response.result.data.length).to.be.equal(4);
response = await novuClient.messages.retrieve({ transactionId: [transactionId1] });
expect(response.result.data.length).to.be.equal(2);
response = await novuClient.messages.retrieve({ transactionId: [transactionId1, transactionId2] });
expect(response.result.data.length).to.be.equal(4);
response = await novuClient.messages.retrieve({ transactionId: [transactionId2] });
expect(response.result.data.length).to.be.equal(2);
});
it('should fetch messages using contextKeys filter', async () => {
const subscriber4 = await subscriberService.createSubscriber();
const workflowBody: CreateWorkflowDto = {
name: 'Test Context Workflow',
workflowId: 'test-context-workflow-messages',
__source: WorkflowCreationSourceEnum.DASHBOARD,
steps: [
{
type: StepTypeEnum.IN_APP,
name: 'Test Step',
controlValues: {
subject: 'Test Subject',
body: 'Test Body',
},
},
],
};
const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody);
expect(workflowResponse.status).to.equal(201);
const workflow: WorkflowResponseDto = workflowResponse.body.data;
await novuClient.trigger({
workflowId: workflow.workflowId,
to: subscriber4.subscriberId,
payload: {},
context: { teamId: 'team-alpha' },
});
await novuClient.trigger({
workflowId: workflow.workflowId,
to: subscriber4.subscriberId,
payload: {},
context: { teamId: 'team-beta' },
});
await session.waitForWorkflowQueueCompletion();
await session.waitForSubscriberQueueCompletion();
await session.waitForStandardQueueCompletion();
await session.waitForJobCompletion(workflow._id);
let response = await novuClient.messages.retrieve({ subscriberId: subscriber4.subscriberId });
expect(response.result.data.length).to.be.equal(2);
response = await novuClient.messages.retrieve({
subscriberId: subscriber4.subscriberId,
contextKeys: ['teamId:team-alpha'],
});
expect(response.result.data.length).to.be.equal(1);
response = await novuClient.messages.retrieve({
subscriberId: subscriber4.subscriberId,
contextKeys: ['teamId:team-beta'],
});
expect(response.result.data.length).to.be.equal(1);
});
async function triggerEventWithTransactionId(
templateIdentifier: string,
subscriberId: string,
transactionId: string
) {
return await novuClient.trigger({
workflowId: templateIdentifier,
to: [{ subscriberId, email: 'gg@ff.com' }],
payload: {},
transactionId,
});
}
});
================================================
FILE: apps/api/src/app/messages/e2e/remove-message.e2e.ts
================================================
import { Novu } from '@novu/api';
import { MessageRepository, NotificationTemplateEntity, SubscriberEntity } from '@novu/dal';
import { ChannelTypeEnum } from '@novu/shared';
import { SubscribersService, UserSession } from '@novu/testing';
import axios from 'axios';
import { expect } from 'chai';
import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
const axiosInstance = axios.create();
describe('Delete Message - /messages/:messageId (DELETE) #novu-v2', () => {
let session: UserSession;
const messageRepository = new MessageRepository();
let template: NotificationTemplateEntity;
let subscriber: SubscriberEntity;
let subscriberService: SubscribersService;
let novuClient: Novu;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
template = await session.createTemplate();
subscriberService = new SubscribersService(session.organization._id, session.environment._id);
subscriber = await subscriberService.createSubscriber();
novuClient = initNovuClassSdk(session);
});
it('should fail to delete non existing message', async () => {
const response = await session.testAgent.delete(`/v1/messages/${MessageRepository.createObjectId()}`);
expect(response.statusCode).to.equal(404);
expect(response.body.error).to.equal('Not Found');
});
it('should delete a existing message', async () => {
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: [{ subscriberId: subscriber.subscriberId, email: 'gg@ff.com' }],
payload: {
email: 'new-test-email@gmail.com',
firstName: 'Testing of User Name',
urlVar: '/test/url/path',
},
});
await session.waitForJobCompletion(template._id);
const messages = await messageRepository.findBySubscriberChannel(
session.environment._id,
subscriber._id,
ChannelTypeEnum.EMAIL
);
const message = messages[0];
await novuClient.messages.delete(message._id);
const result = await messageRepository.findOne({ _id: message._id, _environmentId: message._environmentId });
expect(result).to.not.be.ok;
});
});
================================================
FILE: apps/api/src/app/messages/e2e/remove-messages-by-transactionId.e2e.ts
================================================
import { Novu } from '@novu/api';
import { MessageRepository, NotificationTemplateEntity, SubscriberEntity } from '@novu/dal';
import { ChannelTypeEnum } from '@novu/shared';
import { SubscribersService, UserSession } from '@novu/testing';
import { expect } from 'chai';
import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
describe('Delete Messages By TransactionId - /messages/?transactionId= (DELETE) #novu-v2', () => {
let session: UserSession;
const messageRepository = new MessageRepository();
let template: NotificationTemplateEntity;
let subscriber: SubscriberEntity;
let subscriberService: SubscribersService;
let novuClient: Novu;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
template = await session.createTemplate();
subscriberService = new SubscribersService(session.organization._id, session.environment._id);
subscriber = await subscriberService.createSubscriber();
novuClient = initNovuClassSdk(session);
});
it('should fail to delete non existing message', async () => {
const { error } = await expectSdkExceptionGeneric(() => novuClient.messages.deleteByTransactionId('abc-1234'));
expect(error?.statusCode).to.equal(404);
expect(error?.ctx?.error, JSON.stringify(error)).to.equal('Not Found');
});
it('should delete messages by transactionId', async () => {
await novuClient.subscribers.create({
subscriberId: '123456',
firstName: 'broadcast ',
lastName: 'subscriber',
});
const res = await novuClient.triggerBroadcast({
name: template.triggers[0].identifier,
payload: {
email: 'new-test-email@gmail.com',
firstName: 'Testing of User Name',
urlVar: '/test/url/path',
},
});
await session.waitForJobCompletion(template._id);
const { transactionId } = res.result;
const messages = await messageRepository.find({
_environmentId: session.environment._id,
_organizationId: session.organization._id,
transactionId,
});
expect(messages.length).to.be.greaterThan(0);
expect(transactionId).to.be.ok;
if (transactionId == null) {
throw new Error('must have transaction id');
}
await novuClient.messages.deleteByTransactionId(transactionId);
const result = await messageRepository.find({
transactionId,
_environmentId: session.environment._id,
_organizationId: session.organization._id,
});
expect(result.length).to.equal(0);
});
it('should delete messages by transactionId and channel', async () => {
const response = await novuClient.triggerBroadcast({
name: template.triggers[0].identifier,
payload: {
email: 'new-test-email@gmail.com',
firstName: 'Testing of User Name',
urlVar: '/test/url/path',
},
});
await session.waitForJobCompletion(template._id);
const { transactionId } = response.result;
const messages = await messageRepository.find({
_environmentId: session.environment._id,
_organizationId: session.organization._id,
transactionId,
});
const emailMessages = messages.filter((message) => message.channel === ChannelTypeEnum.EMAIL);
const inAppMessages = messages.filter((message) => message.channel === ChannelTypeEnum.IN_APP);
const inAppMessagesCount = inAppMessages.length;
expect(messages.length).to.be.greaterThan(0);
expect(emailMessages.length).to.be.greaterThan(0);
expect(inAppMessagesCount).to.be.greaterThan(0);
expect(transactionId).to.be.ok;
if (transactionId == null) {
throw new Error('must have transaction id');
}
await novuClient.messages.deleteByTransactionId(transactionId, ChannelTypeEnum.EMAIL);
const result = await messageRepository.find({
transactionId,
_environmentId: session.environment._id,
_organizationId: session.organization._id,
});
const emailResult = result.filter((message) => message.channel === ChannelTypeEnum.EMAIL);
const inAppResult = result.filter((message) => message.channel === ChannelTypeEnum.IN_APP);
const inAppResultCount = inAppResult.length;
expect(result.length).to.be.greaterThan(0);
expect(emailResult.length).to.equal(0);
expect(inAppResultCount).to.be.greaterThan(0);
expect(inAppResultCount).to.equal(inAppMessagesCount);
});
});
================================================
FILE: apps/api/src/app/messages/messages.controller.ts
================================================
import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import {
ApiCommonResponses,
ApiNoContentResponse,
ApiOkResponse,
ApiResponse,
} from '../shared/framework/response.decorator';
import { SdkMethodName } from '../shared/framework/swagger/sdk.decorators';
import { UserSession } from '../shared/framework/user.decorator';
import { MessagesResponseDto } from '../widgets/dtos/message-response.dto';
import { DeleteMessageResponseDto } from './dtos/delete-message-response.dto';
import { GetMessagesRequestDto } from './dtos/get-messages-requests.dto';
import { DeleteMessageByTransactionIdRequestDto } from './dtos/remove-messages-by-transactionId-request.dto';
import { DeleteMessageParams } from './params/delete-message.param';
import { GetMessages, GetMessagesCommand } from './usecases/get-messages';
import { RemoveMessage, RemoveMessageCommand } from './usecases/remove-message';
import { RemoveMessagesByTransactionIdCommand } from './usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.command';
import { RemoveMessagesByTransactionId } from './usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.usecase';
@ApiCommonResponses()
@RequireAuthentication()
@Controller('/messages')
@ApiTags('Messages')
export class MessagesController {
constructor(
private removeMessage: RemoveMessage,
private getMessagesUsecase: GetMessages,
private removeMessagesByTransactionId: RemoveMessagesByTransactionId
) {}
@Get('')
@ExternalApiAccessible()
@ApiOkResponse({
type: MessagesResponseDto,
})
@ApiOperation({
summary: 'List all messages',
description: `List all messages for the current environment.
This API supports filtering by **channel**, **subscriberId**, and **transactionId**.
This API returns a paginated list of messages.`,
})
@RequirePermissions(PermissionsEnum.MESSAGE_READ)
async getMessages(
@UserSession() user: UserSessionData,
@Query() query: GetMessagesRequestDto
): Promise {
let transactionIdQuery: string[] | undefined;
if (query.transactionId) {
transactionIdQuery = Array.isArray(query.transactionId) ? query.transactionId : [query.transactionId];
}
return await this.getMessagesUsecase.execute(
GetMessagesCommand.create({
organizationId: user.organizationId,
environmentId: user.environmentId,
channel: query.channel,
subscriberId: query.subscriberId,
contextKeys: query.contextKeys,
page: query.page ? Number(query.page) : 0,
limit: query.limit ? Number(query.limit) : 10,
transactionIds: transactionIdQuery,
})
);
}
@Delete('/:messageId')
@ExternalApiAccessible()
@ApiResponse(DeleteMessageResponseDto)
@ApiOperation({
summary: 'Delete a message',
description: `Delete a message entity from the Novu platform by **messageId**.
This action is irreversible. **messageId** is required and of mongodbId type.`,
})
@ApiParam({ name: 'messageId', type: String, required: true, example: '507f1f77bcf86cd799439011' })
@RequirePermissions(PermissionsEnum.MESSAGE_WRITE)
async deleteMessage(
@UserSession() user: UserSessionData,
@Param() { messageId }: DeleteMessageParams
): Promise {
return await this.removeMessage.execute(
RemoveMessageCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
messageId,
})
);
}
@Delete('/transaction/:transactionId')
@HttpCode(HttpStatus.NO_CONTENT)
@ExternalApiAccessible()
@ApiNoContentResponse()
@ApiOperation({
summary: 'Delete messages by transactionId',
description: `Delete multiple messages from the Novu platform using **transactionId** of triggered event.
This API supports filtering by **channel** and delete all messages associated with the **transactionId**.`,
})
@ApiParam({ name: 'transactionId', type: String, required: true, example: '507f1f77bcf86cd799439011' })
@SdkMethodName('deleteByTransactionId')
@RequirePermissions(PermissionsEnum.MESSAGE_WRITE)
async deleteMessagesByTransactionId(
@UserSession() user: UserSessionData,
@Param() { transactionId }: { transactionId: string },
@Query() query: DeleteMessageByTransactionIdRequestDto
) {
return await this.removeMessagesByTransactionId.execute(
RemoveMessagesByTransactionIdCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
transactionId,
channel: query.channel,
})
);
}
}
================================================
FILE: apps/api/src/app/messages/messages.module.ts
================================================
import { forwardRef, Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { AuthModule } from '../auth/auth.module';
import { SharedModule } from '../shared/shared.module';
import { SubscribersV1Module } from '../subscribers/subscribersV1.module';
import { WidgetsModule } from '../widgets/widgets.module';
import { MessagesController } from './messages.controller';
import { USE_CASES } from './usecases';
@Module({
imports: [SharedModule, SubscribersV1Module, AuthModule, TerminusModule, forwardRef(() => WidgetsModule)],
controllers: [MessagesController],
providers: [...USE_CASES],
exports: [...USE_CASES],
})
export class MessagesModule {}
================================================
FILE: apps/api/src/app/messages/params/delete-message.param.ts
================================================
import { IsMongoId } from 'class-validator';
export class DeleteMessageParams {
@IsMongoId()
messageId: string;
}
================================================
FILE: apps/api/src/app/messages/usecases/get-messages/get-messages.command.ts
================================================
import { ChannelTypeEnum } from '@novu/shared';
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class GetMessagesCommand extends EnvironmentCommand {
@IsOptional()
subscriberId?: string;
@IsOptional()
channel?: ChannelTypeEnum;
@IsOptional()
@IsArray()
@IsString({ each: true })
contextKeys?: string[];
@IsNumber()
page = 0;
@IsNumber()
limit = 10;
@IsOptional()
@IsArray()
@IsString({ each: true })
transactionIds?: string[] | undefined;
}
================================================
FILE: apps/api/src/app/messages/usecases/get-messages/get-messages.usecase.ts
================================================
import { BadRequestException, Injectable } from '@nestjs/common';
import { FeatureFlagsService } from '@novu/application-generic';
import { MessageEntity, MessageRepository, OrganizationEntity, SubscriberEntity } from '@novu/dal';
import { ActorTypeEnum, FeatureFlagsKeysEnum } from '@novu/shared';
import { GetSubscriber, GetSubscriberCommand } from '../../../subscribers/usecases/get-subscriber';
import { GetMessagesCommand } from './get-messages.command';
@Injectable()
export class GetMessages {
constructor(
private messageRepository: MessageRepository,
private getSubscriberUseCase: GetSubscriber,
private featureFlagService: FeatureFlagsService
) {}
async execute(command: GetMessagesCommand) {
const LIMIT = command.limit;
const COUNT_LIMIT = 1000;
if (LIMIT > 1000) {
throw new BadRequestException('Limit can not be larger then 1000');
}
const query: Partial> & {
_environmentId: string;
transactionId?: string[];
contextKeys?: string[];
} = {
_environmentId: command.environmentId,
};
if (command.subscriberId) {
const subscriber = await this.getSubscriberUseCase.execute(
GetSubscriberCommand.create({
subscriberId: command.subscriberId,
environmentId: command.environmentId,
organizationId: command.organizationId,
})
);
query._subscriberId = subscriber._id;
}
if (command.channel) {
query.channel = command.channel;
}
if (command.transactionIds) {
query.transactionId = command.transactionIds;
}
if (command.contextKeys) {
query.contextKeys = command.contextKeys;
}
const data = await this.messageRepository.getMessages(query, '', {
limit: LIMIT,
sort: { createdAt: -1 },
skip: command.page * LIMIT,
});
for (const message of data) {
if (message._actorId && message.actor?.type === ActorTypeEnum.USER) {
message.actor.data = this.processUserAvatar(message.actorSubscriber);
}
}
const isEnabled = await this.featureFlagService.getFlag({
key: FeatureFlagsKeysEnum.IS_NEW_MESSAGES_API_RESPONSE_ENABLED,
organization: { _id: command.organizationId } as OrganizationEntity,
defaultValue: false,
});
if (isEnabled) {
return {
hasMore: data?.length === command.limit,
page: command.page,
pageSize: LIMIT,
data,
};
}
const totalCount = await this.messageRepository.count(query);
const hasMore = this.getHasMore(command.page, LIMIT, data.length, totalCount);
return {
page: command.page,
totalCount,
hasMore,
pageSize: LIMIT,
data,
};
}
private getHasMore(page: number, limit: number, feedLength: number, totalCount: number) {
const currentPaginationTotal = page * limit + feedLength;
return currentPaginationTotal < totalCount;
}
private processUserAvatar(actorSubscriber?: SubscriberEntity): string | null {
return actorSubscriber?.avatar || null;
}
}
================================================
FILE: apps/api/src/app/messages/usecases/get-messages/index.ts
================================================
export * from './get-messages.command';
export * from './get-messages.usecase';
================================================
FILE: apps/api/src/app/messages/usecases/index.ts
================================================
import { GetMessages } from './get-messages';
import { RemoveMessage } from './remove-message';
import { RemoveMessagesByTransactionId } from './remove-messages-by-transactionId/remove-messages-by-transactionId.usecase';
export const USE_CASES = [RemoveMessage, GetMessages, RemoveMessagesByTransactionId];
================================================
FILE: apps/api/src/app/messages/usecases/remove-message/index.ts
================================================
export * from './remove-message.command';
export * from './remove-message.usecase';
================================================
FILE: apps/api/src/app/messages/usecases/remove-message/remove-message.command.ts
================================================
import { IsString } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class RemoveMessageCommand extends EnvironmentCommand {
@IsString()
messageId: string;
}
================================================
FILE: apps/api/src/app/messages/usecases/remove-message/remove-message.usecase.ts
================================================
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { buildFeedKey, buildMessageCountKey, InvalidateCacheService } from '@novu/application-generic';
import { MessageRepository } from '@novu/dal';
import { RemoveMessageCommand } from './remove-message.command';
@Injectable()
export class RemoveMessage {
constructor(
private invalidateCache: InvalidateCacheService,
private messageRepository: MessageRepository
) {}
async execute(command: RemoveMessageCommand) {
const message = await this.messageRepository.findMessageById({
_environmentId: command.environmentId,
_id: command.messageId,
});
if (!message) {
throw new NotFoundException(`Message with id ${command.messageId} not found`);
}
if (!message.subscriber)
throw new BadRequestException(`A subscriber was not found for message ${command.messageId}`);
await this.invalidateCache.invalidateQuery({
key: buildMessageCountKey().invalidate({
subscriberId: message.subscriber.subscriberId,
_environmentId: command.environmentId,
}),
});
await this.messageRepository.delete({
_environmentId: command.environmentId,
_id: command.messageId,
});
return {
acknowledged: true,
status: 'deleted',
};
}
}
================================================
FILE: apps/api/src/app/messages/usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.command.ts
================================================
import { ChannelTypeEnum } from '@novu/shared';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class RemoveMessagesByTransactionIdCommand extends EnvironmentCommand {
@IsString()
transactionId: string;
@IsEnum(ChannelTypeEnum)
@IsOptional()
channel?: ChannelTypeEnum;
}
================================================
FILE: apps/api/src/app/messages/usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.usecase.ts
================================================
import { Injectable, NotFoundException } from '@nestjs/common';
import { buildFeedKey, buildMessageCountKey, InvalidateCacheService } from '@novu/application-generic';
import { EnforceEnvId, MessageEntity, MessageRepository } from '@novu/dal';
import { RemoveMessagesByTransactionIdCommand } from './remove-messages-by-transactionId.command';
@Injectable()
export class RemoveMessagesByTransactionId {
constructor(
private messageRepository: MessageRepository,
private invalidateCache: InvalidateCacheService
) {}
async execute(command: RemoveMessagesByTransactionIdCommand) {
const messages = await this.messageRepository.findMessagesByTransactionId({
transactionId: [command.transactionId],
_environmentId: command.environmentId,
_organizationId: command.organizationId,
...(command.channel && { channel: command.channel }),
});
if (messages.length === 0) {
throw new NotFoundException('Invalid transactionId or channel');
}
for (const message of messages) {
const subscriberId = message.subscriber?.subscriberId;
if (subscriberId) {
await this.invalidateCache.invalidateQuery({
key: buildMessageCountKey().invalidate({
subscriberId,
_environmentId: command.environmentId,
}),
});
}
}
const deleteQuery: Partial & EnforceEnvId = {
transactionId: command.transactionId,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
};
if (command.channel) {
deleteQuery.channel = command.channel;
}
await this.messageRepository.delete(deleteQuery);
}
}
================================================
FILE: apps/api/src/app/notification-groups/dtos/create-notification-group-request.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { IsDefined, IsString } from 'class-validator';
export class CreateNotificationGroupRequestDto {
@ApiProperty()
@IsString()
@IsDefined()
name: string;
}
================================================
FILE: apps/api/src/app/notification-groups/dtos/delete-notification-group-response.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsDefined, IsString } from 'class-validator';
export class DeleteNotificationGroupResponseDto {
@ApiProperty({
description: 'A boolean stating the success of the action',
})
@IsBoolean()
@IsDefined()
acknowledged: boolean;
@ApiProperty({
description: 'The status enum for the performed action',
enum: ['deleted'],
})
@IsString()
@IsDefined()
status: string;
}
================================================
FILE: apps/api/src/app/notification-groups/dtos/notification-group-response.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class NotificationGroupResponseDto {
@ApiPropertyOptional()
_id?: string;
@ApiProperty()
name: string;
@ApiProperty()
_environmentId: string;
@ApiProperty()
_organizationId: string;
@ApiPropertyOptional()
_parentId?: string;
}
================================================
FILE: apps/api/src/app/notification-groups/e2e/create-notification-group.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Create Notification Group - /notification-groups (POST) #novu-v0', async () => {
let session: UserSession;
before(async () => {
session = new UserSession();
await session.initialize();
});
it('should create notification group', async () => {
const testTemplate = {
name: 'Test name',
};
const { body } = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate);
expect(body.data).to.be.ok;
const group = body.data;
expect(group.name).to.equal(`Test name`);
expect(group._environmentId).to.equal(session.environment._id);
});
});
================================================
FILE: apps/api/src/app/notification-groups/e2e/delete-notification-group.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Delete Notification Group - /notification-groups/:id (DELETE) #novu-v0', async () => {
let session: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
});
it('should delete notification group by id', async () => {
const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send({
name: 'Test delete group',
});
const { id } = postNotificationGroup1.body.data;
const getResult = await session.testAgent.get(`/v1/notification-groups/${id}`);
const group = getResult.body.data;
expect(group.name).to.equal(`Test delete group`);
expect(group._id).to.equal(postNotificationGroup1.body.data.id);
expect(group._environmentId).to.equal(session.environment._id);
const { body: deleteResult } = await session.testAgent.delete(`/v1/notification-groups/${id}`);
expect(deleteResult.data.acknowledged).to.equal(true);
expect(deleteResult.data.status).to.equal('deleted');
const { body: getResultAfterDelete } = await session.testAgent.get(`/v1/notification-groups/${id}`);
expect(getResultAfterDelete.statusCode).to.eq(404);
});
it('should return 404 error when attempting to delete non-existent notification group', async () => {
const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send({
name: 'Test name',
});
const { id } = postNotificationGroup1.body.data;
await session.testAgent.delete(`/v1/notification-groups/${id}`);
const { body } = await session.testAgent.delete(`/v1/notification-groups/${id}`);
expect(body.statusCode).to.equal(404);
});
});
================================================
FILE: apps/api/src/app/notification-groups/e2e/get-notification-group.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Get Notification Group - /notification-groups/:id (GET) #novu-v0', async () => {
let session: UserSession;
const testTemplate = {
name: 'Test name',
};
beforeEach(async () => {
session = new UserSession();
await session.initialize();
});
it('should get the notification group by id', async () => {
const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate);
const { id } = postNotificationGroup1.body.data;
const { body } = await session.testAgent.get(`/v1/notification-groups/${id}`);
const group = body.data;
expect(group.name).to.equal(`Test name`);
expect(group._id).to.equal(postNotificationGroup1.body.data.id);
expect(group._environmentId).to.equal(session.environment._id);
});
it('should get 404 when notification group is not present with the requested id', async () => {
const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate);
const { id } = postNotificationGroup1.body.data;
await session.testAgent.delete(`/v1/notification-groups/${id}`);
const { body } = await session.testAgent.get(`/v1/notification-groups/${id}`);
expect(body.statusCode).to.equal(404);
});
});
================================================
FILE: apps/api/src/app/notification-groups/e2e/get-notification-groups.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Get Notification Groups - /notification-groups (GET) #novu-v0', async () => {
let session: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
});
it('should get all notification groups', async () => {
await session.testAgent.post(`/v1/notification-groups`).send({
name: 'Test name',
});
await session.testAgent.post(`/v1/notification-groups`).send({
name: 'Test name 2',
});
const { body } = await session.testAgent.get(`/v1/notification-groups`);
expect(body.data.length).to.equal(3);
const group = body.data.find((i) => i.name === 'Test name');
expect(group.name).to.equal(`Test name`);
expect(group._environmentId).to.equal(session.environment._id);
});
});
================================================
FILE: apps/api/src/app/notification-groups/e2e/update-notification-group.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Update Notification Group - /notification-groups/:id (PATCH) #novu-v0', async () => {
let session: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
});
it('update the notification group by id', async () => {
const postNotificationGroup = await session.testAgent.post(`/v1/notification-groups`).send({
name: 'Test name 1',
});
const { id } = postNotificationGroup.body.data;
const { body: getNotificationGroupResult } = await session.testAgent.get(`/v1/notification-groups/${id}`);
expect(getNotificationGroupResult.data.name).to.equal(`Test name 1`);
expect(getNotificationGroupResult.data._id).to.equal(postNotificationGroup.body.data.id);
expect(getNotificationGroupResult.data._environmentId).to.equal(session.environment._id);
const { body: putNotificationGroup } = await session.testAgent.patch(`/v1/notification-groups/${id}`).send({
name: 'Updated name',
});
expect(putNotificationGroup.data._id).to.equal(id);
const { body: getUpdatedNotificationGroupResult } = await session.testAgent.get(`/v1/notification-groups/${id}`);
expect(getUpdatedNotificationGroupResult.data.name).to.equal(`Updated name`);
expect(getUpdatedNotificationGroupResult.data.id).to.equal(id);
expect(getUpdatedNotificationGroupResult.data._environmentId).to.equal(session.environment._id);
});
it('should return a 404 error if the notification group to be updated does not exist', async () => {
const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send({
name: 'Test name',
});
const { id } = postNotificationGroup1.body.data;
await session.testAgent.delete(`/v1/notification-groups/${id}`);
const { body } = await session.testAgent.patch(`/v1/notification-groups/${id}`).send({
name: 'Updated name',
});
expect(body.statusCode).to.equal(404);
});
});
================================================
FILE: apps/api/src/app/notification-groups/notification-groups.controller.ts
================================================
import {
Body,
ClassSerializerInterceptor,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseInterceptors,
} from '@nestjs/common';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { CreateNotificationGroupRequestDto } from './dtos/create-notification-group-request.dto';
import { DeleteNotificationGroupResponseDto } from './dtos/delete-notification-group-response.dto';
import { NotificationGroupResponseDto } from './dtos/notification-group-response.dto';
import { CreateNotificationGroupCommand } from './usecases/create-notification-group/create-notification-group.command';
import { CreateNotificationGroup } from './usecases/create-notification-group/create-notification-group.usecase';
import { DeleteNotificationGroupCommand } from './usecases/delete-notification-group/delete-notification-group.command';
import { DeleteNotificationGroup } from './usecases/delete-notification-group/delete-notification-group.usecase';
import { GetNotificationGroupCommand } from './usecases/get-notification-group/get-notification-group.command';
import { GetNotificationGroup } from './usecases/get-notification-group/get-notification-group.usecase';
import { GetNotificationGroupsCommand } from './usecases/get-notification-groups/get-notification-groups.command';
import { GetNotificationGroups } from './usecases/get-notification-groups/get-notification-groups.usecase';
import { UpdateNotificationGroupCommand } from './usecases/update-notification-group/update-notification-group.command';
import { UpdateNotificationGroup } from './usecases/update-notification-group/update-notification-group.usecase';
@ApiCommonResponses()
@Controller('/notification-groups')
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
@ApiTags('Workflow groups')
@ApiExcludeController()
export class NotificationGroupsController {
constructor(
private createNotificationGroupUsecase: CreateNotificationGroup,
private getNotificationGroupsUsecase: GetNotificationGroups,
private getNotificationGroupUsecase: GetNotificationGroup,
private deleteNotificationGroupUsecase: DeleteNotificationGroup,
private updateNotificationGroupUsecase: UpdateNotificationGroup
) {}
@Post('')
@ExternalApiAccessible()
@ApiResponse(NotificationGroupResponseDto, 201)
@ApiOperation({
summary: 'Create workflow group',
description: `workflow group was previously named notification group`,
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
createNotificationGroup(
@UserSession() user: UserSessionData,
@Body() body: CreateNotificationGroupRequestDto
): Promise {
return this.createNotificationGroupUsecase.execute(
CreateNotificationGroupCommand.create({
organizationId: user.organizationId,
userId: user._id,
environmentId: user.environmentId,
name: body.name,
})
);
}
@Get('')
@ExternalApiAccessible()
@ApiResponse(NotificationGroupResponseDto, 200, true)
@ApiOperation({
summary: 'Get workflow groups',
description: `workflow group was previously named notification group`,
})
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
listNotificationGroups(@UserSession() user: UserSessionData): Promise {
return this.getNotificationGroupsUsecase.execute(
GetNotificationGroupsCommand.create({
organizationId: user.organizationId,
userId: user._id,
environmentId: user.environmentId,
})
);
}
@Get('/:id')
@ExternalApiAccessible()
@ApiResponse(NotificationGroupResponseDto, 200)
@ApiOperation({
summary: 'Get workflow group',
description: `workflow group was previously named notification group`,
})
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
getNotificationGroup(
@UserSession() user: UserSessionData,
@Param('id') id: string
): Promise {
return this.getNotificationGroupUsecase.execute(
GetNotificationGroupCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
id,
})
);
}
@Patch('/:id')
@ExternalApiAccessible()
@ApiResponse(NotificationGroupResponseDto, 200)
@ApiOperation({
summary: 'Update workflow group',
description: `workflow group was previously named notification group`,
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
updateNotificationGroup(
@UserSession() user: UserSessionData,
@Param('id') id: string,
@Body() body: CreateNotificationGroupRequestDto
): Promise {
return this.updateNotificationGroupUsecase.execute(
UpdateNotificationGroupCommand.create({
organizationId: user.organizationId,
userId: user._id,
environmentId: user.environmentId,
name: body.name,
id,
})
);
}
@Delete('/:id')
@ExternalApiAccessible()
@ApiResponse(DeleteNotificationGroupResponseDto, 200)
@ApiOperation({
summary: 'Delete workflow group',
description: `workflow group was previously named notification group`,
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
deleteNotificationGroup(
@UserSession() user: UserSessionData,
@Param('id') id: string
): Promise {
return this.deleteNotificationGroupUsecase.execute(
DeleteNotificationGroupCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
id,
})
);
}
}
================================================
FILE: apps/api/src/app/notification-groups/notification-groups.module.ts
================================================
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChangeModule } from '../change/change.module';
import { SharedModule } from '../shared/shared.module';
import { NotificationGroupsController } from './notification-groups.controller';
import { USE_CASES } from './usecases';
@Module({
imports: [SharedModule, forwardRef(() => AuthModule), ChangeModule],
providers: [...USE_CASES],
controllers: [NotificationGroupsController],
exports: [...USE_CASES],
})
export class NotificationGroupsModule {}
================================================
FILE: apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.command.ts
================================================
import { IsString } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class CreateNotificationGroupCommand extends EnvironmentWithUserCommand {
@IsString()
name: string;
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { CreateChange, CreateChangeCommand } from '@novu/application-generic';
import { NotificationGroupEntity, NotificationGroupRepository } from '@novu/dal';
import { ChangeEntityTypeEnum } from '@novu/shared';
import { CreateNotificationGroupCommand } from './create-notification-group.command';
@Injectable()
export class CreateNotificationGroup {
constructor(
private notificationGroupRepository: NotificationGroupRepository,
private createChange: CreateChange
) {}
async execute(command: CreateNotificationGroupCommand): Promise {
const group = await this.notificationGroupRepository.findOne({
_organizationId: command.organizationId,
});
const item = await this.notificationGroupRepository.create({
_environmentId: command.environmentId,
_organizationId: command.organizationId,
name: command.name,
_parentId: group?._id,
});
await this.createChange.execute(
CreateChangeCommand.create({
item,
environmentId: command.environmentId,
organizationId: command.organizationId,
userId: command.userId,
type: ChangeEntityTypeEnum.NOTIFICATION_GROUP,
changeId: NotificationGroupRepository.createObjectId(),
})
);
return item;
}
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/delete-notification-group/delete-notification-group.command.ts
================================================
import { IsDefined, IsString } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class DeleteNotificationGroupCommand extends EnvironmentWithUserCommand {
@IsString()
@IsDefined()
id: string;
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/delete-notification-group/delete-notification-group.usecase.ts
================================================
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DalException, NotificationGroupRepository } from '@novu/dal';
import { DeleteNotificationGroupCommand } from './delete-notification-group.command';
@Injectable()
export class DeleteNotificationGroup {
constructor(private notificationGroupRepository: NotificationGroupRepository) {}
async execute(command: DeleteNotificationGroupCommand) {
const { environmentId, id } = command;
try {
const group = await this.notificationGroupRepository.findOne({
_environmentId: environmentId,
_id: id,
});
if (group === null) throw new NotFoundException();
await this.notificationGroupRepository.delete({
_environmentId: environmentId,
_id: id,
});
} catch (e) {
if (e instanceof DalException) {
throw new BadRequestException(e.message);
}
throw e;
}
return {
acknowledged: true,
status: 'deleted',
};
}
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/get-notification-group/get-notification-group.command.ts
================================================
import { IsDefined, IsString } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetNotificationGroupCommand extends EnvironmentWithUserCommand {
@IsString()
@IsDefined()
id: string;
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/get-notification-group/get-notification-group.usecase.ts
================================================
import { Injectable, NotFoundException } from '@nestjs/common';
import { NotificationGroupEntity, NotificationGroupRepository } from '@novu/dal';
import { GetNotificationGroupCommand } from './get-notification-group.command';
@Injectable()
export class GetNotificationGroup {
constructor(private notificationGroupRepository: NotificationGroupRepository) {}
async execute(command: GetNotificationGroupCommand): Promise {
const { id, environmentId } = command;
const result = await this.notificationGroupRepository.findOne({
_environmentId: environmentId,
_id: id,
});
if (result === null) throw new NotFoundException();
return result;
}
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.command.ts
================================================
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetNotificationGroupsCommand extends EnvironmentWithUserCommand {}
================================================
FILE: apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { NotificationGroupEntity, NotificationGroupRepository } from '@novu/dal';
import { GetNotificationGroupsCommand } from './get-notification-groups.command';
@Injectable()
export class GetNotificationGroups {
constructor(private notificationGroupRepository: NotificationGroupRepository) {}
async execute(command: GetNotificationGroupsCommand): Promise {
return await this.notificationGroupRepository.find({
_environmentId: command.environmentId,
});
}
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/index.ts
================================================
import { CreateNotificationGroup } from './create-notification-group/create-notification-group.usecase';
import { DeleteNotificationGroup } from './delete-notification-group/delete-notification-group.usecase';
import { GetNotificationGroup } from './get-notification-group/get-notification-group.usecase';
import { GetNotificationGroups } from './get-notification-groups/get-notification-groups.usecase';
import { UpdateNotificationGroup } from './update-notification-group/update-notification-group.usecase';
export const USE_CASES = [
GetNotificationGroups,
CreateNotificationGroup,
GetNotificationGroup,
DeleteNotificationGroup,
UpdateNotificationGroup,
];
================================================
FILE: apps/api/src/app/notification-groups/usecases/update-notification-group/update-notification-group.command.ts
================================================
import { IsDefined, IsString } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class UpdateNotificationGroupCommand extends EnvironmentWithUserCommand {
@IsString()
@IsDefined()
id: string;
@IsString()
@IsDefined()
name: string;
}
================================================
FILE: apps/api/src/app/notification-groups/usecases/update-notification-group/update-notification-group.usecase.ts
================================================
import { Injectable, NotFoundException } from '@nestjs/common';
import { NotificationGroupRepository } from '@novu/dal';
import { GetNotificationGroup } from '../get-notification-group/get-notification-group.usecase';
import { UpdateNotificationGroupCommand } from './update-notification-group.command';
@Injectable()
export class UpdateNotificationGroup {
constructor(
private notificationGroupRepository: NotificationGroupRepository,
private getNotificationGroup: GetNotificationGroup
) {}
async execute(command: UpdateNotificationGroupCommand) {
const { id, environmentId, name, organizationId, userId } = command;
const item = await this.getNotificationGroup.execute({
environmentId,
organizationId,
userId,
id,
});
const result = await this.notificationGroupRepository.update(
{
_id: item._id,
_environmentId: item._environmentId,
},
{
$set: {
name,
},
}
);
if (result.matched === 0) {
throw new NotFoundException();
}
return await this.getNotificationGroup.execute({
environmentId,
organizationId,
userId,
id,
});
}
}
================================================
FILE: apps/api/src/app/notifications/dtos/activities-request.dto.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ChannelTypeEnum, SeverityLevelEnum } from '@novu/shared';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsEnumOrArray } from '../../shared/validators/is-enum-or-array';
export class ActivitiesRequestDto {
@ApiPropertyOptional({
enum: [...Object.values(ChannelTypeEnum)],
enumName: 'ChannelTypeEnum',
isArray: true,
description: 'Array of channel types',
})
@IsOptional()
channels?: ChannelTypeEnum[] | ChannelTypeEnum;
@ApiPropertyOptional({
type: String,
isArray: true,
description: 'Array of template IDs or a single template ID',
})
@IsOptional()
templates?: string[] | string;
@ApiPropertyOptional({
type: String,
isArray: true,
description: 'Array of email addresses or a single email address',
})
@IsOptional()
emails?: string | string[];
@ApiPropertyOptional({
type: String,
deprecated: true,
description: 'Search term (deprecated)',
})
@IsOptional()
search?: string;
@ApiPropertyOptional({
type: String,
isArray: true,
description: 'Array of subscriber IDs or a single subscriber ID',
})
@IsOptional()
subscriberIds?: string | string[];
@ApiPropertyOptional({
type: String,
isArray: true,
description: 'Array of severity levels or a single severity level',
})
@IsOptional()
@IsEnumOrArray(SeverityLevelEnum)
severity?: SeverityLevelEnum[] | SeverityLevelEnum;
@ApiPropertyOptional({
type: Number,
default: 0,
description: 'Page number for pagination',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
page: number = 0;
@ApiPropertyOptional({
type: Number,
default: 10,
minimum: 1,
maximum: 50,
description: 'Limit for pagination',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit: number = 10;
@ApiPropertyOptional({
type: String,
description: 'The transaction ID to filter by',
})
@IsOptional()
transactionId?: string[] | string;
@ApiPropertyOptional({
type: String,
description: 'Topic Key for filtering notifications by topic',
})
@IsOptional()
@IsString()
topicKey?: string;
@ApiPropertyOptional({
type: String,
description: 'Subscription ID for filtering notifications by subscription',
})
@IsOptional()
@IsString()
subscriptionId?: string;
@ApiPropertyOptional({
type: String,
isArray: true,
description: 'Filter by exact context keys, order insensitive (format: "type:id")',
})
@IsOptional()
@Transform(({ value }) => {
// No parameter = no filter
if (value === undefined) return undefined;
// Empty string = filter for records with no context
if (value === '') return [];
// Normalize to array and remove empty strings
const array = Array.isArray(value) ? value : [value];
return array.filter((v) => v !== '');
})
@IsArray()
@IsString({ each: true })
contextKeys?: string[];
@ApiPropertyOptional({
type: String,
description: 'Date filter for records after this timestamp. Defaults to earliest date allowed by subscription plan',
})
@IsOptional()
after?: string;
@ApiPropertyOptional({
type: String,
description: 'Date filter for records before this timestamp. Defaults to current time of request (now)',
})
@IsOptional()
before?: string;
}
================================================
FILE: apps/api/src/app/notifications/dtos/activities-response.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StepFilterDto } from '@novu/application-generic';
import {
DaysEnum,
DigestTypeEnum,
DigestUnitEnum,
ExecutionDetailsSourceEnum,
ExecutionDetailsStatusEnum,
MessageTemplateDto,
MonthlyTypeEnum,
OrdinalEnum,
OrdinalValueEnum,
ProvidersIdEnum,
ProvidersIdEnumConst,
ResourceOriginEnum,
SeverityLevelEnum,
StepTypeEnum,
TriggerTypeEnum,
} from '@novu/shared';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
export class DigestTimedConfigDto {
@ApiPropertyOptional({ description: 'Time at which the digest is triggered' })
@IsOptional()
@IsString()
atTime?: string;
@ApiPropertyOptional({
description: 'Days of the week for the digest',
type: 'array',
items: {
type: 'string',
enum: Object.values(DaysEnum),
},
enumName: 'DaysEnum',
})
@IsOptional()
@IsArray()
@IsEnum(DaysEnum, { each: true })
weekDays?: DaysEnum[];
@ApiPropertyOptional({ description: 'Specific days of the month for the digest', type: [Number] })
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
monthDays?: number[];
@ApiPropertyOptional({
description: 'Ordinal position for the digest',
enum: [...Object.values(OrdinalEnum)],
enumName: 'OrdinalEnum',
})
@IsOptional()
@IsEnum(OrdinalEnum)
ordinal?: OrdinalEnum;
@ApiPropertyOptional({
description: 'Value of the ordinal',
enum: [...Object.values(OrdinalValueEnum)],
enumName: 'OrdinalValueEnum',
})
@IsOptional()
@IsEnum(OrdinalValueEnum)
ordinalValue?: OrdinalValueEnum;
@ApiPropertyOptional({
description: 'Type of monthly schedule',
enum: [...Object.values(MonthlyTypeEnum)],
enumName: 'MonthlyTypeEnum',
})
@IsOptional()
@IsEnum(MonthlyTypeEnum)
monthlyType?: MonthlyTypeEnum;
@ApiPropertyOptional({ description: 'Cron expression for scheduling' })
@IsOptional()
@IsString()
cronExpression?: string;
@ApiPropertyOptional({ description: 'Until date for scheduling' })
@IsOptional()
@IsString()
untilDate?: string;
}
export class DigestMetadataDto {
@ApiPropertyOptional({ description: 'Optional key for the digest' })
digestKey?: string;
@ApiPropertyOptional({ description: 'Amount for the digest', type: Number })
amount?: number;
@ApiPropertyOptional({ description: 'Unit of the digest', enum: DigestUnitEnum })
unit?: DigestUnitEnum;
@ApiProperty({
enum: [...Object.values(DigestTypeEnum)],
enumName: 'DigestTypeEnum',
description: 'The Digest Type',
type: String,
})
type: DigestTypeEnum;
@ApiPropertyOptional({
type: 'array',
items: {
type: 'object',
additionalProperties: true,
},
description: 'Optional array of events associated with the digest, represented as key-value pairs',
})
events?: Record[];
// Properties for Regular Digest
@ApiPropertyOptional({
description: 'Regular digest: Indicates if backoff is enabled for the regular digest',
type: Boolean,
})
backoff?: boolean;
@ApiPropertyOptional({ description: 'Regular digest: Amount for backoff', type: Number })
backoffAmount?: number;
@ApiPropertyOptional({
description: 'Regular digest: Unit for backoff',
enum: [...Object.values(DigestUnitEnum)],
enumName: 'DigestUnitEnum',
})
backoffUnit?: DigestUnitEnum;
@ApiPropertyOptional({ description: 'Regular digest: Indicates if the digest should update', type: Boolean })
updateMode?: boolean;
// Properties for Timed Digest
@ApiPropertyOptional({ description: 'Configuration for timed digest', type: () => DigestTimedConfigDto })
timed?: DigestTimedConfigDto;
}
export class ActivityNotificationStepResponseDto {
@ApiProperty({ description: 'Unique identifier of the step', type: String })
_id: string;
@ApiProperty({ description: 'Whether the step is active or not', type: Boolean })
active: boolean;
@ApiPropertyOptional({ description: 'Reply callback settings', type: Object })
replyCallback?: {
active: boolean;
url: string;
};
@ApiPropertyOptional({ description: 'Control variables', type: Object })
controlVariables?: Record;
@ApiPropertyOptional({ description: 'Metadata for the workflow step', type: Object })
metadata?: any; // Adjust the type based on your actual metadata structure
@ApiPropertyOptional({ description: 'Step issues', type: Object })
issues?: any; // Adjust the type based on your actual issues structure
@ApiProperty({ description: 'Filter criteria for the step', isArray: true, type: StepFilterDto })
filters: StepFilterDto[];
@ApiPropertyOptional({ description: 'Optional template for the step', type: MessageTemplateDto })
template?: MessageTemplateDto;
@ApiPropertyOptional({ description: 'Variants of the step', type: [ActivityNotificationStepResponseDto] })
variants?: ActivityNotificationStepResponseDto[]; // Assuming variants are the same type
@ApiProperty({ description: 'The identifier for the template associated with this step', type: String })
_templateId: string;
@ApiPropertyOptional({ description: 'The name of the step', type: String })
name?: string;
@ApiPropertyOptional({ description: 'The unique identifier for the parent step', type: String })
_parentId?: string | null;
}
// Activity Notification Execution Detail Response DTO
export class ActivityNotificationExecutionDetailResponseDto {
@ApiProperty({ description: 'Unique identifier of the execution detail', type: String })
_id: string;
@ApiPropertyOptional({ description: 'Creation time of the execution detail', type: String })
createdAt?: string;
@ApiProperty({
enum: [...Object.values(ExecutionDetailsStatusEnum)],
enumName: 'ExecutionDetailsStatusEnum',
description: 'Status of the execution detail',
type: String,
})
status: ExecutionDetailsStatusEnum;
@ApiProperty({ description: 'Detailed information about the execution', type: String })
detail: string;
@ApiProperty({ description: 'Whether the execution is a retry or not', type: Boolean })
isRetry: boolean;
@ApiProperty({ description: 'Whether the execution is a test or not', type: Boolean })
isTest: boolean;
@ApiPropertyOptional({
enum: [...new Set([...Object.values(ProvidersIdEnumConst).flatMap((enumObj) => Object.values(enumObj))])],
enumName: 'ProvidersIdEnum',
description: 'Provider ID of the execution',
type: String,
})
@IsString()
@IsOptional()
@IsEnum(ProvidersIdEnumConst)
providerId?: ProvidersIdEnum;
@ApiPropertyOptional({ description: 'Raw data of the execution', type: String })
raw?: string | null;
@ApiProperty({
enum: [...Object.values(ExecutionDetailsSourceEnum)],
enumName: 'ExecutionDetailsSourceEnum',
description: 'Source of the execution detail',
type: String,
})
@IsString()
@IsEnum(ExecutionDetailsSourceEnum)
source: ExecutionDetailsSourceEnum;
}
// Activity Notification Job Response DTO
export class ActivityNotificationJobResponseDto {
@ApiProperty({ description: 'Unique identifier of the job', type: String })
_id: string;
@ApiProperty({ description: 'Type of the job', type: String })
type: StepTypeEnum;
@ApiPropertyOptional({
description: 'Optional digest for the job, including metadata and events',
type: DigestMetadataDto,
})
digest?: DigestMetadataDto;
@ApiProperty({
description: 'Execution details of the job',
type: [ActivityNotificationExecutionDetailResponseDto],
})
executionDetails: ActivityNotificationExecutionDetailResponseDto[];
@ApiProperty({
description: 'Step details of the job',
type: ActivityNotificationStepResponseDto,
})
step: ActivityNotificationStepResponseDto;
@ApiPropertyOptional({
description: 'Optional context object for additional error details.',
type: 'object',
required: false,
additionalProperties: true,
example: {
workflowId: 'some_wf_id',
stepId: 'some_wf_id',
},
})
overrides?: Record;
@ApiPropertyOptional({ description: 'Optional payload for the job', type: Object })
payload?: Record;
@ApiProperty({
enum: [...new Set([...Object.values(ProvidersIdEnumConst).flatMap((enumObj) => Object.values(enumObj))])],
enumName: 'ProvidersIdEnum',
description: 'Provider ID of the job',
type: String, // Explicit type reference for enum
})
providerId: ProvidersIdEnum;
@ApiProperty({ description: 'Status of the job', type: String })
status: string;
@ApiPropertyOptional({ description: 'Updated time of the notification', type: String })
updatedAt?: string;
@ApiPropertyOptional({
description: 'The number of times the digest/delay job has been extended to align with the subscribers schedule',
type: Number,
})
scheduleExtensionsCount?: number;
}
// Activity Notification Subscriber Response DTO
export class ActivityNotificationSubscriberResponseDto {
@ApiPropertyOptional({ description: 'First name of the subscriber', type: String })
firstName?: string;
@ApiProperty({ description: 'External unique identifier of the subscriber', type: String })
subscriberId: string;
@ApiProperty({ description: 'Internal to Novu unique identifier of the subscriber', type: String })
_id: string;
@ApiPropertyOptional({ description: 'Last name of the subscriber', type: String })
lastName?: string;
@ApiPropertyOptional({ description: 'Email address of the subscriber', type: String })
email?: string;
@ApiPropertyOptional({ description: 'Phone number of the subscriber', type: String })
phone?: string;
}
// Notification Trigger Variable DTO
export class NotificationTriggerVariable {
@ApiProperty({ description: 'Name of the variable', type: String })
name: string;
}
export class NotificationTriggerDto {
@ApiProperty({
enum: TriggerTypeEnum,
description: 'Type of the trigger',
type: String, // Explicit type reference for enum
})
type: TriggerTypeEnum;
@ApiProperty({ description: 'Identifier of the trigger', type: String })
identifier: string;
@ApiProperty({
description: 'Variables of the trigger',
type: [NotificationTriggerVariable],
})
variables: NotificationTriggerVariable[];
@ApiPropertyOptional({
description: 'Subscriber variables of the trigger',
type: [NotificationTriggerVariable],
})
subscriberVariables?: NotificationTriggerVariable[];
}
// Activity Notification Template Response DTO
export class ActivityNotificationTemplateResponseDto {
@ApiPropertyOptional({ description: 'Unique identifier of the template', type: String })
_id?: string;
@ApiProperty({ description: 'Name of the template', type: String })
name: string;
@ApiProperty({
enum: [...Object.values(ResourceOriginEnum)],
enumName: 'ResourceOriginEnum',
description: 'Origin of the workflow',
type: String,
})
@IsString()
@IsEnum(ResourceOriginEnum)
origin?: ResourceOriginEnum;
@ApiProperty({
description: 'Triggers of the template',
type: [NotificationTriggerDto],
})
triggers: NotificationTriggerDto[];
}
export class ActivityTopicDto {
@ApiProperty({ description: 'Internal Topic ID of the notification', type: String })
_topicId: string;
@ApiProperty({ description: 'Topic Key of the notification', type: String })
topicKey: string;
}
// Activity Notification Response DTO
export class ActivityNotificationResponseDto {
@ApiPropertyOptional({ description: 'Unique identifier of the notification', type: String })
_id?: string;
@ApiProperty({ description: 'Environment ID of the notification', type: String })
_environmentId: string;
@ApiProperty({ description: 'Organization ID of the notification', type: String })
_organizationId: string;
@ApiProperty({ description: 'Subscriber ID of the notification', type: String })
_subscriberId: string; // Added to align with NotificationEntity
@ApiProperty({ description: 'Transaction ID of the notification', type: String })
transactionId: string;
@ApiPropertyOptional({ description: 'Template ID of the notification', type: String })
_templateId?: string; // Added to align with NotificationEntity
@ApiPropertyOptional({ description: 'Digested Notification ID', type: String })
_digestedNotificationId?: string; // Added to align with NotificationEntity
@ApiPropertyOptional({ description: 'Creation time of the notification', type: String })
createdAt?: string;
@ApiPropertyOptional({ description: 'Last updated time of the notification', type: String })
updatedAt?: string; // Added to align with NotificationEntity
@ApiPropertyOptional({
description: 'Channels of the notification',
enum: [...Object.values(StepTypeEnum)],
enumName: 'StepTypeEnum',
isArray: true,
type: String,
})
channels?: StepTypeEnum[];
@ApiPropertyOptional({
description: 'Subscriber of the notification',
type: ActivityNotificationSubscriberResponseDto,
})
subscriber?: ActivityNotificationSubscriberResponseDto;
@ApiPropertyOptional({
description: 'Template of the notification',
type: ActivityNotificationTemplateResponseDto,
})
template?: ActivityNotificationTemplateResponseDto;
@ApiPropertyOptional({
description: 'Jobs of the notification',
type: [ActivityNotificationJobResponseDto],
})
jobs?: ActivityNotificationJobResponseDto[];
@ApiPropertyOptional({
description: 'Payload of the notification',
type: 'object',
required: false,
additionalProperties: true,
})
payload?: Record; // Added to align with NotificationEntity
@ApiPropertyOptional({
description: 'Tags associated with the notification',
type: [String],
})
tags?: string[]; // Added to align with NotificationEntity
@ApiPropertyOptional({
description: 'Controls associated with the notification',
type: 'object',
required: false,
additionalProperties: true,
})
controls?: Record; // Added to align with NotificationEntity
@ApiPropertyOptional({
description: 'To field for subscriber definition',
type: 'object',
required: false,
additionalProperties: true,
})
to?: Record; // Added to align with NotificationEntity
@ApiPropertyOptional({ description: 'Topics of the notification', type: [ActivityTopicDto] })
topics?: ActivityTopicDto[];
@ApiPropertyOptional({
description: 'Severity of the notification',
enum: [...Object.values(SeverityLevelEnum)],
enumName: 'SeverityLevelEnum',
})
severity: SeverityLevelEnum;
@ApiPropertyOptional({ description: 'Criticality of the notification', type: Boolean })
critical?: boolean;
@ApiPropertyOptional({ description: 'Context (single or multi) in which the notification was sent', type: [String] })
contextKeys?: string[];
}
// Activities Response DTO
export class ActivitiesResponseDto {
@ApiProperty({ description: 'Indicates if there are more activities in the result set', type: Boolean })
hasMore: boolean;
@ApiProperty({
description: 'Array of activity notifications',
type: [ActivityNotificationResponseDto],
})
data: ActivityNotificationResponseDto[];
@ApiProperty({ description: 'Page size of the activities', type: Number })
pageSize: number;
@ApiProperty({ description: 'Current page of the activities', type: Number })
page: number;
}
================================================
FILE: apps/api/src/app/notifications/dtos/activity-graph-states-response.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { ChannelTypeEnum } from '@novu/shared';
export class ActivityGraphStatesResponse {
@ApiProperty()
_id: string;
@ApiProperty()
count: number;
@ApiProperty()
templates: string[];
@ApiProperty({
enum: ChannelTypeEnum,
isArray: true,
})
channels: ChannelTypeEnum[];
}
================================================
FILE: apps/api/src/app/notifications/dtos/activity-stats-response.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
export class ActivityStatsResponseDto {
@ApiProperty()
weeklySent: number;
@ApiProperty()
monthlySent: number;
}
================================================
FILE: apps/api/src/app/notifications/e2e/get-activity-feed.e2e.ts
================================================
import { Novu } from '@novu/api';
import { ActivityNotificationResponseDto, ChannelTypeEnum } from '@novu/api/models/components';
import { NotificationTemplateEntity, NotificationTemplateRepository, SubscriberRepository } from '@novu/dal';
import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
describe('Get activity feed - /notifications (GET) #novu-v2', async () => {
let session: UserSession;
let template: NotificationTemplateEntity;
let smsOnlyTemplate: NotificationTemplateEntity;
let subscriberId: string;
let novuClient: Novu;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
template = await session.createTemplate();
smsOnlyTemplate = await session.createChannelTemplate(StepTypeEnum.SMS);
subscriberId = SubscriberRepository.createObjectId();
novuClient = initNovuClassSdk(session);
await session.testAgent
.post('/v1/widgets/session/initialize')
.send({
applicationIdentifier: session.environment.identifier,
subscriberId,
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
})
.expect(201);
});
it('should get the current activity feed of user', async () => {
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: { firstName: 'Test' },
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: { firstName: 'Test' },
});
await session.waitForJobCompletion(template._id);
const body = await novuClient.notifications.list({ page: 0 });
const activities = body.result;
expect(activities.hasMore).to.equal(false);
expect(activities.data.length, JSON.stringify(body.result)).to.equal(2);
const activity = activities.data[0];
if (!activity || !activity.template || !activity.subscriber) {
throw new Error(`must have activity${JSON.stringify(activity)}`);
}
expect(activity.template.name).to.equal(template.name);
expect(activity.template.id).to.equal(template._id);
expect(activity.subscriber.firstName).to.equal('Test');
expect(activity.channels).to.be.ok;
expect(activity.channels).to.include.oneOf(Object.keys(ChannelTypeEnum).map((i) => ChannelTypeEnum[i]));
});
it('should filter by channel', async () => {
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: { firstName: 'Test' },
});
await novuClient.trigger({
workflowId: smsOnlyTemplate.triggers[0].identifier,
to: subscriberId,
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: smsOnlyTemplate.triggers[0].identifier,
to: subscriberId,
payload: {
firstName: 'Test',
},
});
await session.waitForJobCompletion([template._id, smsOnlyTemplate._id]);
await novuClient.notifications.list({ page: 0, transactionId: ChannelTypeEnum.Sms });
const body = await novuClient.notifications.list({ page: 0, channels: [ChannelTypeEnum.Sms] });
const activities = body.result;
expect(activities.hasMore).to.equal(false);
expect(activities.data.length).to.equal(2);
const activity = activities.data[0];
if (!activity || !activity.template || !activity.subscriber) {
throw new Error('must have activity');
}
expect(activity.template?.name).to.equal(smsOnlyTemplate.name);
expect(activity.channels).to.include(ChannelTypeEnum.Sms);
});
it('should filter by templateId', async () => {
await novuClient.trigger({
workflowId: smsOnlyTemplate.triggers[0].identifier,
to: subscriberId,
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: { firstName: 'Test' },
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: { firstName: 'Test' },
});
await session.waitForJobCompletion(template._id);
const body = await novuClient.notifications.list({ page: 0, templates: [template._id] });
const activities = body.result;
expect(activities.hasMore).to.equal(false);
expect(activities.data.length).to.equal(2);
expect(getActivity(activities.data, 0).template?.id).to.equal(template._id);
expect(getActivity(activities.data, 1).template?.id).to.equal(template._id);
});
function getActivity(
activities: Array,
index: number
): ActivityNotificationResponseDto {
const activity = activities[index];
if (!activity || !activity.template || !activity.subscriber) {
throw new Error('must have activity');
}
return activity;
}
it('should filter by email', async () => {
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: {
subscriberId: SubscriberRepository.createObjectId(),
email: 'test@email.coms',
},
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: {
subscriberId: SubscriberRepository.createObjectId(),
},
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: SubscriberRepository.createObjectId(),
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: SubscriberRepository.createObjectId(),
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: {
firstName: 'Test',
},
});
await session.waitForJobCompletion(template._id);
const activities = (await novuClient.notifications.list({ page: 0, emails: ['test@email.coms'] })).result.data;
expect(activities.length).to.equal(1);
expect(getActivity(activities, 0).template?.id).to.equal(template._id);
});
it('should filter by subscriberId', async () => {
const subscriberIdToCreate = `${SubscriberRepository.createObjectId()}some-test`;
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: {
subscriberId: subscriberIdToCreate,
email: 'test@email.coms',
},
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: SubscriberRepository.createObjectId(),
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: SubscriberRepository.createObjectId(),
payload: {
firstName: 'Test',
},
});
await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: subscriberId,
payload: {
firstName: 'Test',
},
});
await session.waitForJobCompletion(template._id);
const { result } = await novuClient.notifications.list({ page: 0, subscriberIds: [subscriberIdToCreate] });
const activities = result.data;
expect(activities.length).to.equal(1);
expect(activities[0].template?.id, JSON.stringify(template)).to.equal(template._id);
});
it('should return with deleted workflow and subscriber data', async () => {
const notificationTemplateRepository = new NotificationTemplateRepository();
const subscriberRepository = new SubscriberRepository();
const templateToDelete = await session.createTemplate();
const subscriberIdToDelete = `${SubscriberRepository.createObjectId()}`;
await novuClient.trigger({
workflowId: templateToDelete.triggers[0].identifier,
to: subscriberIdToDelete,
payload: { firstName: 'Test' },
});
await session.waitForJobCompletion(templateToDelete._id);
await notificationTemplateRepository.delete({ _id: templateToDelete._id, _environmentId: session.environment._id });
const subscriberToDelete = await subscriberRepository.findOne({
subscriberId: subscriberIdToDelete,
_environmentId: session.environment._id,
});
await subscriberRepository.delete({ _id: subscriberToDelete?._id, _environmentId: session.environment._id });
const body = await novuClient.notifications.list({ page: 0 });
const activities = body.result;
expect(activities.hasMore).to.equal(false);
expect(activities.data.length, JSON.stringify(body.result)).to.equal(1);
const activity = activities.data[0];
expect(activity.template).to.be.undefined;
expect(activity.subscriber).to.be.undefined;
expect(activity.channels).to.be.ok;
expect(activity.channels).to.include.oneOf(Object.keys(ChannelTypeEnum).map((i) => ChannelTypeEnum[i]));
});
it('should filter by contextKeys', async () => {
const workflowBody: CreateWorkflowDto = {
name: 'Test Context Workflow',
workflowId: 'test-context-workflow-notifications',
__source: WorkflowCreationSourceEnum.DASHBOARD,
steps: [
{
type: StepTypeEnum.IN_APP,
name: 'Test Step',
controlValues: {
subject: 'Test Subject',
body: 'Test Body',
},
},
],
};
const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody);
expect(workflowResponse.status).to.equal(201);
const workflow: WorkflowResponseDto = workflowResponse.body.data;
await novuClient.trigger({
workflowId: workflow.workflowId,
to: subscriberId,
payload: {},
context: { projectId: 'project-alpha' },
});
await novuClient.trigger({
workflowId: workflow.workflowId,
to: subscriberId,
payload: {},
context: { projectId: 'project-beta' },
});
await session.waitForWorkflowQueueCompletion();
await session.waitForSubscriberQueueCompletion();
await session.waitForStandardQueueCompletion();
await session.waitForJobCompletion(workflow._id);
// Test 1: No contextKeys filter - should return all notifications
let body = await novuClient.notifications.list({ page: 0 });
expect(body.result.data.length).to.be.equal(2);
// Test 2: Filter by specific context - should return only matching notification
body = await novuClient.notifications.list({ page: 0, contextKeys: ['projectId:project-alpha'] });
expect(body.result.data.length).to.be.equal(1);
expect(body.result.data[0].template?.id).to.equal(workflow._id);
expect(body.result.data[0].contextKeys).to.deep.equal(['projectId:project-alpha']);
// Test 3: Filter by different context - should return only matching notification
body = await novuClient.notifications.list({ page: 0, contextKeys: ['projectId:project-beta'] });
expect(body.result.data.length).to.be.equal(1);
expect(body.result.data[0].template?.id).to.equal(workflow._id);
expect(body.result.data[0].contextKeys).to.deep.equal(['projectId:project-beta']);
});
});
================================================
FILE: apps/api/src/app/notifications/e2e/get-activity.e2e.ts
================================================
import { Novu } from '@novu/api';
import { ActivityNotificationResponseDto } from '@novu/api/models/components';
import { MessageRepository, NotificationRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal';
import { JobStatusEnum, StepTypeEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
describe('Get activity - /notifications/:notificationId (GET) #novu-v2', async () => {
let session: UserSession;
let template: NotificationTemplateEntity;
let novuClient: Novu;
let originalTraceReadValue: string | undefined;
let originalTraceWriteValue: string | undefined;
let originalStepRunEnvValue: string | undefined;
const messageRepository: MessageRepository = new MessageRepository();
const notificationRepository: NotificationRepository = new NotificationRepository();
const updateNotification = async ({
id,
status,
body,
}: {
id: string;
status: 'read' | 'unread' | 'archive' | 'unarchive' | 'snooze' | 'unsnooze';
body?: any;
}) => {
return await session.testAgent
.patch(`/v1/inbox/notifications/${id}/${status}`)
.set('Authorization', `Bearer ${session.subscriberToken}`)
.send(body);
};
before(async () => {
originalTraceReadValue = process.env.IS_TRACE_LOGS_READ_ENABLED;
originalTraceWriteValue = process.env.IS_TRACE_LOGS_ENABLED;
(process.env as any).IS_TRACE_LOGS_READ_ENABLED = 'true';
(process.env as any).IS_TRACE_LOGS_ENABLED = 'true';
});
after(async () => {
if (originalTraceReadValue === undefined) {
delete (process.env as any).IS_TRACE_LOGS_READ_ENABLED;
} else {
(process.env as any).IS_TRACE_LOGS_READ_ENABLED = originalTraceReadValue;
}
if (originalTraceWriteValue === undefined) {
delete (process.env as any).IS_TRACE_LOGS_ENABLED;
} else {
(process.env as any).IS_TRACE_LOGS_ENABLED = originalTraceWriteValue;
}
if (originalStepRunEnvValue === undefined) {
delete (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED;
}
if (originalStepRunEnvValue !== undefined) {
(process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED = originalStepRunEnvValue;
}
});
beforeEach(async () => {
session = new UserSession();
await session.initialize();
template = await session.createTemplate({
steps: [
{
type: StepTypeEnum.IN_APP,
content: 'Test notification content {{name}}',
},
],
});
novuClient = initNovuClassSdk(session);
});
it('should return traces in activity feed when traces feature flag is enabled', async () => {
// Step 1: Trigger a notification to create trace logs
const triggerResponse = await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: session.subscriberId,
payload: { name: 'Test User' },
});
expect(triggerResponse.result?.acknowledged).to.equal(true);
// Step 2: Wait for the worker to process the notification and create traces
await session.waitForJobCompletion(template._id);
const message = await messageRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(message).to.be.ok;
if (!message) throw new Error('Message not found');
const { body, status } = await updateNotification({
id: message._id,
status: 'read',
});
expect(status).to.equal(200);
const notification = await notificationRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(notification).to.be.ok;
if (!notification) throw new Error('Notification not found');
const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200);
const activity: ActivityNotificationResponseDto = activityResponse.body.data;
expect(activity).to.be.ok;
if (!activity.jobs) throw new Error('Jobs not found');
expect(activity.jobs).to.be.an('array');
const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail);
const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent', 'Message read'];
expect(actualDetails.length).to.be.equal(4);
expectedExecutionDetails.forEach((expectedDetail) => {
expect(actualDetails).to.include(expectedDetail);
});
});
it('should fallback to old method when traces query fails', async () => {
const triggerResponse = await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: session.subscriberId,
payload: { name: 'Test User' },
});
await session.waitForJobCompletion(template._id);
const message = await messageRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(message).to.be.ok;
if (!message) throw new Error('Message not found');
const notification = await notificationRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(notification).to.be.ok;
if (!notification) throw new Error('Notification not found');
const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200);
const activity: ActivityNotificationResponseDto = activityResponse.body.data;
expect(activity).to.be.ok;
if (!activity.jobs) throw new Error('Jobs not found');
expect(activity.jobs).to.be.an('array');
const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail);
const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent'];
expect(actualDetails.length).to.be.equal(3);
expectedExecutionDetails.forEach((expectedDetail) => {
expect(actualDetails).to.include(
expectedDetail,
`Expected execution detail '${expectedDetail}' not found in job. Found: ${actualDetails.join(', ')}`
);
});
expect(actualDetails).to.not.include('Message read');
});
it('should return traces in activity feed with step runs and trace logs', async () => {
// Step 1: Trigger a notification to create trace logs
const triggerResponse = await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: session.subscriberId,
payload: { name: 'Test User' },
});
expect(triggerResponse.result?.acknowledged).to.equal(true);
// Step 2: Wait for the worker to process the notification and create traces
await session.waitForJobCompletion(template._id);
const message = await messageRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(message).to.be.ok;
if (!message) throw new Error('Message not found');
const { body, status } = await updateNotification({
id: message._id,
status: 'read',
});
expect(status).to.equal(200);
const notification = await notificationRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(notification).to.be.ok;
if (!notification) throw new Error('Notification not found');
const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200);
const activity: ActivityNotificationResponseDto = activityResponse.body.data;
expect(activity).to.be.ok;
if (!activity.jobs) throw new Error('Jobs not found');
expect(activity.jobs).to.be.an('array');
const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail);
const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent', 'Message read'];
expect(actualDetails.length).to.be.equal(4);
expectedExecutionDetails.forEach((expectedDetail) => {
expect(actualDetails).to.include(
expectedDetail,
`Expected execution detail '${expectedDetail}' not found in job. Found: ${actualDetails.join(', ')}`
);
});
});
it('should use step runs when both trace and step run feature flags are enabled', async () => {
// Enable both feature flags
(process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED = 'true';
const triggerResponse = await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: session.subscriberId,
payload: { name: 'Test User' },
});
expect(triggerResponse.result?.acknowledged).to.equal(true);
await session.waitForJobCompletion(template._id);
const notification = await notificationRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(notification).to.be.ok;
if (!notification) throw new Error('Notification not found');
const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200);
const activity: ActivityNotificationResponseDto = activityResponse.body.data;
expect(activity).to.be.ok;
expect(activity.jobs?.length).to.be.equal(2);
expect(activity.jobs?.[0].type).to.be.equal(StepTypeEnum.TRIGGER);
expect(activity.jobs?.[0].status).to.be.equal(JobStatusEnum.COMPLETED);
expect(activity.jobs?.[1].type).to.be.equal(StepTypeEnum.IN_APP);
expect(activity.jobs?.[1].status).to.be.equal(JobStatusEnum.COMPLETED);
// Reset feature flag
delete (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED;
});
it('should fallback to trace log method when step runs are not found', async () => {
/*
* Enable both feature flags
* (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED = 'true';
*/
const triggerResponse = await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: session.subscriberId,
payload: { name: 'Test User' },
});
expect(triggerResponse.result?.acknowledged).to.equal(true);
await session.waitForJobCompletion(template._id);
const message = await messageRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(message).to.be.ok;
if (!message) throw new Error('Message not found');
const notification = await notificationRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(notification).to.be.ok;
if (!notification) throw new Error('Notification not found');
const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200);
const activity: ActivityNotificationResponseDto = activityResponse.body.data;
expect(activity).to.be.ok;
// Should still return jobs (even if from step_runs)
expect(activity.jobs?.length).to.be.equal(1);
expect(activity.jobs?.[0].type).to.be.equal(StepTypeEnum.IN_APP);
expect(activity.jobs?.[0].status).to.be.equal(JobStatusEnum.COMPLETED);
});
it('should fallback to old method when traces query fails', async () => {
const triggerResponse = await novuClient.trigger({
workflowId: template.triggers[0].identifier,
to: session.subscriberId,
payload: { name: 'Test User' },
});
await session.waitForJobCompletion(template._id);
const message = await messageRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(message).to.be.ok;
if (!message) throw new Error('Message not found');
const notification = await notificationRepository.findOne({
_environmentId: session.environment._id,
_subscriberId: session.subscriberProfile?._id,
_templateId: template._id,
transactionId: triggerResponse.result?.transactionId,
});
expect(notification).to.be.ok;
if (!notification) throw new Error('Notification not found');
const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200);
const activity: ActivityNotificationResponseDto = activityResponse.body.data;
expect(activity).to.be.ok;
if (!activity.jobs) throw new Error('Jobs not found');
expect(activity.jobs).to.be.an('array');
const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail);
const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent'];
expect(actualDetails.length).to.be.equal(3);
expectedExecutionDetails.forEach((expectedDetail) => {
expect(actualDetails).to.include(
expectedDetail,
`Expected execution detail '${expectedDetail}' not found in job. Found: ${actualDetails.join(', ')}`
);
});
expect(actualDetails).to.not.include('Message read');
});
});
================================================
FILE: apps/api/src/app/notifications/notification.controller.ts
================================================
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { ChannelTypeEnum, PermissionsEnum, SeverityLevelEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ApiCommonResponses, ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator';
import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators';
import { UserSession } from '../shared/framework/user.decorator';
import { ActivitiesRequestDto } from './dtos/activities-request.dto';
import { ActivitiesResponseDto, ActivityNotificationResponseDto } from './dtos/activities-response.dto';
import { ActivityGraphStatesResponse } from './dtos/activity-graph-states-response.dto';
import { ActivityStatsResponseDto } from './dtos/activity-stats-response.dto';
import { GetActivityCommand } from './usecases/get-activity/get-activity.command';
import { GetActivity } from './usecases/get-activity/get-activity.usecase';
import { GetActivityFeedCommand } from './usecases/get-activity-feed/get-activity-feed.command';
import { GetActivityFeed } from './usecases/get-activity-feed/get-activity-feed.usecase';
import { GetActivityGraphStatsCommand } from './usecases/get-activity-graph-states/get-activity-graph-states.command';
import { GetActivityGraphStats } from './usecases/get-activity-graph-states/get-activity-graph-states.usecase';
import { GetActivityStats, GetActivityStatsCommand } from './usecases/get-activity-stats';
@ApiCommonResponses()
@RequireAuthentication()
@Controller('/notifications')
@ApiTags('Notifications')
export class NotificationsController {
constructor(
private getActivityFeedUsecase: GetActivityFeed,
private getActivityStatsUsecase: GetActivityStats,
private getActivityGraphStatsUsecase: GetActivityGraphStats,
private getActivityUsecase: GetActivity
) {}
@Get('')
@ApiOkResponse({
type: ActivitiesResponseDto,
})
@ApiOperation({
summary: 'List all events',
description: `List all notification events (triggered events) for the current environment.
This API supports filtering by **channels**, **templates**, **emails**, **subscriberIds**, **transactionId**, **topicKey**, **severity**, **contextKeys**.
Checkout all available filters in the query section.
This API returns event triggers, to list each channel notifications, check messages APIs.`,
})
@ExternalApiAccessible()
@RequirePermissions(PermissionsEnum.NOTIFICATION_READ)
async listNotifications(
@UserSession() user: UserSessionData,
@Query() query: ActivitiesRequestDto
): Promise {
let channelsQuery: ChannelTypeEnum[] | null = null;
if (query.channels) {
channelsQuery = Array.isArray(query.channels) ? query.channels : [query.channels];
}
let templatesQuery: string[] | null = null;
if (query.templates) {
templatesQuery = Array.isArray(query.templates) ? query.templates : [query.templates];
}
let emailsQuery: string[] = [];
if (query.emails) {
emailsQuery = Array.isArray(query.emails) ? query.emails : [query.emails];
}
let subscribersQuery: string[] = [];
if (query.subscriberIds) {
subscribersQuery = Array.isArray(query.subscriberIds) ? query.subscriberIds : [query.subscriberIds];
}
let transactionIdQuery: string[] | undefined;
if (query.transactionId) {
transactionIdQuery = Array.isArray(query.transactionId) ? query.transactionId : [query.transactionId];
}
let severityQuery: SeverityLevelEnum[] | null = null;
if (query.severity) {
severityQuery = Array.isArray(query.severity) ? query.severity : [query.severity];
}
return this.getActivityFeedUsecase.execute(
GetActivityFeedCommand.create({
page: query.page,
limit: query.limit,
organizationId: user.organizationId,
environmentId: user.environmentId,
userId: user._id,
channels: channelsQuery,
templates: templatesQuery,
emails: emailsQuery,
search: query.search,
subscriberIds: subscribersQuery,
transactionId: transactionIdQuery,
topicKey: query.topicKey,
subscriptionId: query.subscriptionId,
severity: severityQuery,
after: query.after,
before: query.before,
contextKeys: query.contextKeys,
})
);
}
@ApiResponse(ActivityStatsResponseDto)
@ApiExcludeEndpoint()
@ApiOperation({
summary: 'Retrieve events statistics',
description: `Retrieve notification statistics for the current environment.
This API returns the number of weekly and monthly notifications sent for the current environment.`,
deprecated: true,
})
@Get('/stats')
@ExternalApiAccessible()
@SdkGroupName('Notifications.Stats')
@RequirePermissions(PermissionsEnum.NOTIFICATION_READ)
getActivityStats(@UserSession() user: UserSessionData): Promise {
return this.getActivityStatsUsecase.execute(
GetActivityStatsCommand.create({
organizationId: user.organizationId,
environmentId: user.environmentId,
})
);
}
@Get('/graph/stats')
@ExternalApiAccessible()
@ApiExcludeEndpoint()
@ApiResponse(ActivityGraphStatesResponse, 200, true)
@ApiOperation({
summary: 'Retrieve events graph statistics',
description: `Retrieve events graph statistics for the current environment.
This API returns the number of events sent. This data is used to generate the graph in the legacy dashboard.`,
deprecated: true,
})
@ApiQuery({
name: 'days',
type: Number,
required: false,
})
@SdkGroupName('Notifications.Stats')
@SdkMethodName('graph')
@RequirePermissions(PermissionsEnum.NOTIFICATION_READ)
getActivityGraphStats(
@UserSession() user: UserSessionData,
@Query('days') days = 32
): Promise {
return this.getActivityGraphStatsUsecase.execute(
GetActivityGraphStatsCommand.create({
days: days ? Number(days) : 32,
organizationId: user.organizationId,
environmentId: user.environmentId,
userId: user._id,
})
);
}
@Get('/:notificationId')
@ApiResponse(ActivityNotificationResponseDto)
@ApiOperation({
summary: 'Retrieve an event',
description: `Retrieve an event by its unique key identifier **notificationId**.
Here **notificationId** is of mongodbId type.
This API returns the event details - execution logs, status, actual notification (message) generated by each workflow step.`,
})
@ExternalApiAccessible()
@RequirePermissions(PermissionsEnum.NOTIFICATION_READ)
getNotification(
@UserSession() user: UserSessionData,
@Param('notificationId') notificationId: string
): Promise {
return this.getActivityUsecase.execute(
GetActivityCommand.create({
notificationId,
organizationId: user.organizationId,
environmentId: user.environmentId,
userId: user._id,
})
);
}
}
================================================
FILE: apps/api/src/app/notifications/notification.module.ts
================================================
import { Module } from '@nestjs/common';
import { CommunityOrganizationRepository } from '@novu/dal';
import { AuthModule } from '../auth/auth.module';
import { SharedModule } from '../shared/shared.module';
import { NotificationsController } from './notification.controller';
import { USE_CASES } from './usecases';
@Module({
imports: [SharedModule, AuthModule],
providers: [...USE_CASES, CommunityOrganizationRepository],
controllers: [NotificationsController],
})
export class NotificationModule {}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity/get-activity.command.ts
================================================
import { IsDefined, IsMongoId } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetActivityCommand extends EnvironmentWithUserCommand {
@IsDefined()
@IsMongoId()
notificationId: string;
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity/get-activity.usecase.ts
================================================
import { Injectable, NotFoundException } from '@nestjs/common';
import {
AnalyticsService,
FeatureFlagsService,
PinoLogger,
QueryBuilder,
StepRun,
StepRunRepository,
Trace,
TraceLogRepository,
WorkflowRun,
WorkflowRunRepository,
} from '@novu/application-generic';
import {
ExecutionDetailFeedItem,
JobFeedItem,
JobStatusEnum,
NotificationFeedItemEntity,
NotificationRepository,
NotificationStepEntity,
} from '@novu/dal';
import {
ExecutionDetailsSourceEnum,
ExecutionDetailsStatusEnum,
FeatureFlagsKeysEnum,
ProvidersIdEnum,
StepTypeEnum,
TriggerTypeEnum,
} from '@novu/shared';
import { ActivityNotificationResponseDto } from '../../dtos/activities-response.dto';
import { mapFeedItemToDto } from '../get-activity-feed/map-feed-item-to.dto';
import { GetActivityCommand } from './get-activity.command';
const workflowRunSelectColumns = [
'workflow_run_id',
'workflow_id',
'workflow_name',
'organization_id',
'environment_id',
'subscriber_id',
'external_subscriber_id',
'trigger_identifier',
'transaction_id',
'channels',
'subscriber_to',
'payload',
'topics',
'context_keys',
'created_at',
'updated_at',
] as const;
const stepRunSelectColumns = [
'step_run_id',
'step_id',
'step_type',
'provider_id',
'status',
'created_at',
'updated_at',
'schedule_extensions_count',
] as const;
type StepRunFetchResult = Pick;
const traceSelectColumns = ['id', 'entity_id', 'title', 'status', 'created_at', 'raw_data'] as const;
@Injectable()
export class GetActivity {
constructor(
private notificationRepository: NotificationRepository,
private analyticsService: AnalyticsService,
private traceLogRepository: TraceLogRepository,
private stepRunRepository: StepRunRepository,
private workflowRunRepository: WorkflowRunRepository,
private logger: PinoLogger,
private featureFlagsService: FeatureFlagsService
) {}
async execute(command: GetActivityCommand): Promise {
this.analyticsService.track('Get Activity Feed Item - [Activity Feed]', command.userId, {
_organization: command.organizationId,
});
const flagContext = {
organization: { _id: command.organizationId },
user: { _id: command.userId },
environment: { _id: command.environmentId },
} as const;
const [tracesEnabled, stepRunsEnabled, workflowRunsEnabled] = await Promise.all([
this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_TRACE_LOGS_READ_ENABLED,
defaultValue: false,
...flagContext,
}),
this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_STEP_RUN_LOGS_READ_ENABLED,
defaultValue: false,
...flagContext,
}),
this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_WORKFLOW_RUN_LOGS_READ_ENABLED,
defaultValue: false,
...flagContext,
}),
]);
this.logger.debug({
tracesEnabled,
stepRunsEnabled,
workflowRunsEnabled,
}, 'feature flags');
let feedItem: NotificationFeedItemEntity | null = null;
if (workflowRunsEnabled && stepRunsEnabled && tracesEnabled) {
this.logger.debug('analytics full ingegration enabled');
feedItem = await this.getFeedItemFromWorkflowRuns(command);
} else if (tracesEnabled && stepRunsEnabled) {
this.logger.debug('analytics step runs enabled, no workflow runs');
feedItem = await this.getFeedItemFromStepRuns(command);
} else if (tracesEnabled) {
this.logger.debug('analytics traces enabled, no step runs or workflow runs');
feedItem = await this.getFeedItemFromTraceLog(command);
} else {
this.logger.debug('analytics fallback to old method');
feedItem = await this.notificationRepository.getFeedItem(
command.notificationId,
command.environmentId,
command.organizationId
);
}
if (!feedItem) {
throw new NotFoundException('Notification not found', {
cause: `Notification with id ${command.notificationId} not found`,
});
}
return mapFeedItemToDto(feedItem);
}
private mapTraceStatusToExecutionStatus(traceStatus: string): ExecutionDetailsStatusEnum {
switch (traceStatus.toLowerCase()) {
case 'success':
return ExecutionDetailsStatusEnum.SUCCESS;
case 'error':
case 'failed':
return ExecutionDetailsStatusEnum.FAILED;
case 'warning':
return ExecutionDetailsStatusEnum.WARNING;
case 'pending':
return ExecutionDetailsStatusEnum.PENDING;
case 'queued':
return ExecutionDetailsStatusEnum.QUEUED;
default:
return ExecutionDetailsStatusEnum.PENDING;
}
}
private async getExecutionDetailsByEntityId(
entityIds: string[],
command: GetActivityCommand
): Promise> {
if (entityIds.length === 0) {
return new Map();
}
const traceQuery = new QueryBuilder({
environmentId: command.environmentId,
})
.whereIn('entity_id', entityIds)
.whereEquals('entity_type', 'step_run')
.build();
const traceResult = await this.traceLogRepository.find({
where: traceQuery,
orderBy: 'created_at',
orderDirection: 'ASC',
select: traceSelectColumns,
});
const executionDetailsByEntityId = new Map();
// Group traces by entity ID
const traceLogsByEntityId = new Map();
for (const trace of traceResult.data) {
if (!traceLogsByEntityId.has(trace.entity_id)) {
traceLogsByEntityId.set(trace.entity_id, []);
}
// biome-ignore lint/style/noNonNullAssertion: we we create it in the if above
traceLogsByEntityId.get(trace.entity_id)!.push(trace);
}
// Convert traces to execution details for each entity
for (const [entityId, traces] of traceLogsByEntityId) {
const executionDetails: ExecutionDetailFeedItem[] = traces.map((trace) => ({
_id: trace.id,
// TODO: add providerId from traces
providerId: undefined, // Will be overridden by step runs if available
detail: trace.title,
source: ExecutionDetailsSourceEnum.INTERNAL,
_jobId: entityId,
status: this.mapTraceStatusToExecutionStatus(trace.status),
isTest: false,
isRetry: false,
createdAt: new Date(trace.created_at).toISOString(),
raw: trace.raw_data,
}));
executionDetailsByEntityId.set(entityId, executionDetails);
}
return executionDetailsByEntityId;
}
private async processStepRunsForFeedItem(
feedItem: NotificationFeedItemEntity,
command: GetActivityCommand
): Promise {
const stepRunsQuery = new QueryBuilder({
environmentId: command.environmentId,
})
.whereEquals('transaction_id', feedItem.transactionId)
.build();
const stepRunsResult = await this.stepRunRepository.find({
where: stepRunsQuery,
orderBy: 'created_at',
orderDirection: 'ASC',
useFinal: true,
select: stepRunSelectColumns,
});
if (!stepRunsResult.data || stepRunsResult.data.length === 0) {
return [];
}
const stepRunIds = stepRunsResult.data.map((stepRun) => stepRun.step_run_id);
const executionDetailsByStepRunId = await this.getExecutionDetailsByEntityId(stepRunIds, command);
return stepRunsResult.data.map((stepRun) => mapStepRunToJob(stepRun, executionDetailsByStepRunId));
}
private async getFeedItemFromStepRuns(command: GetActivityCommand): Promise {
try {
const feedItem = await this.notificationRepository.findNotificationMetadataOnly(
command.notificationId,
command.environmentId,
command.organizationId
);
if (!feedItem) {
return null;
}
// Process step runs and add them to the feed item
feedItem.jobs = await this.processStepRunsForFeedItem(feedItem, command);
return feedItem;
} catch (error) {
this.logger.error(
{
error: error instanceof Error ? error.message : 'Unknown error',
notificationId: command.notificationId,
environmentId: command.environmentId,
organizationId: command.organizationId,
},
'Failed to get feed item from step runs'
);
// Fall back to the current stage 1 method (traces + jobs from MongoDB)
return await this.getFeedItemFromTraceLog(command);
}
}
private async getFeedItemFromWorkflowRuns(command: GetActivityCommand): Promise {
try {
const workflowRunQuery = new QueryBuilder({
environmentId: command.environmentId,
})
.whereEquals('workflow_run_id', command.notificationId)
.build();
const workflowRunsResult = await this.workflowRunRepository.find({
where: workflowRunQuery,
orderBy: 'created_at',
orderDirection: 'ASC',
limit: 1,
useFinal: true,
select: workflowRunSelectColumns,
});
if (!workflowRunsResult.data || workflowRunsResult.data.length === 0) {
this.logger.warn(
{
notificationId: command.notificationId,
environmentId: command.environmentId,
organizationId: command.organizationId,
},
'No workflow run found in ClickHouse, falling back to step runs'
);
// Fall back to step runs method
return await this.getFeedItemFromStepRuns(command);
}
const mostRecentWorkflowRun = workflowRunsResult.data[0];
// Create the base feed item from workflow run data
const feedItem: NotificationFeedItemEntity = {
_id: mostRecentWorkflowRun.workflow_run_id,
_organizationId: mostRecentWorkflowRun.organization_id,
_environmentId: mostRecentWorkflowRun.environment_id,
_templateId: mostRecentWorkflowRun.workflow_id,
_subscriberId: mostRecentWorkflowRun.subscriber_id,
transactionId: mostRecentWorkflowRun.transaction_id,
template: {
_id: mostRecentWorkflowRun.workflow_id,
name: mostRecentWorkflowRun.workflow_name,
triggers: [
{
identifier: mostRecentWorkflowRun.trigger_identifier,
type: TriggerTypeEnum.EVENT,
variables: [],
},
],
},
subscriber: {
_id: mostRecentWorkflowRun.subscriber_id,
subscriberId: mostRecentWorkflowRun.external_subscriber_id || '',
firstName: '',
lastName: '',
email: '',
phone: undefined,
},
jobs: [],
to: mostRecentWorkflowRun.subscriber_to ? JSON.parse(mostRecentWorkflowRun.subscriber_to) : {},
payload: mostRecentWorkflowRun.payload ? JSON.parse(mostRecentWorkflowRun.payload) : {},
contextKeys: mostRecentWorkflowRun.context_keys,
createdAt: new Date(mostRecentWorkflowRun.created_at).toISOString(),
updatedAt: new Date(mostRecentWorkflowRun.updated_at).toISOString(),
channels: mostRecentWorkflowRun.channels ? JSON.parse(mostRecentWorkflowRun.channels) : [],
topics: mostRecentWorkflowRun.topics ? JSON.parse(mostRecentWorkflowRun.topics) : [],
};
feedItem.jobs = await this.processStepRunsForFeedItem(feedItem, command);
return feedItem;
} catch (error) {
this.logger.error(
{
error: error instanceof Error ? error.message : 'Unknown error',
notificationId: command.notificationId,
environmentId: command.environmentId,
organizationId: command.organizationId,
},
'Failed to get feed item from workflow runs'
);
// Fall back to step runs method
return await this.getFeedItemFromStepRuns(command);
}
}
private async getFeedItemFromTraceLog(command: GetActivityCommand) {
try {
const feedItem = await this.notificationRepository.findMetadataForTraces(
command.notificationId,
command.environmentId,
command.organizationId
);
if (!feedItem) {
return null;
}
const jobIds = feedItem.jobs.map((job) => job._id);
if (jobIds.length === 0) {
return feedItem;
}
const executionDetailsByJobId = await this.getExecutionDetailsByEntityId(jobIds, command);
feedItem.jobs = feedItem.jobs.map((job) => {
const executionDetails = executionDetailsByJobId.get(job._id) || [];
return {
...job,
executionDetails,
};
});
return feedItem;
} catch (error) {
this.logger.error(
{
error: error instanceof Error ? error.message : 'Unknown error',
notificationId: command.notificationId,
environmentId: command.environmentId,
organizationId: command.organizationId,
},
'Failed to get feed item from trace log'
);
// Fall back to the old method if trace log query fails
return await this.notificationRepository.getFeedItem(
command.notificationId,
command.environmentId,
command.organizationId
);
}
}
}
function mapStepRunToJob(
stepRun: StepRunFetchResult,
executionDetailsByStepRunId: Map
): JobFeedItem {
const baseExecutionDetails = executionDetailsByStepRunId.get(stepRun.step_run_id) || [];
// Create execution details with provider ID from step run data
const executionDetails: ExecutionDetailFeedItem[] = baseExecutionDetails.map((detail) => ({
...detail,
providerId: stepRun.provider_id as ProvidersIdEnum,
}));
const stepRunDto: NotificationStepEntity = {
_id: stepRun.step_id,
_templateId: stepRun.step_id,
active: true,
filters: [],
};
const jobDto: JobFeedItem = {
_id: stepRun.step_run_id,
status: stepRun.status as JobStatusEnum,
overrides: {}, // Step runs don't have overrides, use empty object
payload: {}, // Step runs don't have payload, use empty object
step: stepRunDto,
type: stepRun.step_type as StepTypeEnum,
providerId: stepRun.provider_id as ProvidersIdEnum,
createdAt: new Date(stepRun.created_at).toISOString(),
updatedAt: new Date(stepRun.updated_at).toISOString(),
digest: undefined, // Step runs don't have digest info
executionDetails,
scheduleExtensionsCount: stepRun.schedule_extensions_count,
};
return jobDto;
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts
================================================
import { ChannelTypeEnum, SeverityLevelEnum } from '@novu/shared';
import { IsArray, IsEnum, IsMongoId, IsNumber, IsOptional, IsString } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetActivityFeedCommand extends EnvironmentWithUserCommand {
@IsNumber()
page: number;
@IsNumber()
limit: number;
@IsOptional()
@IsEnum(ChannelTypeEnum, {
each: true,
})
channels?: ChannelTypeEnum[] | null;
@IsOptional()
@IsArray()
@IsMongoId({ each: true })
templates?: string[] | null;
@IsOptional()
@IsArray()
emails?: string[];
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsArray()
subscriberIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
transactionId?: string[];
@IsOptional()
@IsString()
topicKey?: string;
@IsOptional()
@IsString()
subscriptionId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
contextKeys?: string[];
@IsOptional()
@IsArray()
@IsEnum(SeverityLevelEnum, { each: true })
severity?: SeverityLevelEnum[] | null;
@IsOptional()
@IsString()
after?: string;
@IsOptional()
@IsString()
before?: string;
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.spec.ts
================================================
import { HttpException, HttpStatus } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FeatureFlagsService, PinoLogger, TraceLogRepository } from '@novu/application-generic';
import { CommunityOrganizationRepository, NotificationRepository, SubscriberRepository } from '@novu/dal';
import { ApiServiceLevelEnum } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
import { GetActivityFeed } from './get-activity-feed.usecase';
describe('GetActivityFeed - validateRetentionLimitForTier', () => {
let useCase: GetActivityFeed;
let organizationRepository: CommunityOrganizationRepository;
let sandbox: sinon.SinonSandbox;
beforeEach(async () => {
sandbox = sinon.createSandbox();
const moduleRef = await Test.createTestingModule({
providers: [
GetActivityFeed,
SubscriberRepository,
NotificationRepository,
{
provide: CommunityOrganizationRepository,
useValue: {
findById: () => {},
},
},
{
provide: TraceLogRepository,
useValue: {
createStepRun: () => {},
},
},
{
provide: FeatureFlagsService,
useValue: {
getFlag: () => Promise.resolve({ value: false }),
},
},
{
provide: PinoLogger,
useValue: {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
trace: () => {},
setContext: () => {},
},
},
],
}).compile();
useCase = moduleRef.get(GetActivityFeed);
organizationRepository = moduleRef.get(CommunityOrganizationRepository);
});
afterEach(() => {
sandbox.restore();
});
describe('Date handling', () => {
it('should default to maximum allowed retention period when no dates provided', async () => {
const now = new Date();
sandbox.useFakeTimers(now.getTime());
const mockOrg = {
_id: 'org-123',
apiServiceLevel: ApiServiceLevelEnum.PRO,
createdAt: new Date('2024-01-01'),
};
sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any);
const result = await (useCase as any).validateRetentionLimitForTier('org-123');
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
expect(new Date(result.after).getTime()).to.be.approximately(sevenDaysAgo.getTime(), 1000); // allowing 1s difference
expect(result.before).to.equal(now.toISOString());
});
it('should use provided dates when within retention period', async () => {
const now = new Date();
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const mockOrg = {
_id: 'org-123',
apiServiceLevel: ApiServiceLevelEnum.FREE,
createdAt: new Date('2024-01-01'),
};
sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any);
const result = await (useCase as any).validateRetentionLimitForTier(
'org-123',
twoDaysAgo.toISOString(),
now.toISOString()
);
expect(result.after).to.equal(twoDaysAgo.toISOString());
expect(result.before).to.equal(now.toISOString());
});
it('should reject when after date is later than before date', async () => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const mockOrg = {
_id: 'org-123',
apiServiceLevel: ApiServiceLevelEnum.FREE,
createdAt: new Date('2024-01-01'),
};
sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any);
try {
await (useCase as any).validateRetentionLimitForTier('org-123', tomorrow.toISOString(), now.toISOString());
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).to.be.instanceOf(HttpException);
expect(error.message).to.match(/Invalid date range/);
expect(error.status).to.equal(HttpStatus.BAD_REQUEST);
}
});
});
describe('Retention periods by tier', () => {
const testCases = [
{
tier: 'Legacy Free',
apiServiceLevel: ApiServiceLevelEnum.FREE,
createdAt: new Date('2024-01-01'),
allowedDays: 30,
rejectedDays: 31,
},
{
tier: 'New Free',
apiServiceLevel: ApiServiceLevelEnum.FREE,
createdAt: new Date('2025-03-01'),
allowedDays: 1,
rejectedDays: 2,
},
{
tier: 'Pro',
apiServiceLevel: ApiServiceLevelEnum.PRO,
createdAt: new Date(),
allowedDays: 7,
rejectedDays: 8,
},
{
tier: 'Team',
apiServiceLevel: ApiServiceLevelEnum.BUSINESS,
createdAt: new Date(),
allowedDays: 90,
rejectedDays: 91,
},
];
testCases.forEach(({ tier, apiServiceLevel, createdAt, allowedDays, rejectedDays }) => {
describe(tier, () => {
it(`should allow access within ${allowedDays} days`, async () => {
const now = new Date();
const withinPeriod = new Date(now.getTime() - allowedDays * 24 * 60 * 60 * 1000);
const mockOrg = {
_id: 'org-123',
apiServiceLevel,
createdAt,
};
sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any);
const result = await (useCase as any).validateRetentionLimitForTier(
'org-123',
withinPeriod.toISOString(),
now.toISOString()
);
expect(result.after).to.equal(withinPeriod.toISOString());
expect(result.before).to.equal(now.toISOString());
});
it(`should reject access beyond ${rejectedDays} days`, async () => {
const now = new Date();
const beyondPeriod = new Date(now.getTime() - rejectedDays * 24 * 60 * 60 * 1000);
const mockOrg = {
_id: 'org-123',
apiServiceLevel,
createdAt,
};
sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any);
try {
await (useCase as any).validateRetentionLimitForTier(
'org-123',
beyondPeriod.toISOString(),
now.toISOString()
);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).to.be.instanceOf(HttpException);
console.log(error.message);
expect(error.message).to.match(/retention period/);
expect(error.status).to.equal(HttpStatus.PAYMENT_REQUIRED);
}
});
});
});
});
});
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts
================================================
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
FeatureFlagsService,
Instrument,
PinoLogger,
QueryBuilder,
Trace,
TraceLogRepository,
} from '@novu/application-generic';
import {
CommunityOrganizationRepository,
ExecutionDetailFeedItem,
NotificationFeedItemEntity,
NotificationRepository,
OrganizationEntity,
SubscriberRepository,
} from '@novu/dal';
import {
ApiServiceLevelEnum,
ExecutionDetailsSourceEnum,
ExecutionDetailsStatusEnum,
FeatureFlagsKeysEnum,
FeatureNameEnum,
getFeatureForTierAsNumber,
} from '@novu/shared';
import { ActivitiesResponseDto, ActivityNotificationResponseDto } from '../../dtos/activities-response.dto';
import { GetActivityFeedCommand } from './get-activity-feed.command';
import { mapFeedItemToDto } from './map-feed-item-to.dto';
const traceFindColumns = ['entity_id', 'id', 'status', 'title', 'raw_data', 'created_at'] as const;
type TraceFindResult = Pick;
@Injectable()
export class GetActivityFeed {
constructor(
private subscribersRepository: SubscriberRepository,
private notificationRepository: NotificationRepository,
private organizationRepository: CommunityOrganizationRepository,
private traceLogRepository: TraceLogRepository,
private featureFlagsService: FeatureFlagsService,
private logger: PinoLogger
) {}
async execute(command: GetActivityFeedCommand): Promise {
let subscriberIds: string[] | undefined;
const { after, before } = await this.validateRetentionLimitForTier(
command.organizationId,
command.after,
command.before
);
command.after = after;
command.before = before;
if (command.search || command.emails?.length || command.subscriberIds?.length) {
subscriberIds = await this.findSubscribers(command);
}
if (subscriberIds && subscriberIds.length === 0) {
return {
page: 0,
hasMore: false,
pageSize: command.limit,
data: [],
};
}
const notifications: NotificationFeedItemEntity[] = await this.getFeedNotifications(command, subscriberIds);
const data = notifications.reduce((memo, notification) => {
// TODO: Identify why mongo returns an array of undefined or null values. Is it a data issue?
if (notification) {
memo.push(mapFeedItemToDto(notification));
}
return memo;
}, []);
return {
page: command.page,
hasMore: notifications?.length === command.limit,
pageSize: command.limit,
data,
};
}
private async validateRetentionLimitForTier(organizationId: string, after?: string, before?: string) {
const organization = await this.organizationRepository.findById(organizationId);
if (!organization) {
throw new HttpException('Organization not found', HttpStatus.INTERNAL_SERVER_ERROR);
}
const maxRetentionMs = this.getMaxRetentionPeriodByOrganization(organization);
// For unlimited retention (self-hosted), skip retention validation
if (maxRetentionMs === Number.MAX_SAFE_INTEGER) {
const effectiveAfterDate = after ? this.parseAndValidateDate(after, 'after') : undefined;
const effectiveBeforeDate = before ? this.parseAndValidateDate(before, 'before') : undefined;
// Basic validation for date range if both dates are provided
if (effectiveAfterDate && effectiveBeforeDate && effectiveAfterDate > effectiveBeforeDate) {
throw new HttpException(
'Invalid date range: start date (after) must be earlier than end date (before)',
HttpStatus.BAD_REQUEST
);
}
return {
after: effectiveAfterDate?.toISOString(),
before: effectiveBeforeDate?.toISOString(),
};
}
const earliestAllowedDate = new Date(Date.now() - maxRetentionMs);
// If no after date is provided, default to the earliest allowed date
const effectiveAfterDate = after ? this.parseAndValidateDate(after, 'after') : earliestAllowedDate;
const effectiveBeforeDate = before ? this.parseAndValidateDate(before, 'before') : new Date();
this.validateDateRange(earliestAllowedDate, effectiveAfterDate, effectiveBeforeDate);
return {
after: effectiveAfterDate.toISOString(),
before: effectiveBeforeDate.toISOString(),
};
}
private parseAndValidateDate(dateString: string, parameterName: string): Date {
const parsedDate = new Date(dateString);
if (Number.isNaN(parsedDate.getTime())) {
throw new HttpException(
`Invalid date format for parameter '${parameterName}': ${dateString}. Please provide a valid ISO 8601 date string.`,
HttpStatus.BAD_REQUEST
);
}
return parsedDate;
}
private validateDateRange(earliestAllowedDate: Date, afterDate: Date, beforeDate: Date) {
if (afterDate > beforeDate) {
throw new HttpException(
'Invalid date range: start date (after) must be earlier than end date (before)',
HttpStatus.BAD_REQUEST
);
}
// add buffer to account for time delay in execution
const buffer = 1 * 60 * 60 * 1000; // 1 hour
const bufferedEarliestAllowedDate = new Date(earliestAllowedDate.getTime() - buffer);
if (
process.env.NODE_ENV !== 'local' &&
(afterDate < bufferedEarliestAllowedDate || beforeDate < bufferedEarliestAllowedDate)
) {
throw new HttpException(
`Requested date range exceeds your plan's retention period. ` +
`The earliest accessible date for your plan is ${earliestAllowedDate.toISOString().split('T')[0]}. ` +
`Please upgrade your plan to access older activities.`,
HttpStatus.PAYMENT_REQUIRED
);
}
}
/**
* Notifications are automatically deleted after a certain period of time
* by a background job.
*
* @see https://github.com/novuhq/cloud-infra/blob/main/scripts/expiredNotification.js#L93
*/
private getMaxRetentionPeriodByOrganization(organization: OrganizationEntity) {
// 1. Self-hosted: effectively unlimited, use a large but safe finite window (100 years)
if (process.env.IS_SELF_HOSTED === 'true') {
return 100 * 365 * 24 * 60 * 60 * 1000; // ~100 years in ms, safe for Date math
}
const { apiServiceLevel, createdAt } = organization;
// 2. Special case: Free tier orgs created before Feb 28, 2025 get 30 days
if (apiServiceLevel === ApiServiceLevelEnum.FREE && new Date(createdAt) < new Date('2025-02-28')) {
return 30 * 24 * 60 * 60 * 1000;
}
// 3. Otherwise, use tier-based retention from feature flags
return getFeatureForTierAsNumber(
FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION,
apiServiceLevel ?? ApiServiceLevelEnum.FREE,
true
);
}
@Instrument()
private async findSubscribers(command: GetActivityFeedCommand): Promise {
return await this.subscribersRepository.searchSubscribers(
command.environmentId,
command.subscriberIds,
command.emails,
command.search
);
}
@Instrument()
private async getFeedNotifications(
command: GetActivityFeedCommand,
subscriberIds?: string[]
): Promise {
const notifications = await this.notificationRepository.getFeed(
command.environmentId,
{
channels: command.channels,
templates: command.templates,
subscriberIds: subscriberIds || [],
transactionId: command.transactionId,
topicKey: command.topicKey,
subscriptionId: command.subscriptionId,
after: command.after,
before: command.before,
severity: command.severity,
contextKeys: command.contextKeys,
},
command.page * command.limit,
command.limit
);
const isClickHouseOnlyEnabled = await this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_EXECUTION_DETAILS_CLICKHOUSE_ONLY_ENABLED,
defaultValue: false,
organization: { _id: command.organizationId },
user: { _id: command.userId },
environment: { _id: command.environmentId },
});
if (isClickHouseOnlyEnabled) {
return await this.enhanceNotificationsWithTraces(notifications, command);
}
return notifications;
}
private async enhanceNotificationsWithTraces(
notifications: NotificationFeedItemEntity[],
command: GetActivityFeedCommand
): Promise {
try {
// Collect all job IDs from all notifications
const allJobIds: string[] = [];
for (const notification of notifications) {
if (notification.jobs) {
allJobIds.push(...notification.jobs.map((job) => job._id));
}
}
if (allJobIds.length === 0) {
return notifications;
}
// Get execution details from ClickHouse for all job IDs
const executionDetailsByJobId = await this.getExecutionDetailsByEntityId(allJobIds, command);
// Enhance each notification with the execution details
const enhancedNotifications = notifications.map((notification) => {
if (!notification.jobs) {
return notification;
}
const enhancedJobs = notification.jobs.map((job) => {
const executionDetails = executionDetailsByJobId.get(job._id) || [];
return {
...job,
executionDetails,
};
});
return {
...notification,
jobs: enhancedJobs,
};
});
this.logger.debug({
notificationCount: notifications.length,
jobCount: allJobIds.length,
executionDetailsCount: Array.from(executionDetailsByJobId.values()).flat().length,
}, 'Successfully enhanced notifications with ClickHouse execution details');
return enhancedNotifications;
} catch (error) {
this.logger.error(
{
error: error instanceof Error ? error.message : 'Unknown error',
environmentId: command.environmentId,
organizationId: command.organizationId,
},
'Failed to enhance notifications with ClickHouse execution details, falling back to MongoDB data'
);
// Fall back to the original notifications if ClickHouse query fails
return notifications;
}
}
private mapTraceStatusToExecutionStatus(traceStatus: string): ExecutionDetailsStatusEnum {
switch (traceStatus.toLowerCase()) {
case 'success':
return ExecutionDetailsStatusEnum.SUCCESS;
case 'error':
case 'failed':
return ExecutionDetailsStatusEnum.FAILED;
case 'warning':
return ExecutionDetailsStatusEnum.WARNING;
case 'pending':
return ExecutionDetailsStatusEnum.PENDING;
case 'queued':
return ExecutionDetailsStatusEnum.QUEUED;
default:
return ExecutionDetailsStatusEnum.PENDING;
}
}
private async getExecutionDetailsByEntityId(
entityIds: string[],
command: GetActivityFeedCommand
): Promise> {
if (entityIds.length === 0) {
return new Map();
}
const traceQuery = new QueryBuilder({
environmentId: command.environmentId,
})
.whereIn('entity_id', entityIds)
.whereEquals('entity_type', 'step_run')
.build();
const traceResult = await this.traceLogRepository.find({
where: traceQuery,
orderBy: 'created_at',
orderDirection: 'ASC',
select: traceFindColumns,
});
const executionDetailsByEntityId = new Map();
// Group traces by entity ID
const traceLogsByEntityId = new Map();
for (const trace of traceResult.data) {
if (!traceLogsByEntityId.has(trace.entity_id)) {
traceLogsByEntityId.set(trace.entity_id, []);
}
const entityTraces = traceLogsByEntityId.get(trace.entity_id);
if (entityTraces) {
entityTraces.push(trace);
}
}
// Convert traces to execution details for each entity
for (const [entityId, traces] of traceLogsByEntityId) {
const executionDetails: ExecutionDetailFeedItem[] = traces.map((trace: TraceFindResult) => ({
_id: trace.id,
providerId: undefined,
detail: trace.title,
source: ExecutionDetailsSourceEnum.INTERNAL,
_jobId: entityId,
status: this.mapTraceStatusToExecutionStatus(trace.status),
isTest: false,
isRetry: false,
createdAt: new Date(trace.created_at).toISOString(),
raw: trace.raw_data,
}));
executionDetailsByEntityId.set(entityId, executionDetails);
}
return executionDetailsByEntityId;
}
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-feed/map-feed-item-to.dto.ts
================================================
import {
FieldFilterPartDto,
FilterPartsDto,
OnlineInLastFilterPartDto,
PreviousStepFilterPartDto,
RealtimeOnlineFilterPartDto,
StepFilterDto,
TenantFilterPartDto,
WebhookFilterPartDto,
} from '@novu/application-generic';
import {
ExecutionDetailFeedItem,
JobFeedItem,
NotificationFeedItemEntity,
NotificationStepEntity,
StepFilter,
SubscriberFeedItem,
TemplateFeedItem,
} from '@novu/dal';
import {
DigestTypeEnum,
FilterParts,
FilterPartTypeEnum,
IDigestRegularMetadata,
IDigestTimedMetadata,
IWorkflowStepMetadata,
ProvidersIdEnum,
SeverityLevelEnum,
StepTypeEnum,
} from '@novu/shared';
import { MessageTemplateDto } from '../../../shared/dtos/message.template.dto';
import {
ActivityNotificationExecutionDetailResponseDto,
ActivityNotificationJobResponseDto,
ActivityNotificationResponseDto,
ActivityNotificationStepResponseDto,
ActivityNotificationSubscriberResponseDto,
ActivityNotificationTemplateResponseDto,
DigestMetadataDto,
} from '../../dtos/activities-response.dto';
function buildSubscriberDto(subscriber: SubscriberFeedItem): ActivityNotificationSubscriberResponseDto {
return {
_id: subscriber._id,
subscriberId: subscriber.subscriberId,
email: subscriber.email,
firstName: subscriber.firstName,
lastName: subscriber.lastName,
phone: subscriber.phone,
};
}
function buildTemplate(template: TemplateFeedItem): ActivityNotificationTemplateResponseDto {
return {
_id: template._id,
name: template.name,
triggers: template.triggers,
origin: template.origin,
};
}
export function mapFeedItemToDto(entity: NotificationFeedItemEntity): ActivityNotificationResponseDto {
return {
_digestedNotificationId: entity._digestedNotificationId,
_environmentId: entity._environmentId,
_id: entity._id,
_organizationId: entity._organizationId,
_subscriberId: entity._subscriberId,
_templateId: entity._templateId,
topics: entity.topics?.map((topic) => ({
_topicId: topic._topicId,
topicKey: topic.topicKey,
})),
channels: entity.channels,
createdAt: entity.createdAt,
jobs: entity.jobs.map(mapJobToDto),
tags: entity.tags,
transactionId: entity.transactionId,
updatedAt: entity.updatedAt,
controls: entity.controls as Record,
payload: entity.payload as Record,
to: entity.to as Record,
subscriber: entity.subscriber ? buildSubscriberDto(entity.subscriber) : undefined,
template: entity.template ? buildTemplate(entity.template) : undefined,
severity: entity.severity ?? SeverityLevelEnum.NONE,
critical: entity.critical,
contextKeys: entity.contextKeys,
};
}
function mapChildFilterToDto(filterPart: FilterParts): FilterPartsDto {
switch (filterPart.on) {
case FilterPartTypeEnum.SUBSCRIBER:
case FilterPartTypeEnum.PAYLOAD:
return {
...filterPart,
on: filterPart.on, // Ensure the correct enum value is set
} as FieldFilterPartDto;
case FilterPartTypeEnum.WEBHOOK:
return {
...filterPart,
on: FilterPartTypeEnum.WEBHOOK,
} as WebhookFilterPartDto;
case FilterPartTypeEnum.IS_ONLINE:
return {
...filterPart,
on: FilterPartTypeEnum.IS_ONLINE,
} as RealtimeOnlineFilterPartDto;
case FilterPartTypeEnum.IS_ONLINE_IN_LAST:
return {
...filterPart,
on: FilterPartTypeEnum.IS_ONLINE_IN_LAST,
} as OnlineInLastFilterPartDto;
case FilterPartTypeEnum.PREVIOUS_STEP:
return {
...filterPart,
on: FilterPartTypeEnum.PREVIOUS_STEP,
} as PreviousStepFilterPartDto;
case FilterPartTypeEnum.TENANT:
return {
...filterPart,
on: FilterPartTypeEnum.TENANT,
} as TenantFilterPartDto;
default:
throw new Error(`Unknown filter part type: ${filterPart}`);
}
}
function mapToFilterDto(stepFilter: StepFilter): StepFilterDto {
return {
children: stepFilter.children.map((child) => mapChildFilterToDto(child)),
isNegated: stepFilter.isNegated,
type: stepFilter.type,
value: stepFilter.value,
};
}
function convertStepToResponse(step: NotificationStepEntity): ActivityNotificationStepResponseDto {
const responseDto = new ActivityNotificationStepResponseDto();
responseDto._id = step._id || '';
responseDto.active = step.active || false;
responseDto.replyCallback = step.replyCallback;
responseDto.controlVariables = step.controlVariables;
responseDto.metadata = step.metadata;
responseDto.issues = step.issues;
responseDto._templateId = step._templateId || '';
responseDto.name = step.name;
responseDto._parentId = step._parentId || null;
// Map filters
responseDto.filters = (step.filters || []).map(mapToFilterDto);
// Map template if exists
if (step.template) {
const messageTemplateDto = new MessageTemplateDto();
messageTemplateDto.type = step.template.type;
messageTemplateDto.content = step.template.content;
messageTemplateDto.contentType = step.template.contentType;
messageTemplateDto.cta = step.template.cta;
messageTemplateDto.actor = step.template.actor;
messageTemplateDto.variables = step.template.variables;
messageTemplateDto._feedId = step.template._feedId;
messageTemplateDto._layoutId = step.template._layoutId;
messageTemplateDto.name = step.template.name;
messageTemplateDto.subject = step.template.subject;
messageTemplateDto.title = step.template.title;
messageTemplateDto.preheader = step.template.preheader;
messageTemplateDto.senderName = step.template.senderName;
messageTemplateDto._creatorId = step.template._creatorId;
responseDto.template = messageTemplateDto;
}
if (step.variants) {
responseDto.variants = step.variants.map((variant) => convertStepToResponse(variant));
}
return responseDto;
}
function isDigestRegularMetadata(item: IWorkflowStepMetadata): item is IDigestRegularMetadata {
return 'type' in item && (item.type === DigestTypeEnum.REGULAR || item.type === DigestTypeEnum.BACKOFF);
}
function isDigestTimedMetadata(item: IWorkflowStepMetadata): item is IDigestTimedMetadata {
return 'type' in item && item.type === DigestTypeEnum.TIMED;
}
function mapDigest(
digestData?:
| (IWorkflowStepMetadata & {
events?: any[];
})
| string
| null
): DigestMetadataDto | undefined {
if (!digestData) {
return undefined;
}
const digestItem =
typeof digestData === 'string'
? (JSON.parse(digestData) as IWorkflowStepMetadata & {
events?: any[];
})
: (digestData as IWorkflowStepMetadata & {
events?: any[];
});
if (!digestItem) {
return undefined;
}
// Type guarding and mapping based on the type of item
if (isDigestRegularMetadata(digestItem)) {
// If it's IDigestRegularMetadata
return {
digestKey: digestItem.digestKey,
amount: digestItem.amount,
unit: digestItem.unit,
events: digestItem.events || [], // Default to an empty array if no events are provided
type: digestItem.type, // Set the type as either REGULAR or BACKOFF
backoff: digestItem.backoff,
backoffAmount: digestItem.backoffAmount,
backoffUnit: digestItem.backoffUnit,
updateMode: digestItem.updateMode, // Set update mode if available
};
}
if (isDigestTimedMetadata(digestItem)) {
return {
digestKey: digestItem.digestKey,
amount: digestItem.amount,
unit: digestItem.unit,
events: digestItem.events || [], // Default to an empty array if no events are provided
type: DigestTypeEnum.TIMED, // Set the type as TIMED
timed: {
atTime: digestItem.timed?.atTime,
weekDays: digestItem.timed?.weekDays,
monthDays: digestItem.timed?.monthDays,
ordinal: digestItem.timed?.ordinal,
ordinalValue: digestItem.timed?.ordinalValue,
monthlyType: digestItem.timed?.monthlyType,
cronExpression: digestItem.timed?.cronExpression,
untilDate: digestItem.timed?.untilDate,
},
};
}
return undefined;
}
function mapJobToDto(item: JobFeedItem): ActivityNotificationJobResponseDto {
return {
_id: item._id,
type: item.type as StepTypeEnum,
digest: mapDigest(item.digest),
executionDetails: item.executionDetails.map(convertExecutionDetail),
step: convertStepToResponse(item.step),
overrides: item.overrides,
payload: item.payload,
providerId: item.providerId as ProvidersIdEnum,
status: item.status,
updatedAt: item.updatedAt,
scheduleExtensionsCount: item.scheduleExtensionsCount,
};
}
function convertExecutionDetail(entity: ExecutionDetailFeedItem): ActivityNotificationExecutionDetailResponseDto {
return {
_id: entity._id,
detail: entity.detail,
isRetry: entity.isRetry,
isTest: entity.isTest,
providerId: entity.providerId as unknown as ProvidersIdEnum,
source: entity.source,
status: entity.status,
raw: entity.raw || undefined,
createdAt: entity.createdAt,
};
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-graph-states/get-activity-graph-states.command.ts
================================================
import { IsNumber, IsOptional } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetActivityGraphStatsCommand extends EnvironmentWithUserCommand {
@IsNumber()
@IsOptional()
days: number;
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-graph-states/get-activity-graph-states.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { NotificationRepository } from '@novu/dal';
import { subDays } from 'date-fns';
import { ActivityGraphStatesResponse } from '../../dtos/activity-graph-states-response.dto';
import { GetActivityGraphStatsCommand } from './get-activity-graph-states.command';
@Injectable()
export class GetActivityGraphStats {
constructor(private notificationRepository: NotificationRepository) {}
async execute(command: GetActivityGraphStatsCommand): Promise {
return await this.notificationRepository.getActivityGraphStats(
subDays(new Date(), command.days),
command.environmentId
);
}
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-stats/get-activity-stats.command.ts
================================================
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class GetActivityStatsCommand extends EnvironmentCommand {}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-stats/get-activity-stats.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { NotificationRepository } from '@novu/dal';
import { ActivityStatsResponseDto } from '../../dtos/activity-stats-response.dto';
import { GetActivityStatsCommand } from './get-activity-stats.command';
@Injectable()
export class GetActivityStats {
constructor(private notificationRepository: NotificationRepository) {}
async execute(command: GetActivityStatsCommand): Promise {
const result = await this.notificationRepository.getStats(command.environmentId);
return {
weeklySent: result.weekly,
monthlySent: result.monthly,
};
}
}
================================================
FILE: apps/api/src/app/notifications/usecases/get-activity-stats/index.ts
================================================
export { GetActivityStatsCommand } from './get-activity-stats.command';
export { GetActivityStats } from './get-activity-stats.usecase';
================================================
FILE: apps/api/src/app/notifications/usecases/index.ts
================================================
import { GetActivity } from './get-activity/get-activity.usecase';
import { GetActivityFeed } from './get-activity-feed/get-activity-feed.usecase';
import { GetActivityGraphStats } from './get-activity-graph-states/get-activity-graph-states.usecase';
import { GetActivityStats } from './get-activity-stats';
export const USE_CASES = [
GetActivityStats,
GetActivityGraphStats,
GetActivityFeed,
GetActivity,
//
];
================================================
FILE: apps/api/src/app/organization/dtos/create-organization.dto.ts
================================================
import { ICreateOrganizationDto, JobTitleEnum, ProductUseCases } from '@novu/shared';
import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator';
export class CreateOrganizationDto implements ICreateOrganizationDto {
@IsString()
@IsDefined()
name: string;
@IsString()
@IsOptional()
logo?: string;
@IsOptional()
@IsEnum(JobTitleEnum)
jobTitle?: JobTitleEnum;
@IsString()
@IsOptional()
domain?: string;
@IsOptional()
language?: string[];
}
================================================
FILE: apps/api/src/app/organization/dtos/get-my-organization.dto.ts
================================================
import { OrganizationEntity } from '@novu/dal';
export type IGetMyOrganizationDto = OrganizationEntity;
================================================
FILE: apps/api/src/app/organization/dtos/get-organization-settings.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { IsValidLocale } from '@novu/application-generic';
import { OrganizationEntity } from '@novu/dal';
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class GetOrganizationSettingsDto {
@ApiProperty({
description: 'Remove Novu branding',
example: false,
})
@IsBoolean()
removeNovuBranding: boolean;
@ApiProperty({
description: 'Default locale',
example: 'en_US',
})
@IsValidLocale()
defaultLocale: string;
@ApiProperty({
description: 'Target locales',
example: ['en_US', 'es_ES'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
targetLocales?: string[];
}
================================================
FILE: apps/api/src/app/organization/dtos/get-organizations.dto.ts
================================================
import { OrganizationEntity } from '@novu/dal';
export type IGetOrganizationsDto = OrganizationEntity[];
================================================
FILE: apps/api/src/app/organization/dtos/member-response.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared';
import { IsDate, IsEnum, IsObject, IsString } from 'class-validator';
export class MemberUserDto {
@ApiProperty()
@IsString()
_id: string;
@ApiProperty()
@IsString()
firstName: string;
@ApiProperty()
@IsString()
lastName: string;
@ApiProperty()
@IsString()
email: string;
}
export class MemberInviteDTO {
@ApiProperty()
@IsString()
email: string;
@ApiProperty()
@IsString()
token: string;
@ApiProperty()
@IsDate()
invitationDate: Date;
@ApiPropertyOptional()
@IsDate()
answerDate?: Date;
@ApiProperty()
@IsString()
_inviterId: string;
}
export class MemberResponseDto {
@ApiProperty()
@IsString()
_id: string;
@ApiProperty()
@IsString()
_userId: string;
@ApiPropertyOptional()
@IsObject()
user?: MemberUserDto;
@ApiPropertyOptional({ enum: MemberRoleEnum })
@IsEnum(MemberRoleEnum)
roles?: MemberRoleEnum;
@ApiPropertyOptional()
@IsObject()
invite?: MemberInviteDTO;
@ApiPropertyOptional({
enum: { ...MemberStatusEnum },
})
@IsEnum(MemberStatusEnum)
memberStatus?: MemberStatusEnum;
@ApiProperty()
@IsString()
_organizationId: string;
}
================================================
FILE: apps/api/src/app/organization/dtos/organization-response.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DirectionEnum, PartnerTypeEnum } from '@novu/dal';
import { IsArray, IsEnum, IsObject, IsString } from 'class-validator';
import { UpdateBrandingDetailsDto } from './update-branding-details.dto';
export class IPartnerConfigurationResponseDto {
@ApiPropertyOptional()
@IsArray()
@IsString({ each: true })
projectIds?: string[];
@ApiProperty()
@IsString()
accessToken: string;
@ApiProperty()
@IsString()
configurationId: string;
@ApiPropertyOptional()
@IsString()
teamId: string;
@ApiProperty({
enum: PartnerTypeEnum,
description: 'Partner Type Enum',
})
@IsEnum(PartnerTypeEnum)
partnerType: PartnerTypeEnum;
}
export class OrganizationBrandingResponseDto extends UpdateBrandingDetailsDto {
@ApiPropertyOptional({
enum: DirectionEnum,
})
@IsString()
direction?: DirectionEnum;
}
export class OrganizationResponseDto {
@ApiProperty()
@IsString()
name: string;
@ApiPropertyOptional()
@IsString()
logo?: string;
@ApiProperty()
@IsObject()
branding: OrganizationBrandingResponseDto;
@ApiPropertyOptional()
@IsObject()
partnerConfigurations: IPartnerConfigurationResponseDto[];
}
================================================
FILE: apps/api/src/app/organization/dtos/rename-organization.dto.ts
================================================
import { IsDefined, IsString } from 'class-validator';
export class RenameOrganizationDto {
@IsString()
@IsDefined()
name: string;
}
================================================
FILE: apps/api/src/app/organization/dtos/update-branding-details.dto.ts
================================================
import { IsHexColor, IsOptional, IsString, IsUrl } from 'class-validator';
import { IsImageUrl } from '../../shared/validators/image.validator';
const environments = ['production', 'test'];
const protocols = environments.includes(process.env.NODE_ENV || '') ? ['https'] : ['http', 'https'];
export class UpdateBrandingDetailsDto {
@IsUrl({
require_protocol: true,
protocols,
require_tld: false,
})
@IsImageUrl({
message: 'Logo must be a valid image URL with one of the following extensions: jpg, jpeg, png, gif, svg',
})
@IsOptional()
logo: string;
@IsOptional()
@IsHexColor()
color: string;
@IsOptional()
@IsHexColor()
fontColor: string;
@IsOptional()
@IsHexColor()
contentBackground: string;
@IsOptional()
@IsString()
fontFamily?: string;
}
================================================
FILE: apps/api/src/app/organization/dtos/update-member-roles.dto.ts
================================================
import { MemberRoleEnum } from '@novu/shared';
import { IsEnum } from 'class-validator';
export class UpdateMemberRolesDto {
@IsEnum(MemberRoleEnum)
role: MemberRoleEnum.OSS_ADMIN;
}
================================================
FILE: apps/api/src/app/organization/dtos/update-organization-settings.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { IsValidLocale } from '@novu/application-generic';
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateOrganizationSettingsDto {
@ApiProperty({
description: 'Enable or disable Novu branding',
example: true,
})
@IsOptional()
@IsBoolean()
removeNovuBranding?: boolean;
@ApiProperty({
description: 'Default locale',
example: 'en_US',
})
@IsOptional()
@IsValidLocale()
defaultLocale?: string;
@ApiProperty({
description: 'Target locales',
example: ['en_US', 'es_ES'],
})
@IsOptional()
@IsArray()
@IsString({ each: true }) // TODO: validate locales
targetLocales?: string[];
}
================================================
FILE: apps/api/src/app/organization/e2e/change-member-role.e2e.ts
================================================
import { CommunityMemberRepository } from '@novu/dal';
import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { describe } from 'mocha';
describe('Change member role - /organizations/members/:memberId/role (PUT) #novu-v0-os', async () => {
const memberRepository = new CommunityMemberRepository();
let session: UserSession;
let user2: UserSession;
let user3: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
user2 = new UserSession();
await user2.initialize({
noOrganization: true,
});
user3 = new UserSession();
await user3.initialize({
noOrganization: true,
});
});
// Currently skipped until we implement role management
it.skip('should update admin to member', async () => {
await memberRepository.addMember(session.organization._id, {
_userId: user2.user._id,
invite: null,
roles: [MemberRoleEnum.OSS_ADMIN],
memberStatus: MemberStatusEnum.ACTIVE,
});
const member = await memberRepository.findMemberByUserId(session.organization._id, user2.user._id);
const { body } = await session.testAgent.put(`/v1/organizations/members/${member._id}/roles`).send({
role: MemberRoleEnum.OSS_MEMBER,
});
expect(body.data.roles.length).to.equal(1);
expect(body.data.roles[0]).to.equal(MemberRoleEnum.OSS_MEMBER);
});
it('should update member to admin', async () => {
await memberRepository.addMember(session.organization._id, {
_userId: user3.user._id,
invite: null,
roles: [MemberRoleEnum.OSS_MEMBER],
memberStatus: MemberStatusEnum.ACTIVE,
});
const member = await memberRepository.findMemberByUserId(session.organization._id, user3.user._id);
const { body } = await session.testAgent.put(`/v1/organizations/members/${member._id}/roles`).send({
role: MemberRoleEnum.OSS_ADMIN,
});
expect(body.data.roles.length).to.equal(1);
expect(body.data.roles.includes(MemberRoleEnum.OSS_ADMIN)).to.be.ok;
expect(body.data.roles.includes(MemberRoleEnum.OSS_MEMBER)).not.to.be.ok;
});
});
================================================
FILE: apps/api/src/app/organization/e2e/create-organization.e2e.ts
================================================
import {
CommunityMemberRepository,
CommunityOrganizationRepository,
CommunityUserRepository,
EnvironmentRepository,
IntegrationRepository,
} from '@novu/dal';
import {
ApiServiceLevelEnum,
ChannelTypeEnum,
ChatProviderIdEnum,
EmailProviderIdEnum,
ICreateOrganizationDto,
InAppProviderIdEnum,
JobTitleEnum,
MemberRoleEnum,
SmsProviderIdEnum,
} from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Create Organization - /organizations (POST) #novu-v0-os', async () => {
let session: UserSession;
const organizationRepository = new CommunityOrganizationRepository();
const userRepository = new CommunityUserRepository();
const memberRepository = new CommunityMemberRepository();
const integrationRepository = new IntegrationRepository();
const environmentRepository = new EnvironmentRepository();
before(async () => {
session = new UserSession();
await session.initialize({
noOrganization: true,
});
});
describe('Valid Creation', () => {
it('should add the user as admin', async () => {
const { body } = await session.testAgent
.post('/v1/organizations')
.send({
name: 'Test Org 2',
})
.expect(201);
const dbOrganization = await organizationRepository.findById(body.data._id);
const members = await memberRepository.getOrganizationMembers(dbOrganization?._id as string);
expect(members.length).to.eq(1);
expect(members[0]._userId).to.eq(session.user._id);
expect(members[0].roles[0]).to.eq(MemberRoleEnum.OSS_ADMIN);
});
it('should create organization with correct name', async () => {
const demoOrganization = {
name: 'Hello Org',
};
const { body } = await session.testAgent.post('/v1/organizations').send(demoOrganization).expect(201);
expect(body.data.name).to.eq(demoOrganization.name);
});
it('should not create organization with no name', async () => {
await session.testAgent.post('/v1/organizations').send({}).expect(400);
});
it('should create organization with apiServiceLevel of free by default', async () => {
const testOrganization = {
name: 'Free Org',
};
const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const dbOrganization = await organizationRepository.findById(body.data._id);
expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelEnum.FREE);
});
it('should create organization with questionnaire data', async () => {
const testOrganization: ICreateOrganizationDto = {
name: 'Org Name',
domain: 'org.com',
};
const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const dbOrganization = await organizationRepository.findById(body.data._id);
expect(dbOrganization?.name).to.eq(testOrganization.name);
expect(dbOrganization?.domain).to.eq(testOrganization.domain);
});
it('should update user job title on organization creation', async () => {
const testOrganization: ICreateOrganizationDto = {
name: 'Org Name',
jobTitle: JobTitleEnum.PRODUCT_MANAGER,
};
await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const user = await userRepository.findById(session.user._id);
expect(user?.jobTitle).to.eq(testOrganization.jobTitle);
});
it('should create organization with built in Novu integrations and set them as primary', async () => {
const testOrganization: ICreateOrganizationDto = {
name: 'Org Name',
};
const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const integrations = await integrationRepository.find({ _organizationId: body.data._id });
const environments = await environmentRepository.find({ _organizationId: body.data._id });
const productionEnv = environments.find((e) => e.name === 'Production');
const developmentEnv = environments.find((e) => e.name === 'Development');
const novuEmailIntegration = integrations.filter(
(i) => i.active && i.channel === ChannelTypeEnum.EMAIL && i.providerId === EmailProviderIdEnum.Novu
);
const novuSmsIntegration = integrations.filter(
(i) => i.active && i.channel === ChannelTypeEnum.SMS && i.providerId === SmsProviderIdEnum.Novu
);
const novuChatIntegration = integrations.filter(
(i) => i.active && i.channel === ChannelTypeEnum.CHAT && i.providerId === ChatProviderIdEnum.Novu
);
const novuInAppIntegration = integrations.filter(
(i) => i.active && i.channel === ChannelTypeEnum.IN_APP && i.providerId === InAppProviderIdEnum.Novu
);
const novuEmailIntegrationProduction = novuEmailIntegration.filter(
(el) => el._environmentId === productionEnv?._id
);
const novuEmailIntegrationDevelopment = novuEmailIntegration.filter(
(el) => el._environmentId === developmentEnv?._id
);
const novuSmsIntegrationProduction = novuSmsIntegration.filter((el) => el._environmentId === productionEnv?._id);
const novuSmsIntegrationDevelopment = novuSmsIntegration.filter(
(el) => el._environmentId === developmentEnv?._id
);
const novuInAppIntegrationProduction = novuInAppIntegration.filter(
(el) => el._environmentId === productionEnv?._id
);
const novuInAppIntegrationDevelopment = novuInAppIntegration.filter(
(el) => el._environmentId === developmentEnv?._id
);
expect(integrations.length).to.eq(6);
expect(novuEmailIntegration?.length).to.eq(2);
expect(novuSmsIntegration?.length).to.eq(2);
expect(novuChatIntegration?.length).to.eq(1);
expect(novuInAppIntegration?.length).to.eq(2);
expect(novuEmailIntegrationProduction.length).to.eq(1);
expect(novuSmsIntegrationProduction.length).to.eq(1);
expect(novuInAppIntegrationProduction.length).to.eq(1);
expect(novuEmailIntegrationDevelopment.length).to.eq(1);
expect(novuSmsIntegrationDevelopment.length).to.eq(1);
expect(novuInAppIntegrationDevelopment.length).to.eq(1);
expect(novuEmailIntegrationProduction[0].primary).to.eq(true);
expect(novuSmsIntegrationProduction[0].primary).to.eq(true);
expect(novuEmailIntegrationDevelopment[0].primary).to.eq(true);
expect(novuSmsIntegrationDevelopment[0].primary).to.eq(true);
});
it('when Novu Email credentials are not set it should not create Novu Email integration', async () => {
const oldNovuEmailIntegrationApiKey = process.env.NOVU_EMAIL_INTEGRATION_API_KEY;
process.env.NOVU_EMAIL_INTEGRATION_API_KEY = '';
const testOrganization: ICreateOrganizationDto = {
name: 'Org Name',
};
const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const integrations = await integrationRepository.find({ _organizationId: body.data._id });
const environments = await environmentRepository.find({ _organizationId: body.data._id });
const productionEnv = environments.find((e) => e.name === 'Production');
const developmentEnv = environments.find((e) => e.name === 'Development');
const novuSmsIntegration = integrations.filter(
(i) => i.active && i.name === 'Novu SMS' && i.providerId === SmsProviderIdEnum.Novu
);
expect(integrations.length).to.eq(4);
expect(novuSmsIntegration?.length).to.eq(2);
expect(novuSmsIntegration.filter((el) => el._environmentId === productionEnv?._id).length).to.eq(1);
expect(novuSmsIntegration.filter((el) => el._environmentId === developmentEnv?._id).length).to.eq(1);
process.env.NOVU_EMAIL_INTEGRATION_API_KEY = oldNovuEmailIntegrationApiKey;
});
it('when Novu SMS credentials are not set it should not create Novu SMS integration', async () => {
const oldNovuSmsIntegrationAccountSid = process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID;
process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID = '';
const testOrganization: ICreateOrganizationDto = {
name: 'Org Name',
};
const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const integrations = await integrationRepository.find({ _organizationId: body.data._id });
const environments = await environmentRepository.find({ _organizationId: body.data._id });
const productionEnv = environments.find((e) => e.name === 'Production');
const developmentEnv = environments.find((e) => e.name === 'Development');
const novuEmailIntegrations = integrations.filter(
(i) => i.active && i.name === 'Novu Email' && i.providerId === EmailProviderIdEnum.Novu
);
expect(integrations.length).to.eq(4);
expect(novuEmailIntegrations?.length).to.eq(2);
expect(novuEmailIntegrations.filter((el) => el._environmentId === productionEnv?._id).length).to.eq(1);
expect(novuEmailIntegrations.filter((el) => el._environmentId === developmentEnv?._id).length).to.eq(1);
process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID = oldNovuSmsIntegrationAccountSid;
});
it('when Novu Chat credentials are not set it should not create Novu Chat integration', async () => {
// todo
});
});
});
================================================
FILE: apps/api/src/app/organization/e2e/get-members.e2e.ts
================================================
import { CommunityMemberRepository } from '@novu/dal';
import { MemberRoleEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Get members - /organization/members (GET) #novu-v0-os', async () => {
let session: UserSession;
let otherSession: UserSession;
const memberRepository = new CommunityMemberRepository();
before(async () => {
session = new UserSession();
await session.initialize();
otherSession = new UserSession();
await otherSession.initialize({
noOrganization: true,
});
await session.testAgent
.post('/v1/invites/bulk')
.send({
invitees: [
{
email: 'dddd@asdas.com',
role: MemberRoleEnum.OSS_ADMIN,
},
],
})
.expect(201);
const members = await memberRepository.getOrganizationMembers(session.organization._id);
const invitee = members.find((i) => !i._userId);
await otherSession.testAgent.post(`/v1/invites/${invitee.invite.token}/accept`).expect(201);
otherSession.organization = session.organization;
await otherSession.fetchJWT();
});
it('should see emails of all members as admin', async () => {
const { body } = await session.testAgent.get('/v1/organizations/members').expect(200);
expect(JSON.stringify(body.data)).to.include('dddd@asdas.com');
expect(JSON.stringify(body.data)).to.include(session.user.firstName);
});
});
================================================
FILE: apps/api/src/app/organization/e2e/get-my-organization.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Get my organization - /organizations/me (GET) #novu-v0-os', async () => {
let session: UserSession;
before(async () => {
session = new UserSession();
await session.initialize();
});
describe('Get organization profile', () => {
it('should return the correct organization', async () => {
const { body } = await session.testAgent.get('/v1/organizations/me').expect(200);
expect(body.data._id).to.eq(session.organization._id);
});
});
});
================================================
FILE: apps/api/src/app/organization/e2e/get-organizations.e2e.ts
================================================
import { CommunityMemberRepository, OrganizationEntity } from '@novu/dal';
import { MemberRoleEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Get organizations - /organizations (GET) #novu-v0-os', async () => {
let session: UserSession;
let otherSession: UserSession;
let thirdSession: UserSession;
let thirdOldOrganization: OrganizationEntity;
const memberRepository = new CommunityMemberRepository();
before(async () => {
session = new UserSession();
await session.initialize();
otherSession = new UserSession();
await otherSession.initialize();
thirdSession = new UserSession();
await thirdSession.initialize();
await session.testAgent
.post('/v1/invites/bulk')
.send({
invitees: [
{
email: 'dddd@asdas.com',
role: MemberRoleEnum.OSS_MEMBER,
},
],
})
.expect(201);
const members = await memberRepository.getOrganizationMembers(session.organization._id);
const invitee = members.find((i) => !i._userId);
thirdOldOrganization = thirdSession.organization;
await thirdSession.testAgent.post(`/v1/invites/${invitee.invite.token}/accept`).expect(201);
});
it('should see all organizations that you are a part of', async () => {
const { body } = await thirdSession.testAgent.get('/v1/organizations').expect(200);
expect(JSON.stringify(body.data)).to.include(session.organization.name);
expect(JSON.stringify(body.data)).to.include(thirdSession.organization.name);
expect(JSON.stringify(body.data)).to.include(thirdOldOrganization.name);
expect(JSON.stringify(body.data)).to.not.include(otherSession.organization.name);
});
});
================================================
FILE: apps/api/src/app/organization/e2e/remove-member.e2e.ts
================================================
import { CommunityMemberRepository, EnvironmentRepository, MemberEntity } from '@novu/dal';
import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { describe } from 'mocha';
describe('Remove organization member - /organizations/members/:memberId (DELETE) #novu-v0-os', async () => {
let session: UserSession;
const memberRepository = new CommunityMemberRepository();
const environmentRepository = new EnvironmentRepository();
let user2: UserSession;
let user3: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
user2 = new UserSession();
await user2.initialize({
noOrganization: true,
});
user3 = new UserSession();
await user3.initialize({
noOrganization: true,
});
await memberRepository.addMember(session.organization._id, {
_userId: user2.user._id,
invite: null,
roles: [MemberRoleEnum.OSS_ADMIN],
memberStatus: MemberStatusEnum.ACTIVE,
});
await memberRepository.addMember(session.organization._id, {
_userId: user3.user._id,
invite: null,
roles: [MemberRoleEnum.OSS_ADMIN],
memberStatus: MemberStatusEnum.ACTIVE,
});
user2.organization = session.organization;
user3.organization = session.organization;
});
it('should switch the apiKey association when api key creator removed', async () => {
const members: MemberEntity[] = await getOrganizationMembers();
const originalCreator = members.find((i) => i._userId === session.user._id);
await user2.fetchJWT();
expect(session.environment.apiKeys[0]._userId).to.equal(session.user._id);
const { body } = await user2.testAgent.delete(`/v1/organizations/members/${originalCreator._id}`);
expect(body.data._id).to.equal(originalCreator._id);
const membersAfterRemoval: MemberEntity[] = await getOrganizationMembers(user2);
const originalCreatorAfterRemoval = membersAfterRemoval.find((i) => i._userId === originalCreator.user._id);
expect(originalCreatorAfterRemoval).to.not.be.ok;
const environment = await environmentRepository.findOne({ _id: session.environment._id });
expect(environment.apiKeys[0]._userId).to.not.equal(session.user._id);
});
it('should remove the member by his id', async () => {
const members: MemberEntity[] = await getOrganizationMembers();
const user2Member = members.find((i) => i._userId === user2.user._id);
const { body } = await session.testAgent.delete(`/v1/organizations/members/${user2Member._id}`).expect(200);
expect(body.data._id).to.equal(user2Member._id);
const membersAfterRemoval: MemberEntity[] = await getOrganizationMembers();
const user2Removed = membersAfterRemoval.find((i) => i._userId === user2.user._id);
expect(user2Removed).to.not.be.ok;
/**
* The API Key owner should not be updated if non creator was removed
*/
const environment = await environmentRepository.findOne({ _id: session.environment._id });
expect(environment.apiKeys[0]._userId).to.equal(session.user._id);
});
async function getOrganizationMembers(sessionToUser = session) {
const { body } = await sessionToUser.testAgent.get('/v1/organizations/members');
return body.data;
}
});
================================================
FILE: apps/api/src/app/organization/e2e/rename-organization.e2e.ts
================================================
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Rename Organization - /organizations (PATCH) #novu-v0-os', () => {
let session: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
});
it('should rename the organization', async () => {
const payload = {
name: 'Liberty Powers',
};
await session.testAgent.patch('/v1/organizations').send(payload);
const { body } = await session.testAgent.get('/v1/organizations/me').expect(200);
const organization = body.data;
expect(organization?.name).to.equal(payload.name);
});
});
================================================
FILE: apps/api/src/app/organization/e2e/update-branding-details.e2e.ts
================================================
import { processTestAgentExpectedStatusCode, UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Update Branding Details - /organizations/branding (PUT) #novu-v0-os', () => {
let session: UserSession;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
});
it('should update organization name only', async () => {
const payload = {
name: 'New Name',
};
await session.testAgent.patch('/v1/organizations').send(payload).expect(processTestAgentExpectedStatusCode(200));
const { body } = await session.testAgent.get('/v1/organizations/me').expect(200);
const organization = body.data;
expect(organization?.name).to.equal(payload.name);
expect(organization?.logo).to.equal(session.organization.logo);
});
it('should update the branding details', async () => {
const payload = {
color: '#fefefe',
fontColor: '#f4f4f4',
contentBackground: '#fefefe',
fontFamily: 'Nunito',
logo: 'https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.png',
};
const result = await session.testAgent
.put('/v1/organizations/branding')
.send(payload)
.expect(processTestAgentExpectedStatusCode(200));
const { body } = await session.testAgent.get('/v1/organizations/me').expect(200);
const organization = body.data;
expect(organization?.branding.color).to.equal(payload.color);
expect(organization?.branding.logo).to.equal(payload.logo);
expect(organization?.branding.fontColor).to.equal(payload.fontColor);
expect(organization?.branding.fontFamily).to.equal(payload.fontFamily);
expect(organization?.branding.contentBackground).to.equal(payload.contentBackground);
});
it('logo should be an https protocol', async () => {
const payload = {
logo: 'http://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.png',
};
const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(400);
});
['png', 'jpg', 'jpeg', 'gif', 'svg'].forEach((extension) => {
it(`should update if logo is a valid image URL with ${extension} extension`, async () => {
const payload = {
logo: `https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.${extension}`,
};
const result = await session.testAgent
.put('/v1/organizations/branding')
.send(payload)
.expect(processTestAgentExpectedStatusCode(200));
});
});
['exe', 'zip'].forEach((extension) => {
it(`should fail to update if logo is a valid image URL with ${extension} extension`, async () => {
const payload = {
logo: `https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.${extension}`,
};
const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(400);
});
});
});
================================================
FILE: apps/api/src/app/organization/e2e/update-organization-settings.e2e.ts
================================================
import { CommunityOrganizationRepository } from '@novu/dal';
import { ApiServiceLevelEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
describe('Update Organization Settings - /organizations/settings (PATCH) #novu-v2', () => {
let session: UserSession;
let organizationRepository: CommunityOrganizationRepository;
beforeEach(async () => {
session = new UserSession();
await session.initialize();
organizationRepository = new CommunityOrganizationRepository();
});
it('should allow updating removeNovuBranding for PRO tier organizations', async () => {
await organizationRepository.update(
{ _id: session.organization._id },
{ apiServiceLevel: ApiServiceLevelEnum.PRO }
);
const payload = { removeNovuBranding: true };
const { body } = await session.testAgent.patch('/v1/organizations/settings').send(payload).expect(200);
expect(body.data.removeNovuBranding).to.equal(true);
});
it('should block branding updates for free tier organizations', async () => {
await organizationRepository.update(
{ _id: session.organization._id },
{ apiServiceLevel: ApiServiceLevelEnum.FREE }
);
const payload = { removeNovuBranding: true };
const { body } = await session.testAgent.patch('/v1/organizations/settings').send(payload).expect(402);
expect(body.message).to.include('Removing Novu branding is not allowed on the free plan');
});
it('should allow free tier organizations to call endpoint without branding changes', async () => {
await organizationRepository.update(
{ _id: session.organization._id },
{ apiServiceLevel: ApiServiceLevelEnum.FREE }
);
const payload = {};
const { body } = await session.testAgent.patch('/v1/organizations/settings').send(payload).expect(200);
expect(body.data).to.have.property('removeNovuBranding');
expect(typeof body.data.removeNovuBranding).to.equal('boolean');
});
});
================================================
FILE: apps/api/src/app/organization/ee.organization.controller.ts
================================================
import { Body, ClassSerializerInterceptor, Controller, Get, Patch, Put, UseInterceptors } from '@nestjs/common';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ExternalApiAccessible, RequirePermissions } from '@novu/application-generic';
import { PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { IGetMyOrganizationDto } from './dtos/get-my-organization.dto';
import { GetOrganizationSettingsDto } from './dtos/get-organization-settings.dto';
import { OrganizationBrandingResponseDto, OrganizationResponseDto } from './dtos/organization-response.dto';
import { RenameOrganizationDto } from './dtos/rename-organization.dto';
import { UpdateBrandingDetailsDto } from './dtos/update-branding-details.dto';
import { UpdateOrganizationSettingsDto } from './dtos/update-organization-settings.dto';
import { GetMyOrganizationCommand } from './usecases/get-my-organization/get-my-organization.command';
import { GetMyOrganization } from './usecases/get-my-organization/get-my-organization.usecase';
import { GetOrganizationSettingsCommand } from './usecases/get-organization-settings/get-organization-settings.command';
import { GetOrganizationSettings } from './usecases/get-organization-settings/get-organization-settings.usecase';
import { RenameOrganization } from './usecases/rename-organization/rename-organization.usecase';
import { RenameOrganizationCommand } from './usecases/rename-organization/rename-organization-command';
import { UpdateBrandingDetailsCommand } from './usecases/update-branding-details/update-branding-details.command';
import { UpdateBrandingDetails } from './usecases/update-branding-details/update-branding-details.usecase';
import { UpdateOrganizationSettingsCommand } from './usecases/update-organization-settings/update-organization-settings.command';
import { UpdateOrganizationSettings } from './usecases/update-organization-settings/update-organization-settings.usecase';
@Controller('/organizations')
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
@ApiTags('Organizations')
@ApiCommonResponses()
@ApiExcludeController()
export class EEOrganizationController {
constructor(
private updateBrandingDetailsUsecase: UpdateBrandingDetails,
private getMyOrganizationUsecase: GetMyOrganization,
private renameOrganizationUsecase: RenameOrganization,
private getOrganizationSettingsUsecase: GetOrganizationSettings,
private updateOrganizationSettingsUsecase: UpdateOrganizationSettings
) {}
/**
* @deprecated - used in v1 legacy web
*/
@Get('/me')
@ApiResponse(OrganizationResponseDto)
@ApiOperation({
summary: 'Fetch current organization details',
})
async getMyOrganization(@UserSession() user: UserSessionData): Promise {
const command = GetMyOrganizationCommand.create({
userId: user._id,
id: user.organizationId,
});
return await this.getMyOrganizationUsecase.execute(command);
}
/**
* @deprecated - used in v1 legacy web
*/
@Put('/branding')
@ExternalApiAccessible()
@ApiResponse(OrganizationBrandingResponseDto)
@ApiOperation({
summary: 'Update organization branding details',
})
async updateBrandingDetails(@UserSession() user: UserSessionData, @Body() body: UpdateBrandingDetailsDto) {
return await this.updateBrandingDetailsUsecase.execute(
UpdateBrandingDetailsCommand.create({
logo: body.logo,
color: body.color,
userId: user._id,
id: user.organizationId,
fontColor: body.fontColor,
fontFamily: body.fontFamily,
contentBackground: body.contentBackground,
})
);
}
/**
* @deprecated - used in v1 legacy web
*/
@Patch('/')
@ExternalApiAccessible()
@ApiResponse(RenameOrganizationDto)
@ApiOperation({
summary: 'Rename organization name',
})
async renameOrganization(@UserSession() user: UserSessionData, @Body() body: RenameOrganizationDto) {
return await this.renameOrganizationUsecase.execute(
RenameOrganizationCommand.create({
name: body.name,
userId: user._id,
id: user.organizationId,
})
);
}
@Get('/settings')
@ExternalApiAccessible()
@ApiResponse(GetOrganizationSettingsDto)
@ApiOperation({
summary: 'Get organization settings',
})
@RequirePermissions(PermissionsEnum.ORG_SETTINGS_READ)
async getSettings(@UserSession() user: UserSessionData) {
return await this.getOrganizationSettingsUsecase.execute(
GetOrganizationSettingsCommand.create({
organizationId: user.organizationId,
})
);
}
@Patch('/settings')
@ApiResponse(UpdateOrganizationSettingsDto)
@ExternalApiAccessible()
@ApiOperation({
summary: 'Update organization settings',
})
@RequirePermissions(PermissionsEnum.ORG_SETTINGS_WRITE)
async updateSettings(@UserSession() user: UserSessionData, @Body() body: UpdateOrganizationSettingsDto) {
return await this.updateOrganizationSettingsUsecase.execute(
UpdateOrganizationSettingsCommand.create({
userId: user._id,
organizationId: user.organizationId,
removeNovuBranding: body.removeNovuBranding,
defaultLocale: body.defaultLocale,
targetLocales: body.targetLocales,
})
);
}
}
================================================
FILE: apps/api/src/app/organization/organization.controller.ts
================================================
import {
Body,
ClassSerializerInterceptor,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseInterceptors,
} from '@nestjs/common';
import { ApiExcludeEndpoint, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator';
import { OrganizationEntity } from '@novu/dal';
import { MemberRoleEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { CreateOrganizationDto } from './dtos/create-organization.dto';
import { IGetMyOrganizationDto } from './dtos/get-my-organization.dto';
import { IGetOrganizationsDto } from './dtos/get-organizations.dto';
import { MemberResponseDto } from './dtos/member-response.dto';
import { OrganizationBrandingResponseDto, OrganizationResponseDto } from './dtos/organization-response.dto';
import { RenameOrganizationDto } from './dtos/rename-organization.dto';
import { UpdateBrandingDetailsDto } from './dtos/update-branding-details.dto';
import { UpdateMemberRolesDto } from './dtos/update-member-roles.dto';
import { CreateOrganizationCommand } from './usecases/create-organization/create-organization.command';
import { CreateOrganization } from './usecases/create-organization/create-organization.usecase';
import { GetMyOrganizationCommand } from './usecases/get-my-organization/get-my-organization.command';
import { GetMyOrganization } from './usecases/get-my-organization/get-my-organization.usecase';
import { GetOrganizationsCommand } from './usecases/get-organizations/get-organizations.command';
import { GetOrganizations } from './usecases/get-organizations/get-organizations.usecase';
import { ChangeMemberRoleCommand } from './usecases/membership/change-member-role/change-member-role.command';
import { ChangeMemberRole } from './usecases/membership/change-member-role/change-member-role.usecase';
import { GetMembersCommand } from './usecases/membership/get-members/get-members.command';
import { GetMembers } from './usecases/membership/get-members/get-members.usecase';
import { RemoveMemberCommand } from './usecases/membership/remove-member/remove-member.command';
import { RemoveMember } from './usecases/membership/remove-member/remove-member.usecase';
import { RenameOrganization } from './usecases/rename-organization/rename-organization.usecase';
import { RenameOrganizationCommand } from './usecases/rename-organization/rename-organization-command';
import { UpdateBrandingDetailsCommand } from './usecases/update-branding-details/update-branding-details.command';
import { UpdateBrandingDetails } from './usecases/update-branding-details/update-branding-details.usecase';
@Controller('/organizations')
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
@ApiTags('Organizations')
@ApiCommonResponses()
@ApiExcludeController()
export class OrganizationController {
constructor(
private createOrganizationUsecase: CreateOrganization,
private getMembers: GetMembers,
private removeMemberUsecase: RemoveMember,
private changeMemberRoleUsecase: ChangeMemberRole,
private updateBrandingDetailsUsecase: UpdateBrandingDetails,
private getOrganizationsUsecase: GetOrganizations,
private getMyOrganizationUsecase: GetMyOrganization,
private renameOrganizationUsecase: RenameOrganization
) {}
@Post('/')
@ExternalApiAccessible()
@ApiResponse(OrganizationResponseDto, 201)
@ApiOperation({
summary: 'Create an organization',
})
async createOrganization(
@UserSession() user: UserSessionData,
@Body() body: CreateOrganizationDto
): Promise {
return await this.createOrganizationUsecase.execute(
CreateOrganizationCommand.create({
userId: user._id,
logo: body.logo,
name: body.name,
jobTitle: body.jobTitle,
domain: body.domain,
language: body.language,
})
);
}
@Get('/')
@ExternalApiAccessible()
@ApiResponse(OrganizationResponseDto, 200, true)
@ApiOperation({
summary: 'Fetch all organizations',
})
async listOrganizations(@UserSession() user: UserSessionData): Promise {
const command = GetOrganizationsCommand.create({
userId: user._id,
});
return await this.getOrganizationsUsecase.execute(command);
}
@Get('/me')
@ExternalApiAccessible()
@ApiResponse(OrganizationResponseDto)
@ApiOperation({
summary: 'Fetch current organization details',
})
async getSelfOrganizationData(@UserSession() user: UserSessionData): Promise {
const command = GetMyOrganizationCommand.create({
userId: user._id,
id: user.organizationId,
});
return await this.getMyOrganizationUsecase.execute(command);
}
@Delete('/members/:memberId')
@ExternalApiAccessible()
@ApiResponse(MemberResponseDto)
@ApiOperation({
summary: 'Remove a member from organization using memberId',
})
@ApiParam({ name: 'memberId', type: String, required: true })
async remove(@UserSession() user: UserSessionData, @Param('memberId') memberId: string) {
return await this.removeMemberUsecase.execute(
RemoveMemberCommand.create({
userId: user._id,
organizationId: user.organizationId,
memberId,
})
);
}
@Put('/members/:memberId/roles')
@ExternalApiAccessible()
@ApiExcludeEndpoint()
@ApiResponse(MemberResponseDto)
@ApiOperation({
summary: 'Update a member role to admin',
})
@ApiParam({ name: 'memberId', type: String, required: true })
async updateMemberRoles(
@UserSession() user: UserSessionData,
@Param('memberId') memberId: string,
@Body() body: UpdateMemberRolesDto
) {
if (body.role !== MemberRoleEnum.OSS_ADMIN) {
throw new Error('Only admin role can be assigned to a member');
}
return await this.changeMemberRoleUsecase.execute(
ChangeMemberRoleCommand.create({
memberId,
role: MemberRoleEnum.OSS_ADMIN,
userId: user._id,
organizationId: user.organizationId,
})
);
}
@Get('/members')
@ExternalApiAccessible()
@ApiResponse(MemberResponseDto, 200, true)
@ApiOperation({
summary: 'Fetch all members of current organizations',
})
async listOrganizationMembers(@UserSession() user: UserSessionData) {
return await this.getMembers.execute(
GetMembersCommand.create({
user,
userId: user._id,
organizationId: user.organizationId,
})
);
}
@Put('/branding')
@ExternalApiAccessible()
@ApiResponse(OrganizationBrandingResponseDto)
@ApiOperation({
summary: 'Update organization branding details',
})
async updateBrandingDetails(@UserSession() user: UserSessionData, @Body() body: UpdateBrandingDetailsDto) {
return await this.updateBrandingDetailsUsecase.execute(
UpdateBrandingDetailsCommand.create({
logo: body.logo,
color: body.color,
userId: user._id,
id: user.organizationId,
fontColor: body.fontColor,
fontFamily: body.fontFamily,
contentBackground: body.contentBackground,
})
);
}
@Patch('/')
@ExternalApiAccessible()
@ApiResponse(RenameOrganizationDto)
@ApiOperation({
summary: 'Rename organization name',
})
async rename(@UserSession() user: UserSessionData, @Body() body: RenameOrganizationDto) {
return await this.renameOrganizationUsecase.execute(
RenameOrganizationCommand.create({
name: body.name,
userId: user._id,
id: user.organizationId,
})
);
}
}
================================================
FILE: apps/api/src/app/organization/organization.module.ts
================================================
import {
DynamicModule,
ForwardReference,
forwardRef,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { Type } from '@nestjs/common/interfaces/type.interface';
import { AuthGuard } from '@nestjs/passport';
import { isBetterAuthEnabled, isClerkEnabled } from '@novu/shared';
import { AuthModule } from '../auth/auth.module';
import { EnvironmentsModuleV1 } from '../environments-v1/environments-v1.module';
import { IntegrationModule } from '../integrations/integrations.module';
import { LayoutsV2Module } from '../layouts-v2/layouts.module';
import { SharedModule } from '../shared/shared.module';
import { UserModule } from '../user/user.module';
import { EEOrganizationController } from './ee.organization.controller';
import { OrganizationController } from './organization.controller';
import { USE_CASES } from './usecases';
const enterpriseImports = (): Array | ForwardReference> => {
const modules: Array | ForwardReference> = [];
if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
if (require('@novu/ee-billing')?.BillingModule) {
modules.push(require('@novu/ee-billing')?.BillingModule.forRoot());
}
}
return modules;
};
function getControllers() {
if (isClerkEnabled() || isBetterAuthEnabled()) {
return [EEOrganizationController];
}
return [OrganizationController];
}
@Module({
imports: [
SharedModule,
UserModule,
EnvironmentsModuleV1,
IntegrationModule,
forwardRef(() => AuthModule),
LayoutsV2Module,
...enterpriseImports(),
],
controllers: [...getControllers()],
providers: [...USE_CASES],
exports: [...USE_CASES],
})
export class OrganizationModule implements NestModule {
configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {
if (process.env.NOVU_ENTERPRISE !== 'true' && process.env.CI_EE_TEST !== 'true') {
consumer.apply(AuthGuard).exclude({
method: RequestMethod.GET,
path: '/organizations/invite/:inviteToken',
});
}
}
}
================================================
FILE: apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts
================================================
import { ApiServiceLevelEnum, JobTitleEnum } from '@novu/shared';
import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator';
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class CreateOrganizationCommand extends AuthenticatedCommand {
@IsString()
@IsDefined()
public readonly name: string;
@IsString()
@IsOptional()
public readonly logo?: string;
@IsOptional()
@IsEnum(JobTitleEnum)
jobTitle?: JobTitleEnum;
@IsString()
@IsOptional()
domain?: string;
@IsOptional()
language?: string[];
@IsOptional()
@IsEnum(ApiServiceLevelEnum)
apiServiceLevel?: ApiServiceLevelEnum;
}
================================================
FILE: apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts
================================================
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AnalyticsService } from '@novu/application-generic';
import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal';
import { ApiServiceLevelEnum, EnvironmentEnum, JobTitleEnum, MemberRoleEnum } from '@novu/shared';
import { CreateEnvironmentCommand } from '../../../environments-v1/usecases/create-environment/create-environment.command';
import { CreateEnvironment } from '../../../environments-v1/usecases/create-environment/create-environment.usecase';
import { GetOrganizationCommand } from '../get-organization/get-organization.command';
import { GetOrganization } from '../get-organization/get-organization.usecase';
import { AddMemberCommand } from '../membership/add-member/add-member.command';
import { AddMember } from '../membership/add-member/add-member.usecase';
import { CreateOrganizationCommand } from './create-organization.command';
@Injectable()
export class CreateOrganization {
constructor(
private readonly organizationRepository: OrganizationRepository,
private readonly addMemberUsecase: AddMember,
private readonly getOrganizationUsecase: GetOrganization,
private readonly userRepository: UserRepository,
private readonly createEnvironmentUsecase: CreateEnvironment,
private analyticsService: AnalyticsService
) {}
async execute(command: CreateOrganizationCommand): Promise {
const user = await this.userRepository.findById(command.userId);
if (!user) throw new BadRequestException('User not found');
const isSelfHosted = process.env.IS_SELF_HOSTED === 'true';
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true';
const defaultApiServiceLevel =
isSelfHosted && isEnterprise ? ApiServiceLevelEnum.UNLIMITED : ApiServiceLevelEnum.FREE;
const createdOrganization = await this.organizationRepository.create({
logo: command.logo,
name: command.name,
apiServiceLevel: command.apiServiceLevel || defaultApiServiceLevel,
domain: command.domain,
language: command.language,
});
if (command.jobTitle) {
await this.updateJobTitle(user, command.jobTitle);
}
await this.addMemberUsecase.execute(
AddMemberCommand.create({
roles: [MemberRoleEnum.OSS_ADMIN],
organizationId: createdOrganization._id,
userId: command.userId,
})
);
const devEnv = await this.createEnvironmentUsecase.execute(
CreateEnvironmentCommand.create({
userId: user._id,
name: EnvironmentEnum.DEVELOPMENT,
organizationId: createdOrganization._id,
system: true,
})
);
await this.createEnvironmentUsecase.execute(
CreateEnvironmentCommand.create({
userId: user._id,
name: EnvironmentEnum.PRODUCTION,
organizationId: createdOrganization._id,
parentEnvironmentId: devEnv._id,
system: true,
})
);
this.analyticsService.upsertGroup(createdOrganization._id, createdOrganization, user);
this.analyticsService.track('[Authentication] - Create Organization', user._id, {
_organization: createdOrganization._id,
language: command.language,
creatorJobTitle: command.jobTitle,
});
const organizationAfterChanges = await this.getOrganizationUsecase.execute(
GetOrganizationCommand.create({
id: createdOrganization._id,
userId: command.userId,
})
);
return organizationAfterChanges as OrganizationEntity;
}
private async updateJobTitle(user, jobTitle: JobTitleEnum) {
await this.userRepository.update(
{
_id: user._id,
},
{
$set: {
jobTitle,
},
}
);
this.analyticsService.setValue(user._id, 'jobTitle', jobTitle);
}
}
================================================
FILE: apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.command.ts
================================================
import { AuthenticatedCommand } from '@novu/application-generic';
import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator';
export class SyncExternalOrganizationCommand extends AuthenticatedCommand {
@IsDefined()
@IsString()
externalId: string;
@IsDefined()
@IsString()
email: string;
@IsOptional()
headers: Record;
}
================================================
FILE: apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.usecase.ts
================================================
import { BadRequestException, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AnalyticsService, PinoLogger } from '@novu/application-generic';
import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal';
import { CreateEnvironmentCommand } from '../../../../environments-v1/usecases/create-environment/create-environment.command';
import { CreateEnvironment } from '../../../../environments-v1/usecases/create-environment/create-environment.usecase';
import { CreateNovuIntegrationsCommand } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.command';
import { CreateNovuIntegrations } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase';
import { UpsertLayout, UpsertLayoutCommand } from '../../../../layouts-v2/usecases/upsert-layout';
import { createDefaultLayout } from '../../../../layouts-v2/utils/layout-templates';
import { GetOrganizationCommand } from '../../get-organization/get-organization.command';
import { GetOrganization } from '../../get-organization/get-organization.usecase';
import { SyncExternalOrganizationCommand } from './sync-external-organization.command';
// TODO: eventually move to @novu/ee-auth
/**
* This logic is closely related to the CreateOrganization use case.
* @see src/app/organization/usecases/create-organization/create-organization.usecase.ts
*
* The side effects of creating a new organization are largely
* consistent with those in CreateOrganization, with only minor differences.
*/
@Injectable()
export class SyncExternalOrganization {
constructor(
private readonly organizationRepository: OrganizationRepository,
private readonly getOrganizationUsecase: GetOrganization,
private readonly createEnvironmentUsecase: CreateEnvironment,
private readonly createNovuIntegrations: CreateNovuIntegrations,
private readonly upsertLayoutUsecase: UpsertLayout,
private analyticsService: AnalyticsService,
private moduleRef: ModuleRef,
private logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
}
async execute(command: SyncExternalOrganizationCommand): Promise {
const isSelfHosted = process.env.IS_SELF_HOSTED === 'true';
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true';
const organization = await this.organizationRepository.create(
{
externalId: command.externalId,
apiServiceLevel: isSelfHosted && isEnterprise ? 'unlimited' : undefined,
},
{ headers: command.headers }
);
const devEnv = await this.createEnvironmentUsecase.execute(
CreateEnvironmentCommand.create({
userId: command.userId,
name: 'Development',
organizationId: organization._id,
system: true,
})
);
await this.createNovuIntegrations.execute(
CreateNovuIntegrationsCommand.create({
environmentId: devEnv._id,
organizationId: devEnv._organizationId,
userId: command.userId,
name: devEnv.name,
})
);
await this.upsertLayoutUsecase.execute(
UpsertLayoutCommand.create({
environmentId: devEnv._id,
organizationId: devEnv._organizationId,
userId: command.userId,
layoutDto: {
name: 'Default layout',
controlValues: {
email: {
body: JSON.stringify(createDefaultLayout(organization.name)),
editorType: 'block',
},
},
},
})
);
const prodEnv = await this.createEnvironmentUsecase.execute(
CreateEnvironmentCommand.create({
userId: command.userId,
name: 'Production',
organizationId: organization._id,
parentEnvironmentId: devEnv._id,
system: true,
})
);
await this.createNovuIntegrations.execute(
CreateNovuIntegrationsCommand.create({
environmentId: prodEnv._id,
organizationId: prodEnv._organizationId,
userId: command.userId,
name: prodEnv.name,
})
);
await this.upsertLayoutUsecase.execute(
UpsertLayoutCommand.create({
environmentId: prodEnv._id,
organizationId: prodEnv._organizationId,
userId: command.userId,
layoutDto: {
name: 'Default layout',
controlValues: {
email: {
body: JSON.stringify(createDefaultLayout(organization.name)),
editorType: 'block',
},
},
},
})
);
this.analyticsService.upsertGroup(organization._id, organization, { _id: command.userId });
this.analyticsService.track('[Authentication] - Create Organization', command.userId, {
_organization: organization._id,
});
const organizationAfterChanges = await this.getOrganizationUsecase.execute(
GetOrganizationCommand.create({
id: organization._id,
userId: command.userId,
})
);
if (organizationAfterChanges !== null) {
await this.createCustomer(command.email, organizationAfterChanges._id);
}
return organizationAfterChanges as OrganizationEntity;
}
private async createCustomer(billingEmail: string, organizationId: string) {
try {
if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') {
if (!require('@novu/ee-billing')?.GetOrCreateCustomer) {
throw new BadRequestException('Billing module is not loaded');
}
const usecase = this.moduleRef.get(require('@novu/ee-billing')?.GetOrCreateCustomer, {
strict: false,
});
await usecase.execute({
organizationId,
billingEmail,
});
}
} catch (e) {
this.logger.error({ err: e }, `Unexpected error while importing enterprise modules`);
}
}
}
================================================
FILE: apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.command.ts
================================================
import { IsDefined } from 'class-validator';
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class GetMyOrganizationCommand extends AuthenticatedCommand {
@IsDefined()
public readonly id: string;
}
================================================
FILE: apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.usecase.ts
================================================
import { Injectable, Scope, UnauthorizedException } from '@nestjs/common';
import { GetOrganizationCommand } from '../get-organization/get-organization.command';
import { GetOrganization } from '../get-organization/get-organization.usecase';
import { GetMyOrganizationCommand } from './get-my-organization.command';
@Injectable({
scope: Scope.REQUEST,
})
export class GetMyOrganization {
constructor(private getOrganizationUseCase: GetOrganization) {}
async execute(command: GetMyOrganizationCommand) {
const organization = await this.getOrganizationUseCase.execute(
GetOrganizationCommand.create({
id: command.id,
userId: command.userId,
})
);
if (!organization) throw new UnauthorizedException('No organization found');
return organization;
}
}
================================================
FILE: apps/api/src/app/organization/usecases/get-organization/get-organization.command.ts
================================================
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class GetOrganizationCommand extends AuthenticatedCommand {
public readonly id: string;
}
================================================
FILE: apps/api/src/app/organization/usecases/get-organization/get-organization.usecase.ts
================================================
import { Injectable, Scope } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { GetOrganizationCommand } from './get-organization.command';
@Injectable()
export class GetOrganization {
constructor(private readonly organizationRepository: OrganizationRepository) {}
async execute(command: GetOrganizationCommand) {
return await this.organizationRepository.findById(command.id);
}
}
================================================
FILE: apps/api/src/app/organization/usecases/get-organization-settings/get-organization-settings.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { OrganizationEntity } from '@novu/dal';
import { IsNotEmpty, IsOptional } from 'class-validator';
export class GetOrganizationSettingsCommand extends BaseCommand {
@IsNotEmpty()
readonly organizationId: string;
@IsOptional()
readonly organization?: OrganizationEntity;
}
================================================
FILE: apps/api/src/app/organization/usecases/get-organization-settings/get-organization-settings.usecase.ts
================================================
import { Injectable, NotFoundException } from '@nestjs/common';
import { CommunityOrganizationRepository } from '@novu/dal';
import { DEFAULT_LOCALE } from '@novu/shared';
import { GetOrganizationSettingsDto } from '../../dtos/get-organization-settings.dto';
import { GetOrganizationSettingsCommand } from './get-organization-settings.command';
@Injectable()
export class GetOrganizationSettings {
constructor(private organizationRepository: CommunityOrganizationRepository) {}
async execute(command: GetOrganizationSettingsCommand): Promise {
const organization = command.organization ?? (await this.organizationRepository.findById(command.organizationId));
if (!organization) {
throw new NotFoundException('Organization not found');
}
return {
removeNovuBranding: organization.removeNovuBranding || false,
defaultLocale: organization.defaultLocale || DEFAULT_LOCALE,
targetLocales: organization.targetLocales || [],
};
}
}
================================================
FILE: apps/api/src/app/organization/usecases/get-organizations/get-organizations.command.ts
================================================
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class GetOrganizationsCommand extends AuthenticatedCommand {}
================================================
FILE: apps/api/src/app/organization/usecases/get-organizations/get-organizations.usecase.ts
================================================
import { Injectable, Scope } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { GetOrganizationsCommand } from './get-organizations.command';
@Injectable({
scope: Scope.REQUEST,
})
export class GetOrganizations {
constructor(private readonly organizationRepository: OrganizationRepository) {}
async execute(command: GetOrganizationsCommand) {
return await this.organizationRepository.findUserActiveOrganizations(command.userId);
}
}
================================================
FILE: apps/api/src/app/organization/usecases/index.ts
================================================
import { isBetterAuthEnabled, isClerkEnabled } from '@novu/shared';
import { CreateOrganization } from './create-organization/create-organization.usecase';
import { SyncExternalOrganization } from './create-organization/sync-external-organization/sync-external-organization.usecase';
import { GetMyOrganization } from './get-my-organization/get-my-organization.usecase';
import { GetOrganization } from './get-organization/get-organization.usecase';
import { GetOrganizationSettings } from './get-organization-settings/get-organization-settings.usecase';
import { GetOrganizations } from './get-organizations/get-organizations.usecase';
import { AddMember } from './membership/add-member/add-member.usecase';
import { ChangeMemberRole } from './membership/change-member-role/change-member-role.usecase';
import { GetMembers } from './membership/get-members/get-members.usecase';
import { RemoveMember } from './membership/remove-member/remove-member.usecase';
import { RenameOrganization } from './rename-organization/rename-organization.usecase';
import { UpdateBrandingDetails } from './update-branding-details/update-branding-details.usecase';
import { UpdateOrganizationSettings } from './update-organization-settings/update-organization-settings.usecase';
// TODO: move ee.organization.controller.ts to EE package
function getEnterpriseUsecases() {
if (isClerkEnabled() || isBetterAuthEnabled()) {
return [
{
provide: 'SyncOrganizationUsecase',
useClass: SyncExternalOrganization,
},
];
}
return [];
}
export const USE_CASES = [
AddMember,
CreateOrganization,
GetOrganization,
GetMembers,
RemoveMember,
ChangeMemberRole,
UpdateBrandingDetails,
GetOrganizations,
GetMyOrganization,
RenameOrganization,
GetOrganizationSettings,
UpdateOrganizationSettings,
...getEnterpriseUsecases(),
];
================================================
FILE: apps/api/src/app/organization/usecases/membership/add-member/add-member.command.ts
================================================
import { MemberRoleEnum } from '@novu/shared';
import { ArrayNotEmpty } from 'class-validator';
import { OrganizationCommand } from '../../../../shared/commands/organization.command';
export class AddMemberCommand extends OrganizationCommand {
@ArrayNotEmpty()
public readonly roles: MemberRoleEnum[];
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/add-member/add-member.usecase.ts
================================================
import { BadRequestException, Injectable } from '@nestjs/common';
import { MemberRepository } from '@novu/dal';
import { MemberStatusEnum } from '@novu/shared';
import { AddMemberCommand } from './add-member.command';
@Injectable()
export class AddMember {
constructor(private readonly memberRepository: MemberRepository) {}
async execute(command: AddMemberCommand): Promise {
const isAlreadyMember = await this.isMember(command.organizationId, command.userId);
if (isAlreadyMember) throw new BadRequestException('Member already exists');
await this.memberRepository.addMember(command.organizationId, {
_userId: command.userId,
roles: command.roles,
memberStatus: MemberStatusEnum.ACTIVE,
});
}
private async isMember(organizationId: string, userId: string): Promise {
return !!(await this.memberRepository.findMemberByUserId(organizationId, userId));
}
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.command.ts
================================================
import { MemberRoleEnum } from '@novu/shared';
import { IsDefined, IsEnum, IsMongoId } from 'class-validator';
import { OrganizationCommand } from '../../../../shared/commands/organization.command';
export class ChangeMemberRoleCommand extends OrganizationCommand {
@IsDefined()
role: MemberRoleEnum.OSS_ADMIN;
@IsDefined()
@IsMongoId()
memberId: string;
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.usecase.ts
================================================
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { MemberRepository, OrganizationRepository } from '@novu/dal';
import { MemberRoleEnum } from '@novu/shared';
import { ChangeMemberRoleCommand } from './change-member-role.command';
@Injectable()
export class ChangeMemberRole {
constructor(
private organizationRepository: OrganizationRepository,
private memberRepository: MemberRepository
) {}
async execute(command: ChangeMemberRoleCommand) {
if (![MemberRoleEnum.OSS_MEMBER, MemberRoleEnum.OSS_ADMIN].includes(command.role)) {
throw new BadRequestException('Not supported role type');
}
if (command.role !== MemberRoleEnum.OSS_ADMIN) {
throw new BadRequestException(`The change of role to an ${command.role} type is not supported`);
}
const organization = await this.organizationRepository.findById(command.organizationId);
if (!organization) throw new NotFoundException('No organization was found');
const member = await this.memberRepository.findMemberById(organization._id, command.memberId);
if (!member) throw new NotFoundException('No member was found');
const roles = [command.role];
await this.memberRepository.updateMemberRoles(organization._id, command.memberId, roles);
return this.memberRepository.findMemberByUserId(organization._id, member._userId);
}
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/get-members/get-members.command.ts
================================================
import { UserSessionData } from '@novu/shared';
import { IsDefined } from 'class-validator';
import { OrganizationCommand } from '../../../../shared/commands/organization.command';
export class GetMembersCommand extends OrganizationCommand {
@IsDefined()
user: UserSessionData;
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/get-members/get-members.usecase.ts
================================================
import { Injectable, Scope } from '@nestjs/common';
import { MemberRepository } from '@novu/dal';
import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared';
import { GetMembersCommand } from './get-members.command';
@Injectable({
scope: Scope.REQUEST,
})
export class GetMembers {
constructor(private membersRepository: MemberRepository) {}
async execute(command: GetMembersCommand) {
return (await this.membersRepository.getOrganizationMembers(command.organizationId))
.map((member) => {
if (!command.user.roles.includes(MemberRoleEnum.OSS_ADMIN)) {
if (member.memberStatus === MemberStatusEnum.INVITED) return null;
if (member.user) member.user.email = '';
if (member.invite) member.invite.email = '';
}
return member;
})
.filter((member) => !!member);
}
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/remove-member/remove-member.command.ts
================================================
import { IsMongoId, IsString } from 'class-validator';
import { OrganizationCommand } from '../../../../shared/commands/organization.command';
export class RemoveMemberCommand extends OrganizationCommand {
@IsString()
@IsMongoId()
memberId: string;
}
================================================
FILE: apps/api/src/app/organization/usecases/membership/remove-member/remove-member.usecase.ts
================================================
import { BadRequestException, Injectable, NotFoundException, Scope } from '@nestjs/common';
import { EnvironmentRepository, MemberRepository } from '@novu/dal';
import { RemoveMemberCommand } from './remove-member.command';
@Injectable({
scope: Scope.REQUEST,
})
export class RemoveMember {
constructor(
private memberRepository: MemberRepository,
private environmentRepository: EnvironmentRepository
) {}
async execute(command: RemoveMemberCommand) {
const members = await this.memberRepository.getOrganizationMembers(command.organizationId);
const memberToRemove = members.find((i) => i._id === command.memberId);
if (!memberToRemove) throw new NotFoundException('Member not found');
if (memberToRemove._userId && memberToRemove._userId && memberToRemove._userId === command.userId) {
throw new BadRequestException('Cannot remove self from members');
}
await this.memberRepository.removeMemberById(command.organizationId, memberToRemove._id);
const environments = await this.environmentRepository.findOrganizationEnvironments(command.organizationId);
const isMemberAssociatedWithEnvironment = environments.some((i) =>
i.apiKeys.some((key) => key._userId === memberToRemove._userId)
);
if (isMemberAssociatedWithEnvironment) {
const owner = await this.memberRepository.getOrganizationOwnerAccount(command.organizationId);
if (!owner) throw new NotFoundException('No owner account found for organization');
await this.environmentRepository.updateApiKeyUserId(
command.organizationId,
memberToRemove._userId,
owner._userId
);
}
return memberToRemove;
}
}
================================================
FILE: apps/api/src/app/organization/usecases/rename-organization/rename-organization-command.ts
================================================
import { IsDefined, IsNotEmpty } from 'class-validator';
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class RenameOrganizationCommand extends AuthenticatedCommand {
@IsDefined()
public readonly id: string;
@IsDefined()
@IsNotEmpty()
name: string;
}
================================================
FILE: apps/api/src/app/organization/usecases/rename-organization/rename-organization.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { RenameOrganizationCommand } from './rename-organization-command';
@Injectable()
export class RenameOrganization {
constructor(private organizationRepository: OrganizationRepository) {}
async execute(command: RenameOrganizationCommand) {
const payload = {
name: command.name,
};
await this.organizationRepository.renameOrganization(command.id, payload);
return payload;
}
}
================================================
FILE: apps/api/src/app/organization/usecases/update-branding-details/update-branding-details.command.ts
================================================
import { IsDefined, IsHexColor, IsOptional, IsUrl } from 'class-validator';
import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';
export class UpdateBrandingDetailsCommand extends AuthenticatedCommand {
@IsDefined()
public readonly id: string;
@IsUrl({ require_tld: false })
@IsOptional()
logo: string;
@IsOptional()
@IsHexColor()
color: string;
@IsOptional()
@IsHexColor()
fontColor: string;
@IsOptional()
@IsHexColor()
contentBackground: string;
@IsOptional()
fontFamily?: string;
}
================================================
FILE: apps/api/src/app/organization/usecases/update-branding-details/update-branding-details.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { UpdateBrandingDetailsCommand } from './update-branding-details.command';
@Injectable()
export class UpdateBrandingDetails {
constructor(private organizationRepository: OrganizationRepository) {}
async execute(command: UpdateBrandingDetailsCommand) {
const payload = {
color: command.color,
logo: command.logo,
fontColor: command.fontColor,
contentBackground: command.contentBackground,
fontFamily: command.fontFamily,
};
await this.organizationRepository.updateBrandingDetails(command.id, payload);
return payload;
}
}
================================================
FILE: apps/api/src/app/organization/usecases/update-organization-settings/update-organization-settings.command.ts
================================================
import { AuthenticatedCommand, IsValidLocale } from '@novu/application-generic';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateOrganizationSettingsCommand extends AuthenticatedCommand {
@IsNotEmpty()
readonly organizationId: string;
@IsOptional()
@IsBoolean()
removeNovuBranding?: boolean;
@IsOptional()
@IsValidLocale()
defaultLocale?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
targetLocales?: string[];
}
================================================
FILE: apps/api/src/app/organization/usecases/update-organization-settings/update-organization-settings.usecase.ts
================================================
import { HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
import { AnalyticsService } from '@novu/application-generic';
import { CommunityOrganizationRepository, OrganizationEntity } from '@novu/dal';
import { ApiServiceLevelEnum, DEFAULT_LOCALE, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared';
import { GetOrganizationSettingsDto } from '../../dtos/get-organization-settings.dto';
import { UpdateOrganizationSettingsCommand } from './update-organization-settings.command';
@Injectable()
export class UpdateOrganizationSettings {
constructor(
private organizationRepository: CommunityOrganizationRepository,
private analyticsService: AnalyticsService
) {}
async execute(command: UpdateOrganizationSettingsCommand): Promise {
const organization = await this.organizationRepository.findById(command.organizationId);
if (!organization) {
throw new NotFoundException('Organization not found');
}
this.validateTierRestrictions(command, organization);
const updateFields = this.buildUpdateFields(command);
if (Object.keys(updateFields).length === 0) {
return this.buildSettingsResponse(organization);
}
await this.organizationRepository.updateOne({ _id: organization._id }, { $set: updateFields });
if (command.removeNovuBranding !== undefined) {
this.analyticsService.mixpanelTrack('Remove Branding', command.userId, {
_organization: command.organizationId,
newStatus: command.removeNovuBranding,
});
}
return this.buildSettingsResponse({
...organization,
...updateFields,
});
}
private validateTierRestrictions(command: UpdateOrganizationSettingsCommand, organization: OrganizationEntity): void {
// Only validate branding feature access if user is trying to update it
if (command.removeNovuBranding !== undefined) {
const canRemoveNovuBranding = getFeatureForTierAsBoolean(
FeatureNameEnum.PLATFORM_REMOVE_NOVU_BRANDING_BOOLEAN,
organization.apiServiceLevel || ApiServiceLevelEnum.FREE
);
if (!canRemoveNovuBranding) {
throw new HttpException(
{
error: 'Payment Required',
message:
'Removing Novu branding is not allowed on the free plan. Please upgrade to a paid plan to access this feature.',
},
HttpStatus.PAYMENT_REQUIRED
);
}
}
if (command.targetLocales !== undefined || command.defaultLocale !== undefined) {
const canUseTranslations = getFeatureForTierAsBoolean(
FeatureNameEnum.AUTO_TRANSLATIONS,
organization.apiServiceLevel || ApiServiceLevelEnum.FREE
);
if (!canUseTranslations) {
throw new HttpException(
{
error: 'Payment Required',
message:
'Update of locales is a part of the translation feature. Please upgrade to a paid plan to access this feature.',
},
HttpStatus.PAYMENT_REQUIRED
);
}
}
}
private buildUpdateFields(command: UpdateOrganizationSettingsCommand): Partial {
const updateFields: Partial = {};
if (command.removeNovuBranding !== undefined) {
updateFields.removeNovuBranding = command.removeNovuBranding;
}
if (command.defaultLocale !== undefined) {
updateFields.defaultLocale = command.defaultLocale;
}
if (command.targetLocales !== undefined) {
updateFields.targetLocales = command.targetLocales;
}
return updateFields;
}
private buildSettingsResponse(organization: OrganizationEntity): GetOrganizationSettingsDto {
return {
removeNovuBranding: organization.removeNovuBranding || false,
defaultLocale: organization.defaultLocale || DEFAULT_LOCALE,
targetLocales: organization.targetLocales || [],
};
}
}
================================================
FILE: apps/api/src/app/outbound-webhooks/dtos/create-webhook-portal-response.dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
export class CreateWebhookPortalResponseDto {
@ApiProperty({
description: 'The webhook portal application ID',
})
appId: string;
}
================================================
FILE: apps/api/src/app/outbound-webhooks/dtos/get-webhook-portal-token-response.dto.ts
================================================
import { IsNotEmpty, IsString } from 'class-validator';
export class GetWebhookPortalTokenResponseDto {
@IsNotEmpty()
@IsString()
url: string;
@IsNotEmpty()
@IsString()
token: string;
@IsNotEmpty()
@IsString()
appId: string;
}
================================================
FILE: apps/api/src/app/outbound-webhooks/outbound-webhooks.controller.ts
================================================
import { ClassSerializerInterceptor, Controller, Get, Post, UseInterceptors } from '@nestjs/common';
import { ApiExcludeController, ApiOperation } from '@nestjs/swagger';
import { ProductFeature, RequirePermissions, UserSession } from '@novu/application-generic';
import { PermissionsEnum, ProductFeatureKeyEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { CreateWebhookPortalResponseDto } from './dtos/create-webhook-portal-response.dto';
import { GetWebhookPortalTokenResponseDto } from './dtos/get-webhook-portal-token-response.dto';
import { CreateWebhookPortalCommand } from './usecases/create-webhook-portal-token/create-webhook-portal.command';
import { CreateWebhookPortalUsecase } from './usecases/create-webhook-portal-token/create-webhook-portal.usecase';
import { GetWebhookPortalTokenCommand } from './usecases/get-webhook-portal-token/get-webhook-portal-token.command';
import { GetWebhookPortalTokenUsecase } from './usecases/get-webhook-portal-token/get-webhook-portal-token.usecase';
@Controller({ path: `/outbound-webhooks`, version: '2' })
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
@ApiExcludeController()
export class OutboundWebhooksController {
constructor(
private getWebhookPortalTokenUsecase: GetWebhookPortalTokenUsecase,
private createWebhookPortalTokenUsecase: CreateWebhookPortalUsecase
) {}
@Get('/portal/token')
@ProductFeature(ProductFeatureKeyEnum.WEBHOOKS)
@RequirePermissions(PermissionsEnum.WEBHOOK_WRITE, PermissionsEnum.WEBHOOK_READ)
@ApiOperation({
summary: 'Get Webhook Portal Access Token',
description:
'Generates a short-lived token and URL for accessing the Svix application portal for the current environment.',
})
async getPortalToken(@UserSession() user: UserSessionData): Promise {
return await this.getWebhookPortalTokenUsecase.execute(
GetWebhookPortalTokenCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
})
);
}
@Post('/portal/token')
@ProductFeature(ProductFeatureKeyEnum.WEBHOOKS)
@RequirePermissions(PermissionsEnum.WEBHOOK_WRITE)
@ApiOperation({
summary: 'Create Webhook Portal Access Token',
description: 'Creates a token for accessing the webhook portal for the current environment.',
})
async createPortalToken(@UserSession() user: UserSessionData): Promise {
return await this.createWebhookPortalTokenUsecase.execute(
CreateWebhookPortalCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
})
);
}
}
================================================
FILE: apps/api/src/app/outbound-webhooks/outbound-webhooks.module.ts
================================================
import { DynamicModule, Module } from '@nestjs/common';
import { SendWebhookMessage, SvixProviderService } from '@novu/application-generic';
import { NoopSendWebhookMessage } from '../inbox/usecases/noop-send-webhook-message.usecase';
import { SharedModule } from '../shared/shared.module';
import { OutboundWebhooksController } from './outbound-webhooks.controller';
import { CreateWebhookPortalUsecase } from './usecases/create-webhook-portal-token/create-webhook-portal.usecase';
import { GetWebhookPortalTokenUsecase } from './usecases/get-webhook-portal-token/get-webhook-portal-token.usecase';
@Module({})
class OutboundWebhooksModuleDefinition {}
export const OutboundWebhooksModule = {
forRoot(): DynamicModule {
const isEnterprise = process.env.NOVU_ENTERPRISE === 'true';
if (isEnterprise) {
return {
module: OutboundWebhooksModuleDefinition,
imports: [SharedModule],
controllers: [OutboundWebhooksController],
providers: [GetWebhookPortalTokenUsecase, CreateWebhookPortalUsecase, SvixProviderService, SendWebhookMessage],
exports: [SendWebhookMessage],
};
}
return {
module: OutboundWebhooksModuleDefinition,
imports: [SharedModule],
providers: [
{
provide: SendWebhookMessage,
useClass: NoopSendWebhookMessage,
},
],
exports: [SendWebhookMessage],
};
},
};
================================================
FILE: apps/api/src/app/outbound-webhooks/usecases/create-webhook-portal-token/create-webhook-portal.command.ts
================================================
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class CreateWebhookPortalCommand extends EnvironmentWithUserCommand {}
================================================
FILE: apps/api/src/app/outbound-webhooks/usecases/create-webhook-portal-token/create-webhook-portal.usecase.ts
================================================
import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
import { generateWebhookAppId, LogDecorator, SvixClient } from '@novu/application-generic';
import { EnvironmentRepository, OrganizationRepository } from '@novu/dal';
import { CreateWebhookPortalResponseDto } from '../../dtos/create-webhook-portal-response.dto';
import { CreateWebhookPortalCommand } from './create-webhook-portal.command';
@Injectable()
export class CreateWebhookPortalUsecase {
constructor(
private environmentRepository: EnvironmentRepository,
@Inject('SVIX_CLIENT') private svix: SvixClient,
private organizationRepository: OrganizationRepository
) {}
@LogDecorator()
async execute(command: CreateWebhookPortalCommand): Promise {
if (!this.svix) {
throw new BadRequestException('Webhook system is not enabled');
}
const environment = await this.environmentRepository.findOne({
_id: command.environmentId,
_organizationId: command.organizationId,
});
if (!environment) {
throw new NotFoundException(
`Environment not found for id ${command.environmentId} and organization ${command.organizationId}`
);
}
const organization = await this.organizationRepository.findById(command.organizationId);
if (!organization) {
throw new NotFoundException(`Organization not found for id ${command.organizationId}`);
}
try {
const app = await this.svix.application.create({
name: organization.name,
uid: generateWebhookAppId(command.organizationId, command.environmentId),
metadata: {
environmentId: command.environmentId,
organizationId: command.organizationId,
},
});
await this.environmentRepository.updateOne({ _id: command.environmentId }, { $set: { webhookAppId: app.uid } });
return {
appId: app.uid!,
};
} catch (error) {
throw new BadRequestException(`Failed to generate Svix portal token: ${error?.message}`);
}
}
}
================================================
FILE: apps/api/src/app/outbound-webhooks/usecases/get-webhook-portal-token/get-webhook-portal-token.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { IsDefined } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class GetWebhookPortalTokenCommand extends EnvironmentCommand {
@IsDefined()
userId: string;
}
================================================
FILE: apps/api/src/app/outbound-webhooks/usecases/get-webhook-portal-token/get-webhook-portal-token.usecase.ts
================================================
import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
import { generateWebhookAppId, LogDecorator, SvixClient } from '@novu/application-generic';
import { EnvironmentRepository } from '@novu/dal';
import { GetWebhookPortalTokenResponseDto } from '../../dtos/get-webhook-portal-token-response.dto';
import { GetWebhookPortalTokenCommand } from './get-webhook-portal-token.command';
@Injectable()
export class GetWebhookPortalTokenUsecase {
constructor(
private environmentRepository: EnvironmentRepository,
@Inject('SVIX_CLIENT') private svix: SvixClient
) {}
@LogDecorator()
async execute(command: GetWebhookPortalTokenCommand): Promise {
if (!this.svix) {
throw new BadRequestException('Webhook system is not enabled');
}
const environment = await this.environmentRepository.findOne({
_id: command.environmentId,
_organizationId: command.organizationId,
});
if (!environment) {
throw new NotFoundException(
`Environment not found for id ${command.environmentId} and organization ${command.organizationId}`
);
}
if (!environment.webhookAppId) {
throw new NotFoundException(`Portal not found for environment ${command.environmentId}`);
}
try {
const svixResponse = await this.svix.authentication.appPortalAccess(environment.webhookAppId, {});
return {
url: svixResponse.url,
token: svixResponse.token,
appId: environment.webhookAppId,
};
} catch (error) {
if (error.code === 404) {
throw new NotFoundException(`Portal not found for environment ${command.environmentId}`);
}
throw new BadRequestException(`Failed to generate Svix portal token: ${error?.message}`);
}
}
}
================================================
FILE: apps/api/src/app/outbound-webhooks/webhooks.const.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { MessageWebhookResponseDto, WorkflowResponseDto } from '@novu/application-generic';
import { WebhookEventEnum, WebhookObjectTypeEnum } from '@novu/shared';
import { InboxPreference } from '../inbox/utils/types';
interface WebhookEventConfig {
event: WebhookEventEnum;
// biome-ignore lint/complexity/noBannedTypes: This is the expected type for the payloadDto for SwaggerDocumentOptions.extraModels
payloadDto: Function;
objectType: WebhookObjectTypeEnum;
}
type WebhookEventRecord = Record;
export class WebhookUpdatedWorkflowDto {
@ApiProperty({ description: 'Current workflow state', type: () => WorkflowResponseDto })
object: WorkflowResponseDto;
@ApiProperty({ description: 'Previous state of the workflow', type: () => WorkflowResponseDto })
previousObject: WorkflowResponseDto;
}
export class WebhookCreatedWorkflowDto {
@ApiProperty({ description: 'Current workflow state', type: () => WorkflowResponseDto })
object: WorkflowResponseDto;
}
export class WebhookDeletedWorkflowDto {
@ApiProperty({ description: 'Current workflow state', type: () => WorkflowResponseDto })
object: WorkflowResponseDto;
}
export class WebhookMessageDto {
@ApiProperty({ description: 'Current message state' })
object: MessageWebhookResponseDto;
}
enum MessageFailedErrorCodeEnum {
TOKEN_EXPIRED = 'token_expired',
}
export class MessageFailedWebhookDto {
@ApiProperty({ description: 'Current message state' })
object: MessageWebhookResponseDto;
@ApiProperty({ description: 'Error message' })
errorCode: MessageFailedErrorCodeEnum;
}
export class MessageFailedPushDto {
@ApiProperty({ description: 'Is invalid token' })
isInvalidToken: boolean;
@ApiProperty({ description: 'Device token' })
deviceToken: string;
}
export class MessageFailedErrorDto {
@ApiProperty({ description: 'Error message' })
message: string;
@ApiProperty({ description: 'Push error' })
push?: MessageFailedPushDto;
}
export class WebhookMessageFailedDto {
@ApiProperty({ description: 'Current message state' })
object: MessageWebhookResponseDto;
@ApiProperty({ description: 'Error message' })
error: MessageFailedErrorDto;
}
export class WebhookPreferenceDto {
@ApiProperty({ description: 'Current preference state' })
object: InboxPreference;
@ApiProperty({ description: 'Subscriber ID' })
subscriberId: string;
}
// Create the webhook events as a record to ensure all enum values are covered
const webhookEventRecord = {
[WebhookEventEnum.MESSAGE_SENT]: {
event: WebhookEventEnum.MESSAGE_SENT,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_FAILED]: {
event: WebhookEventEnum.MESSAGE_FAILED,
payloadDto: WebhookMessageFailedDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_DELIVERED]: {
event: WebhookEventEnum.MESSAGE_DELIVERED,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_SEEN]: {
event: WebhookEventEnum.MESSAGE_SEEN,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_READ]: {
event: WebhookEventEnum.MESSAGE_READ,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_UNREAD]: {
event: WebhookEventEnum.MESSAGE_UNREAD,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_ARCHIVED]: {
event: WebhookEventEnum.MESSAGE_ARCHIVED,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_UNARCHIVED]: {
event: WebhookEventEnum.MESSAGE_UNARCHIVED,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_SNOOZED]: {
event: WebhookEventEnum.MESSAGE_SNOOZED,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_UNSNOOZED]: {
event: WebhookEventEnum.MESSAGE_UNSNOOZED,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.MESSAGE_DELETED]: {
event: WebhookEventEnum.MESSAGE_DELETED,
payloadDto: WebhookMessageDto,
objectType: WebhookObjectTypeEnum.MESSAGE,
},
[WebhookEventEnum.WORKFLOW_CREATED]: {
event: WebhookEventEnum.WORKFLOW_CREATED,
payloadDto: WebhookCreatedWorkflowDto,
objectType: WebhookObjectTypeEnum.WORKFLOW,
},
[WebhookEventEnum.WORKFLOW_UPDATED]: {
event: WebhookEventEnum.WORKFLOW_UPDATED,
payloadDto: WebhookUpdatedWorkflowDto,
objectType: WebhookObjectTypeEnum.WORKFLOW,
},
[WebhookEventEnum.WORKFLOW_DELETED]: {
event: WebhookEventEnum.WORKFLOW_DELETED,
payloadDto: WebhookDeletedWorkflowDto,
objectType: WebhookObjectTypeEnum.WORKFLOW,
},
[WebhookEventEnum.WORKFLOW_PUBLISHED]: {
event: WebhookEventEnum.WORKFLOW_PUBLISHED,
payloadDto: WebhookUpdatedWorkflowDto,
objectType: WebhookObjectTypeEnum.WORKFLOW,
},
[WebhookEventEnum.PREFERENCE_UPDATED]: {
event: WebhookEventEnum.PREFERENCE_UPDATED,
payloadDto: WebhookPreferenceDto,
objectType: WebhookObjectTypeEnum.PREFERENCE,
},
} as const satisfies WebhookEventRecord;
// Helper function to ensure all enum values are present exactly once
function createWebhookEvents(record: T): WebhookEventConfig[] {
return Object.values(record);
}
// Export the webhook events array created from the type-safe record
export const webhookEvents = createWebhookEvents(webhookEventRecord);
================================================
FILE: apps/api/src/app/partner-integrations/dtos/create-vercel-integration-request.dto.ts
================================================
import { IsDefined, IsString } from 'class-validator';
export class CreateVercelIntegrationRequestDto {
@IsDefined()
@IsString()
vercelIntegrationCode: string;
@IsDefined()
@IsString()
configurationId: string;
}
================================================
FILE: apps/api/src/app/partner-integrations/dtos/create-vercel-integration-response.dto.ts
================================================
import { IsDefined } from 'class-validator';
export class CreateVercelIntegrationResponseDto {
@IsDefined()
success: boolean;
}
================================================
FILE: apps/api/src/app/partner-integrations/dtos/update-vercel-integration-request.dto.ts
================================================
import { IsDefined, IsString } from 'class-validator';
export class UpdateVercelIntegrationRequestDto {
@IsDefined()
data: Record;
@IsDefined()
@IsString()
configurationId: string;
}
================================================
FILE: apps/api/src/app/partner-integrations/partner-integrations.controller.ts
================================================
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Headers,
Param,
Post,
Put,
Query,
UseInterceptors,
} from '@nestjs/common';
import { ApiExcludeController, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import { CreateVercelIntegrationRequestDto } from './dtos/create-vercel-integration-request.dto';
import { CreateVercelIntegrationResponseDto } from './dtos/create-vercel-integration-response.dto';
import { UpdateVercelIntegrationRequestDto } from './dtos/update-vercel-integration-request.dto';
import { CreateVercelIntegrationCommand } from './usecases/create-vercel-integration/create-vercel-integration.command';
import { CreateVercelIntegration } from './usecases/create-vercel-integration/create-vercel-integration.usecase';
import { GetVercelIntegrationCommand } from './usecases/get-vercel-integration/get-vercel-integration.command';
import { GetVercelIntegration } from './usecases/get-vercel-integration/get-vercel-integration.usecase';
import { GetVercelIntegrationProjectsCommand } from './usecases/get-vercel-projects/get-vercel-integration-projects.command';
import { GetVercelIntegrationProjects } from './usecases/get-vercel-projects/get-vercel-integration-projects.usecase';
import { ProcessVercelWebhookCommand } from './usecases/process-vercel-webhook/process-vercel-webhook.command';
import { ProcessVercelWebhook } from './usecases/process-vercel-webhook/process-vercel-webhook.usecase';
import { UpdateVercelIntegrationCommand } from './usecases/update-vercel-integration/update-vercel-integration.command';
import { UpdateVercelIntegration } from './usecases/update-vercel-integration/update-vercel-integration.usecase';
@Controller('/partner-integrations')
@UseInterceptors(ClassSerializerInterceptor)
@ApiTags('Partner Integrations')
@ApiExcludeController()
export class PartnerIntegrationsController {
constructor(
private createVercelIntegrationUsecase: CreateVercelIntegration,
private getVercelIntegrationProjectsUsecase: GetVercelIntegrationProjects,
private getVercelIntegrationUsecase: GetVercelIntegration,
private updateVercelIntegrationUsecase: UpdateVercelIntegration,
private processVercelWebhookUsecase: ProcessVercelWebhook
) {}
@Post('/vercel')
@RequireAuthentication()
@RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_WRITE)
async createVercelIntegration(
@UserSession() user: UserSessionData,
@Body() body: CreateVercelIntegrationRequestDto
): Promise {
return await this.createVercelIntegrationUsecase.execute(
CreateVercelIntegrationCommand.create({
vercelIntegrationCode: body.vercelIntegrationCode,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
configurationId: body.configurationId,
})
);
}
@Put('/vercel')
@RequireAuthentication()
@RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_WRITE)
async updateVercelIntegration(@UserSession() user: UserSessionData, @Body() body: UpdateVercelIntegrationRequestDto) {
return await this.updateVercelIntegrationUsecase.execute(
UpdateVercelIntegrationCommand.create({
data: body.data,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
configurationId: body.configurationId,
})
);
}
@Get('/vercel/:configurationId')
@RequireAuthentication()
@RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_READ)
async getVercelIntegration(@UserSession() user: UserSessionData, @Param('configurationId') configurationId: string) {
return await this.getVercelIntegrationUsecase.execute(
GetVercelIntegrationCommand.create({
userId: user._id,
configurationId,
environmentId: user.environmentId,
organizationId: user.organizationId,
})
);
}
@Get('/vercel/:configurationId/projects')
@RequireAuthentication()
@RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_READ)
async getVercelProjects(
@UserSession() user: UserSessionData,
@Param('configurationId') configurationId: string,
@Query('nextPage') nextPage?: string
) {
return await this.getVercelIntegrationProjectsUsecase.execute(
GetVercelIntegrationProjectsCommand.create({
configurationId,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
...(nextPage && { nextPage }),
})
);
}
@Post('/vercel/webhook')
async webhook(@Body() body: any, @Headers('x-vercel-signature') signatureHeader: string) {
return this.processVercelWebhookUsecase.execute(
ProcessVercelWebhookCommand.create({
body,
signatureHeader,
})
);
}
}
================================================
FILE: apps/api/src/app/partner-integrations/partner-integrations.module.ts
================================================
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { CommunityOrganizationRepository, CommunityUserRepository } from '@novu/dal';
import { BridgeModule } from '../bridge';
import { EnvironmentsModuleV1 } from '../environments-v1/environments-v1.module';
import { SharedModule } from '../shared/shared.module';
import { PartnerIntegrationsController } from './partner-integrations.controller';
import { USE_CASES } from './usecases';
@Module({
imports: [SharedModule, HttpModule, EnvironmentsModuleV1, BridgeModule],
providers: [...USE_CASES, CommunityUserRepository, CommunityOrganizationRepository],
controllers: [PartnerIntegrationsController],
})
export class PartnerIntegrationsModule {}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/create-vercel-integration/create-vercel-integration.command.ts
================================================
import { IsDefined } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class CreateVercelIntegrationCommand extends EnvironmentWithUserCommand {
@IsDefined()
vercelIntegrationCode: string;
@IsDefined()
configurationId: string;
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/create-vercel-integration/create-vercel-integration.spec.ts
================================================
import { HttpService } from '@nestjs/axios';
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AnalyticsService } from '@novu/application-generic';
import { OrganizationRepository, PartnerTypeEnum } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { of } from 'rxjs';
import { assert, match, restore, stub } from 'sinon';
import { CreateVercelIntegration } from './create-vercel-integration.usecase';
describe('CreateVercelIntegration', () => {
let createVercelIntegration: CreateVercelIntegration;
let session: UserSession;
let httpServiceMock;
let organizationRepositoryMock;
let analyticsServiceMock;
beforeEach(async () => {
httpServiceMock = {
post: stub().returns(
of({
data: {
access_token: 'test-token',
team_id: 'test-team-id',
},
})
),
};
organizationRepositoryMock = {
upsertPartnerConfiguration: stub().resolves(true),
};
analyticsServiceMock = {
track: stub().resolves(),
};
const moduleRef = await Test.createTestingModule({
providers: [
CreateVercelIntegration,
{
provide: HttpService,
useValue: httpServiceMock,
},
{
provide: OrganizationRepository,
useValue: organizationRepositoryMock,
},
{ provide: AnalyticsService, useValue: analyticsServiceMock },
],
}).compile();
session = new UserSession();
await session.initialize();
createVercelIntegration = moduleRef.get(CreateVercelIntegration);
// @ts-ignore
process.env.VERCEL_CLIENT_ID = 'test-client-id';
// @ts-ignore
process.env.VERCEL_CLIENT_SECRET = 'test-client-secret';
// @ts-ignore
process.env.VERCEL_REDIRECT_URI = 'test-redirect-uri';
// @ts-ignore
process.env.VERCEL_BASE_URL = 'https://api.vercel.com';
});
afterEach(() => {
restore();
});
it('should successfully set vercel configuration', async () => {
const command = {
organizationId: session.organization._id,
vercelIntegrationCode: 'test-code',
configurationId: 'test-config-id',
userId: session.user._id,
environmentId: session.environment._id,
};
const result = await createVercelIntegration.execute(command);
expect(result.success).to.equal(true);
// Verify HTTP call
assert.calledWith(
httpServiceMock.post,
'https://api.vercel.com/v2/oauth/access_token',
match.instanceOf(URLSearchParams),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
// Verify the URLSearchParams content
const postCall = httpServiceMock.post.getCall(0);
const [, postData] = postCall.args;
expect(postData.get('code')).to.equal('test-code');
expect(postData.get('client_id')).to.equal('test-client-id');
expect(postData.get('client_secret')).to.equal('test-client-secret');
expect(postData.get('redirect_uri')).to.equal('test-redirect-uri');
// Verify organization repository call
assert.calledWith(organizationRepositoryMock.upsertPartnerConfiguration, {
organizationId: command.organizationId,
configuration: {
accessToken: 'test-token',
configurationId: command.configurationId,
teamId: 'test-team-id',
partnerType: PartnerTypeEnum.VERCEL,
},
});
assert.calledWith(
analyticsServiceMock.track,
'Create Vercel Integration - [Partner Integrations]',
command.userId,
{ _organization: command.organizationId }
);
});
it('should throw BadRequestException when Vercel returns an error', async () => {
httpServiceMock.post.throws(new Error('Vercel error'));
try {
await createVercelIntegration.execute({
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
vercelIntegrationCode: 'test-code',
configurationId: 'test-config-id',
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Vercel error');
assert.notCalled(organizationRepositoryMock.upsertPartnerConfiguration);
}
});
});
================================================
FILE: apps/api/src/app/partner-integrations/usecases/create-vercel-integration/create-vercel-integration.usecase.ts
================================================
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AnalyticsService } from '@novu/application-generic';
import { OrganizationRepository, PartnerTypeEnum } from '@novu/dal';
import { lastValueFrom } from 'rxjs';
import { CreateVercelIntegrationResponseDto } from '../../dtos/create-vercel-integration-response.dto';
import { CreateVercelIntegrationCommand } from './create-vercel-integration.command';
@Injectable()
export class CreateVercelIntegration {
constructor(
private httpService: HttpService,
private organizationRepository: OrganizationRepository,
private analyticsService: AnalyticsService
) {}
async execute(command: CreateVercelIntegrationCommand): Promise {
try {
const tokenData = await this.getVercelToken(command.vercelIntegrationCode);
const configuration = {
accessToken: tokenData.accessToken,
configurationId: command.configurationId,
teamId: tokenData.teamId,
partnerType: PartnerTypeEnum.VERCEL,
};
await this.organizationRepository.upsertPartnerConfiguration({
organizationId: command.organizationId,
configuration,
});
this.analyticsService.track('Create Vercel Integration - [Partner Integrations]', command.userId, {
_organization: command.organizationId,
});
return {
success: true,
};
} catch (error) {
throw new BadRequestException(
error?.response?.data?.error_description || error?.response?.data?.message || error.message
);
}
}
private async getVercelToken(code: string): Promise<{
accessToken: string;
teamId: string;
}> {
try {
const postData = new URLSearchParams({
code: code as string,
client_id: process.env.VERCEL_CLIENT_ID as string,
client_secret: process.env.VERCEL_CLIENT_SECRET as string,
redirect_uri: process.env.VERCEL_REDIRECT_URI as string,
});
const response = await lastValueFrom(
this.httpService.post(`${process.env.VERCEL_BASE_URL}/v2/oauth/access_token`, postData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
const { data } = response;
return {
accessToken: data.access_token,
teamId: data.team_id,
};
} catch (error) {
throw new BadRequestException(
error?.response?.data?.error_description || error?.response?.data?.message || error.message
);
}
}
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-integration/get-vercel-integration.command.ts
================================================
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetVercelIntegrationCommand extends EnvironmentWithUserCommand {
configurationId: string;
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-integration/get-vercel-integration.spec.ts
================================================
import { Test } from '@nestjs/testing';
import { OrganizationRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { assert, restore, stub } from 'sinon';
import { GetVercelIntegration } from './get-vercel-integration.usecase';
describe('GetVercelIntegration', () => {
let getVercelIntegration: GetVercelIntegration;
let session: UserSession;
let organizationRepositoryMock;
beforeEach(async () => {
organizationRepositoryMock = {
findByPartnerConfigurationId: stub().resolves([
{
_id: 'org-id-1',
partnerConfigurations: [
{
projectIds: ['project-1', 'project-2'],
},
],
},
{
_id: 'org-id-2',
partnerConfigurations: [
{
projectIds: ['project-2', 'project-3'],
},
],
},
]),
};
const moduleRef = await Test.createTestingModule({
providers: [
GetVercelIntegration,
{
provide: OrganizationRepository,
useValue: organizationRepositoryMock,
},
],
}).compile();
session = new UserSession();
await session.initialize();
getVercelIntegration = moduleRef.get(GetVercelIntegration);
});
afterEach(() => {
restore();
});
it('should get vercel configuration details', async () => {
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
};
const result = await getVercelIntegration.execute(command);
expect(result).to.be.an('array');
expect(result[0]).to.deep.equal({
organizationId: 'org-id-1',
projectIds: ['project-1', 'project-2'],
});
expect(result[1]).to.deep.equal({
organizationId: 'org-id-2',
projectIds: ['project-2', 'project-3'],
});
assert.calledOnceWithExactly(organizationRepositoryMock.findByPartnerConfigurationId, {
userId: command.userId,
configurationId: command.configurationId,
});
});
it('should return empty array when no configurations found', async () => {
organizationRepositoryMock.findByPartnerConfigurationId.resolves([]);
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
};
const result = await getVercelIntegration.execute(command);
expect(result).to.be.an('array');
expect(result).to.have.length(0);
assert.calledOnceWithExactly(organizationRepositoryMock.findByPartnerConfigurationId, {
userId: command.userId,
configurationId: command.configurationId,
});
});
});
================================================
FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-integration/get-vercel-integration.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { GetVercelIntegrationCommand } from './get-vercel-integration.command';
@Injectable()
export class GetVercelIntegration {
constructor(private organizationRepository: OrganizationRepository) {}
async execute(command: GetVercelIntegrationCommand) {
return await this.getConfigurationDetails(command);
}
private async getConfigurationDetails(command: GetVercelIntegrationCommand) {
const details = await this.organizationRepository.findByPartnerConfigurationId({
userId: command.userId,
configurationId: command.configurationId,
});
return details.reduce(
(acc, curr) => {
if (
curr.partnerConfigurations &&
curr.partnerConfigurations.length >= 1 &&
curr.partnerConfigurations[0].projectIds &&
curr.partnerConfigurations[0].projectIds.length >= 1
) {
acc.push({
organizationId: curr._id,
projectIds: curr.partnerConfigurations[0].projectIds,
});
}
return acc;
},
[] as { organizationId: string; projectIds: string[] }[]
);
}
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-projects/get-vercel-integration-projects.command.ts
================================================
import { IsDefined, IsOptional, IsString } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class GetVercelIntegrationProjectsCommand extends EnvironmentWithUserCommand {
@IsDefined()
@IsString()
configurationId: string;
@IsOptional()
@IsString()
nextPage?: string;
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-projects/get-vercel-integration-projects.spec.ts
================================================
import { HttpService } from '@nestjs/axios';
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { OrganizationRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { of } from 'rxjs';
import { assert, restore, stub } from 'sinon';
import { GetVercelIntegrationProjects } from './get-vercel-integration-projects.usecase';
describe('GetVercelIntegrationProjects', () => {
let getVercelIntegrationProjects: GetVercelIntegrationProjects;
let session: UserSession;
let httpServiceMock;
let organizationRepositoryMock;
beforeEach(async () => {
httpServiceMock = {
get: stub().returns(
of({
data: {
projects: [
{ id: 'project-1', name: 'Project One' },
{ id: 'project-2', name: 'Project Two' },
],
pagination: {
next: 'next-page-token',
},
},
})
),
};
organizationRepositoryMock = {
findByPartnerConfigurationId: stub().resolves([
{
partnerConfigurations: [
{
configurationId: 'test-config-id',
accessToken: 'test-token',
teamId: 'test-team-id',
},
],
},
]),
};
const moduleRef = await Test.createTestingModule({
providers: [
GetVercelIntegrationProjects,
{
provide: HttpService,
useValue: httpServiceMock,
},
{
provide: OrganizationRepository,
useValue: organizationRepositoryMock,
},
],
}).compile();
session = new UserSession();
await session.initialize();
getVercelIntegrationProjects = moduleRef.get(GetVercelIntegrationProjects);
});
afterEach(() => {
restore();
});
it('should get vercel projects successfully', async () => {
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
};
const result = await getVercelIntegrationProjects.execute(command);
expect(result.projects).to.have.length(2);
expect(result.projects[0]).to.deep.equal({
name: 'Project One',
id: 'project-1',
});
expect(result.pagination).to.deep.equal({
next: 'next-page-token',
});
assert.calledWith(organizationRepositoryMock.findByPartnerConfigurationId, {
userId: command.userId,
configurationId: command.configurationId,
});
const expectedUrl = `${process.env.VERCEL_BASE_URL}/v10/projects?limit=100&teamId=test-team-id`;
assert.calledWith(httpServiceMock.get, expectedUrl, {
headers: {
Authorization: 'Bearer test-token',
},
});
});
it('should throw BadRequestException when no configuration found', async () => {
organizationRepositoryMock.findByPartnerConfigurationId.resolves([]);
try {
await getVercelIntegrationProjects.execute({
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('No partner configuration found.');
assert.notCalled(httpServiceMock.get);
}
});
it('should throw BadRequestException when HTTP request fails', async () => {
httpServiceMock.get.throws(new Error('HTTP Error'));
try {
await getVercelIntegrationProjects.execute({
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('HTTP Error');
assert.called(httpServiceMock.get);
}
});
});
================================================
FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-projects/get-vercel-integration-projects.usecase.ts
================================================
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Injectable } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { lastValueFrom } from 'rxjs';
import { GetVercelIntegrationProjectsCommand } from './get-vercel-integration-projects.command';
@Injectable()
export class GetVercelIntegrationProjects {
constructor(
private httpService: HttpService,
private organizationRepository: OrganizationRepository
) {}
async execute(command: GetVercelIntegrationProjectsCommand) {
try {
const configuration = await this.getCurrentOrgPartnerConfiguration({
userId: command.userId,
configurationId: command.configurationId,
});
if (!configuration || !configuration.accessToken) {
throw new BadRequestException({
message: 'No partner configuration found.',
type: 'vercel',
});
}
const projects = await this.getVercelProjects(configuration.accessToken, configuration.teamId, command.nextPage);
return projects;
} catch (error) {
throw new BadRequestException(error.message);
}
}
async getCurrentOrgPartnerConfiguration({ userId, configurationId }: { userId: string; configurationId: string }) {
const orgsWithIntegration = await this.organizationRepository.findByPartnerConfigurationId({
userId,
configurationId,
});
if (orgsWithIntegration.length === 0) {
throw new BadRequestException({
message: 'No partner configuration found.',
type: 'vercel',
});
}
const firstOrg = orgsWithIntegration[0];
const configuration = firstOrg.partnerConfigurations?.find((config) => config.configurationId === configurationId);
if (!firstOrg.partnerConfigurations?.length || !configuration) {
throw new BadRequestException({
message: 'No partner configuration found',
type: 'vercel',
});
}
return configuration;
}
private async getVercelProjects(accessToken: string, teamId: string | null, until?: string) {
const queryParams = new URLSearchParams();
queryParams.set('limit', '100');
if (teamId) {
queryParams.set('teamId', teamId);
}
if (until) {
queryParams.set('until', until);
}
const response = await lastValueFrom(
this.httpService.get(`${process.env.VERCEL_BASE_URL}/v10/projects?${queryParams.toString()}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
);
return { projects: this.mapProjects(response.data.projects), pagination: response.data.pagination };
}
private mapProjects(projects) {
return projects.map((project) => {
return {
name: project.name,
id: project.id,
};
});
}
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/index.ts
================================================
import { CreateVercelIntegration } from './create-vercel-integration/create-vercel-integration.usecase';
import { GetVercelIntegration } from './get-vercel-integration/get-vercel-integration.usecase';
import { GetVercelIntegrationProjects } from './get-vercel-projects/get-vercel-integration-projects.usecase';
import { ProcessVercelWebhook } from './process-vercel-webhook/process-vercel-webhook.usecase';
import { UpdateVercelIntegration } from './update-vercel-integration/update-vercel-integration.usecase';
export const USE_CASES = [
CreateVercelIntegration,
GetVercelIntegrationProjects,
GetVercelIntegration,
UpdateVercelIntegration,
ProcessVercelWebhook,
];
================================================
FILE: apps/api/src/app/partner-integrations/usecases/process-vercel-webhook/process-vercel-webhook.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { IsDefined } from 'class-validator';
export class ProcessVercelWebhookCommand extends BaseCommand {
@IsDefined()
signatureHeader: string;
@IsDefined()
body: any;
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/process-vercel-webhook/process-vercel-webhook.spec.ts
================================================
import crypto from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PinoLogger } from '@novu/application-generic';
import {
CommunityOrganizationRepository,
CommunityUserRepository,
EnvironmentRepository,
MemberRepository,
} from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { assert, restore, stub } from 'sinon';
import { Sync } from '../../../bridge/usecases/sync';
import { ProcessVercelWebhook } from './process-vercel-webhook.usecase';
describe('ProcessVercelWebhook', () => {
let processVercelWebhook: ProcessVercelWebhook;
let session: UserSession;
let organizationRepositoryMock;
let environmentRepositoryMock;
let memberRepositoryMock;
let communityUserRepositoryMock;
let syncUsecaseMock;
let loggerMock;
beforeEach(async () => {
organizationRepositoryMock = {
find: stub().resolves([{ _id: 'test-org-id' }]),
};
environmentRepositoryMock = {
findOne: stub().resolves({
_id: 'test-env-id',
_organizationId: 'test-org-id',
}),
};
memberRepositoryMock = {
getOrganizationOwnerAccount: stub().resolves({
_userId: 'test-user-id',
}),
};
communityUserRepositoryMock = {
findOne: stub().resolves({
_id: 'test-internal-user-id',
}),
};
syncUsecaseMock = {
execute: stub().resolves(true),
};
loggerMock = {
info: stub(),
error: stub(),
warn: stub(),
debug: stub(),
trace: stub(),
setContext: stub(),
};
const moduleRef = await Test.createTestingModule({
providers: [
ProcessVercelWebhook,
{
provide: CommunityOrganizationRepository,
useValue: organizationRepositoryMock,
},
{
provide: EnvironmentRepository,
useValue: environmentRepositoryMock,
},
{
provide: MemberRepository,
useValue: memberRepositoryMock,
},
{
provide: CommunityUserRepository,
useValue: communityUserRepositoryMock,
},
{
provide: Sync,
useValue: syncUsecaseMock,
},
{
provide: PinoLogger,
useValue: loggerMock,
},
],
}).compile();
// @ts-ignore
process.env.VERCEL_CLIENT_SECRET = 'test-secret';
session = new UserSession();
await session.initialize();
processVercelWebhook = moduleRef.get(ProcessVercelWebhook);
});
afterEach(() => {
restore();
});
it('should skip non-deployment events', async () => {
const result = await processVercelWebhook.execute({
body: {
type: 'other-event',
},
signatureHeader: 'test-signature',
});
expect(result).to.equal(true);
assert.notCalled(organizationRepositoryMock.find);
});
it('should process deployment succeeded event', async () => {
const body = {
type: 'deployment.succeeded',
payload: {
team: { id: 'team-id' },
project: { id: 'project-id' },
deployment: { url: 'test.vercel.app' },
target: 'production',
},
};
const hmac = crypto
.createHmac('sha1', process.env.VERCEL_CLIENT_SECRET ?? '')
.update(JSON.stringify(body))
.digest('hex');
const result = await processVercelWebhook.execute({
body,
signatureHeader: hmac,
});
expect(result).to.equal(true);
assert.calledWith(organizationRepositoryMock.find, {
'partnerConfigurations.teamId': 'team-id',
'partnerConfigurations.projectIds': 'project-id',
});
assert.calledWith(environmentRepositoryMock.findOne, {
_organizationId: 'test-org-id',
name: 'Production',
});
assert.calledWith(memberRepositoryMock.getOrganizationOwnerAccount, 'test-org-id');
assert.calledWith(communityUserRepositoryMock.findOne, {
externalId: 'test-user-id',
});
assert.calledWith(syncUsecaseMock.execute, {
organizationId: 'test-org-id',
userId: 'test-internal-user-id',
environmentId: 'test-env-id',
bridgeUrl: 'https://test.vercel.app/api/novu',
source: 'vercel',
});
});
it('should throw error for invalid signature', async () => {
const body = {
type: 'deployment.succeeded',
payload: {
team: { id: 'team-id' },
project: { id: 'project-id' },
deployment: { url: 'test.vercel.app' },
target: 'production',
},
};
try {
await processVercelWebhook.execute({
body,
signatureHeader: 'invalid-signature',
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Invalid signature');
assert.notCalled(organizationRepositoryMock.find);
}
});
it('should throw error for missing signature', async () => {
const body = {
type: 'deployment.succeeded',
payload: {
team: { id: 'team-id' },
project: { id: 'project-id' },
deployment: { url: 'test.vercel.app' },
target: 'production',
},
};
try {
await processVercelWebhook.execute({
body,
signatureHeader: '',
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Missing signature or secret');
assert.notCalled(organizationRepositoryMock.find);
}
});
});
================================================
FILE: apps/api/src/app/partner-integrations/usecases/process-vercel-webhook/process-vercel-webhook.usecase.ts
================================================
import crypto from 'node:crypto';
import { BadRequestException, HttpException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { PinoLogger } from '@novu/application-generic';
import {
CommunityOrganizationRepository,
CommunityUserRepository,
EnvironmentEntity,
EnvironmentRepository,
MemberRepository,
} from '@novu/dal';
import { Sync } from '../../../bridge/usecases/sync';
import { ProcessVercelWebhookCommand } from './process-vercel-webhook.command';
@Injectable()
export class ProcessVercelWebhook {
constructor(
private organizationRepository: CommunityOrganizationRepository,
private environmentRepository: EnvironmentRepository,
private syncUsecase: Sync,
private memberRepository: MemberRepository,
private communityUserRepository: CommunityUserRepository,
private logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
}
async execute(command: ProcessVercelWebhookCommand) {
const eventType = command.body.type;
if (eventType !== 'deployment.succeeded') {
this.logger.info(`Skipping processing Vercel webhook event: ${eventType}`);
return true;
}
this.verifySignature(command.signatureHeader, command.body);
const payload = command.body.payload;
if (!payload?.team?.id || !payload?.project?.id || !payload?.deployment?.url) {
throw new BadRequestException('Invalid webhook payload: missing required fields');
}
const teamId = payload.team.id;
const projectId = payload.project.id;
const deploymentUrl = payload.deployment.url;
const vercelEnvironment = payload.target || 'preview';
this.logger.info(
{
teamId,
projectId,
vercelEnvironment,
deploymentUrl,
},
`Processing vercel webhook for ${vercelEnvironment}`
);
const organizations = await this.organizationRepository.find(
{
'partnerConfigurations.teamId': teamId,
'partnerConfigurations.projectIds': projectId,
},
{ 'partnerConfigurations.$': 1 }
);
if (!organizations || organizations.length === 0) {
throw new BadRequestException('Organization not found for vercel webhook integration');
}
for (const organization of organizations) {
let environment: EnvironmentEntity | null;
// TODO: we should think about how to handle different Vercel environments that are not production or development
if (vercelEnvironment === 'production') {
environment = await this.environmentRepository.findOne({
_organizationId: organization._id,
name: 'Production',
});
} else {
environment = await this.environmentRepository.findOne({
_organizationId: organization._id,
name: 'Development',
});
}
if (!environment) {
throw new BadRequestException('Environment Not Found');
}
try {
const orgOwner = await this.memberRepository.getOrganizationOwnerAccount(environment._organizationId);
if (!orgOwner) {
throw new BadRequestException('Organization owner not found');
}
const internalUser = await this.communityUserRepository.findOne({ externalId: orgOwner?._userId });
if (!internalUser) {
throw new BadRequestException('User not found');
}
await this.syncUsecase.execute({
organizationId: environment._organizationId,
userId: internalUser?._id as string,
environmentId: environment._id,
bridgeUrl: `https://${deploymentUrl}/api/novu`,
source: 'vercel',
});
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
this.logger.error(
{
err: error,
organizationId: organization._id,
teamId,
projectId,
},
'Failed to process Vercel webhook for organization'
);
throw new InternalServerErrorException(
`Failed to process Vercel webhook: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
return true;
}
private verifySignature(signature: string, body: any): void {
const secret = process.env.VERCEL_CLIENT_SECRET;
if (!signature || !secret) {
throw new BadRequestException('Missing signature or secret');
}
const computedSignature = crypto.createHmac('sha1', secret).update(JSON.stringify(body)).digest('hex');
if (signature !== computedSignature) {
throw new BadRequestException('Invalid signature');
}
}
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/update-vercel-integration/update-vercel-integration.command.ts
================================================
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
export class UpdateVercelIntegrationCommand extends EnvironmentWithUserCommand {
data: Record;
configurationId: string;
}
================================================
FILE: apps/api/src/app/partner-integrations/usecases/update-vercel-integration/update-vercel-integration.spec.ts
================================================
import { HttpService } from '@nestjs/axios';
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AnalyticsService, PinoLogger } from '@novu/application-generic';
import { CommunityUserRepository, EnvironmentRepository, MemberRepository, OrganizationRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { of } from 'rxjs';
import { assert, restore, stub } from 'sinon';
import { Sync } from '../../../bridge/usecases/sync';
import { UpdateVercelIntegration } from './update-vercel-integration.usecase';
describe('UpdateVercelIntegration', () => {
let updateVercelIntegration: UpdateVercelIntegration;
let session: UserSession;
let httpServiceMock;
let environmentRepositoryMock;
let organizationRepositoryMock;
let analyticsServiceMock;
let syncMock;
let memberRepositoryMock;
let communityUserRepositoryMock;
let loggerMock;
beforeEach(async () => {
// @ts-ignore
process.env.VERCEL_BASE_URL = 'https://api.vercel.com';
httpServiceMock = {
get: stub().callsFake((url, config) => {
if (url.includes('/v4/projects') && url.includes('teamId=test-team-id')) {
return of({
data: {
projects: [
{
id: 'project-1',
env: [
{ id: 'env-1', key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', target: ['production'] },
{ id: 'env-2', key: 'NOVU_CLIENT_APP_ID', target: ['production'] },
{ id: 'env-3', key: 'NOVU_SECRET_KEY', target: ['production'] },
{ id: 'env-4', key: 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER', target: ['production'] },
],
},
],
},
});
} else if (url.includes('/v9/projects/project-1') && url.includes('teamId=test-team-id')) {
return of({
data: {
targets: {
production: {
alias: ['prod-alias.vercel.app'],
},
development: {
alias: ['dev-alias.vercel.app'],
},
},
},
});
}
// Default response for any other URLs
return of({ data: {} });
}),
post: stub().returns(of({ data: { success: true } })),
delete: stub().returns(of({ data: { success: true } })),
};
organizationRepositoryMock = {
findByPartnerConfigurationId: stub().resolves([
{
partnerConfigurations: [
{
configurationId: 'test-config-id',
accessToken: 'test-token',
teamId: 'test-team-id',
projectIds: ['project-1'],
},
],
},
]),
bulkUpdatePartnerConfiguration: stub().resolves(true),
};
analyticsServiceMock = {
track: stub().resolves(),
};
syncMock = {
execute: stub().resolves(),
};
environmentRepositoryMock = {
find: stub().resolves([
{
_id: 'env-id',
name: 'Production',
identifier: 'prod',
_organizationId: 'org-id',
apiKeys: [{ key: 'encrypted-key' }],
},
{
_id: 'env-id-2',
name: 'Development',
identifier: 'dev',
_organizationId: 'org-id',
apiKeys: [{ key: 'encrypted-key-2' }],
},
]),
};
memberRepositoryMock = {
getOrganizationOwnerAccount: stub().resolves({ _userId: 'admin-id' }),
};
communityUserRepositoryMock = {
findOne: stub().resolves({ _id: 'internal-user-id' }),
};
loggerMock = {
log: stub(),
error: stub(),
warn: stub(),
debug: stub(),
info: stub(),
trace: stub(),
setContext: stub(),
};
const moduleRef = await Test.createTestingModule({
providers: [
UpdateVercelIntegration,
{ provide: HttpService, useValue: httpServiceMock },
{ provide: EnvironmentRepository, useValue: environmentRepositoryMock },
{ provide: OrganizationRepository, useValue: organizationRepositoryMock },
{ provide: AnalyticsService, useValue: analyticsServiceMock },
{ provide: Sync, useValue: syncMock },
{ provide: MemberRepository, useValue: memberRepositoryMock },
{ provide: CommunityUserRepository, useValue: communityUserRepositoryMock },
{ provide: PinoLogger, useValue: loggerMock },
],
}).compile();
session = new UserSession();
await session.initialize();
updateVercelIntegration = moduleRef.get(UpdateVercelIntegration);
});
afterEach(() => {
restore();
});
it('should update vercel configuration successfully', async () => {
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {
'org-id': ['project-1'],
},
};
const result = await updateVercelIntegration.execute(command);
expect(result.success).to.equal(true);
// Verify existing projects lookup
assert.calledWith(organizationRepositoryMock.findByPartnerConfigurationId, {
userId: command.userId,
configurationId: command.configurationId,
});
// Verify project environment variables lookup
assert.calledWith(httpServiceMock.get, `${process.env.VERCEL_BASE_URL}/v4/projects?teamId=test-team-id`, {
headers: {
Authorization: 'Bearer test-token',
},
});
// Verify environment variable deletion calls
assert.calledWith(
httpServiceMock.delete,
`${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-1?teamId=test-team-id`,
{
headers: {
Authorization: 'Bearer test-token',
},
}
);
assert.calledWith(
httpServiceMock.delete,
`${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-2?teamId=test-team-id`,
{
headers: {
Authorization: 'Bearer test-token',
},
}
);
assert.calledWith(
httpServiceMock.delete,
`${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-3?teamId=test-team-id`,
{
headers: {
Authorization: 'Bearer test-token',
},
}
);
assert.calledWith(
httpServiceMock.delete,
`${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-4?teamId=test-team-id`,
{
headers: {
Authorization: 'Bearer test-token',
},
}
);
assert.calledWith(organizationRepositoryMock.bulkUpdatePartnerConfiguration, {
userId: command.userId,
data: command.data,
configuration: {
configurationId: 'test-config-id',
accessToken: 'test-token',
teamId: 'test-team-id',
projectIds: ['project-1'],
},
});
// Verify environment repository calls
assert.calledWith(environmentRepositoryMock.find, {
_organizationId: { $in: ['org-id'] },
});
// Verify environment variables setup
assert.calledWith(
httpServiceMock.post,
'https://api.vercel.com/v10/projects/project-1/env?upsert=true&teamId=test-team-id',
[
{
target: ['production'],
type: 'encrypted',
value: 'prod',
key: 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER',
},
],
{
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
}
);
// Verify bridge URL update
assert.calledWith(httpServiceMock.get, 'https://api.vercel.com/v9/projects/project-1?teamId=test-team-id', {
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
});
// Verify sync execution
assert.calledWith(syncMock.execute, {
organizationId: 'org-id',
userId: 'internal-user-id',
environmentId: 'env-id',
bridgeUrl: 'https://prod-alias.vercel.app/api/novu',
source: 'vercel',
});
// Verify analytics
assert.calledWith(
analyticsServiceMock.track,
'Update Vercel Integration - [Partner Integrations]',
command.userId,
{ _organization: command.organizationId }
);
});
it('should handle projects with no environment variables', async () => {
// Reset the stub before creating a new behavior
httpServiceMock.get.reset();
httpServiceMock.get.callsFake((url) => {
if (url.includes('/v4/projects')) {
return of({
data: {
projects: [
{
id: 'project-1',
env: [], // Empty env array
},
],
},
});
}
return of({ data: {} });
});
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {
'org-id': ['project-1'],
},
};
const result = await updateVercelIntegration.execute(command);
expect(result.success).to.equal(true);
assert.notCalled(httpServiceMock.delete);
});
it('should handle projects with missing Novu environment variables', async () => {
// Reset the stub before creating a new behavior
httpServiceMock.get.reset();
httpServiceMock.get.callsFake((url) => {
if (url.includes('/v4/projects')) {
return of({
data: {
projects: [
{
id: 'project-1',
env: [{ id: 'env-1', key: 'OTHER_ENV_VAR' }], // Only non-Novu env var
},
],
},
});
}
return of({ data: {} });
});
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {
'org-id': ['project-1'],
},
};
const result = await updateVercelIntegration.execute(command);
expect(result.success).to.equal(true);
assert.notCalled(httpServiceMock.delete);
});
it('should throw BadRequestException when configuration not found', async () => {
organizationRepositoryMock.findByPartnerConfigurationId.resolves([]);
try {
await updateVercelIntegration.execute({
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {},
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('No partner configuration found.');
assert.notCalled(httpServiceMock.get);
assert.notCalled(httpServiceMock.delete);
}
});
it('should handle errors during project fetch', async () => {
httpServiceMock.get.throws(new Error('HTTP Error'));
try {
await updateVercelIntegration.execute({
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {
'org-id': ['project-1'],
},
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('HTTP Error');
assert.notCalled(httpServiceMock.delete);
}
});
it('should handle errors during environment variable deletion', async () => {
httpServiceMock.delete.onCall(0).throws(new Error('Delete Error'));
try {
await updateVercelIntegration.execute({
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {
'org-id': ['project-1'],
},
});
throw new Error('Should not reach here');
} catch (error) {
expect(error).to.be.instanceOf(BadRequestException);
expect(error.message).to.equal('Delete Error');
assert.called(httpServiceMock.get);
assert.called(httpServiceMock.delete);
}
});
it('should handle multiple projects with environment variables', async () => {
// Reset the stub before creating a new behavior
httpServiceMock.get.reset();
httpServiceMock.get.callsFake((url) => {
if (url.includes('/v4/projects')) {
return of({
data: {
projects: [
{
id: 'project-1',
env: [{ id: 'env-1', key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', target: ['production'] }],
},
{
id: 'project-2',
env: [{ id: 'env-2', key: 'NOVU_SECRET_KEY', target: ['production'] }],
},
],
},
});
} else if (url.includes('/v9/projects/')) {
return of({
data: {
targets: {
production: {
alias: ['prod-alias.vercel.app'],
},
development: {
alias: ['dev-alias.vercel.app'],
},
},
},
});
}
return of({ data: {} });
});
organizationRepositoryMock.findByPartnerConfigurationId.resolves([
{
partnerConfigurations: [{ configurationId: 'test-config-id', projectIds: ['project-1', 'project-2'] }],
},
]);
const command = {
userId: session.user._id,
organizationId: session.organization._id,
environmentId: session.environment._id,
configurationId: 'test-config-id',
data: {
'org-id': ['project-1', 'project-2'],
},
};
const result = await updateVercelIntegration.execute(command);
expect(result.success).to.equal(true);
assert.calledTwice(httpServiceMock.delete);
});
});
================================================
FILE: apps/api/src/app/partner-integrations/usecases/update-vercel-integration/update-vercel-integration.usecase.ts
================================================
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AnalyticsService, decryptApiKey, PinoLogger } from '@novu/application-generic';
import {
CommunityUserRepository,
EnvironmentEntity,
EnvironmentRepository,
MemberRepository,
OrganizationRepository,
} from '@novu/dal';
import { lastValueFrom } from 'rxjs';
import { Sync } from '../../../bridge/usecases/sync';
import { UpdateVercelIntegrationCommand } from './update-vercel-integration.command';
interface ISetEnvironment {
name: string;
token: string;
projectIds: string[];
teamId: string | null;
applicationIdentifier: string;
privateKey: string;
}
interface IRemoveEnvironment {
token: string;
teamId: string | null;
userId: string;
configurationId: string;
}
type ProjectDetails = {
projectId: string;
clientAppIdEnv?: string;
secretKeyEnv?: string;
nextClientAppIdEnv?: string;
nextApplicationIdentifierEnv?: string;
};
@Injectable()
export class UpdateVercelIntegration {
constructor(
private httpService: HttpService,
private organizationRepository: OrganizationRepository,
private memberRepository: MemberRepository,
private communityUserRepository: CommunityUserRepository,
private environmentRepository: EnvironmentRepository,
private syncUsecase: Sync,
private analyticsService: AnalyticsService,
private logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
}
async execute(command: UpdateVercelIntegrationCommand): Promise<{ success: boolean }> {
try {
const { userId, organizationId, configurationId, data: orgIdsToProjectIds } = command;
const configuration = await this.getCurrentOrgPartnerConfiguration({
userId,
configurationId,
});
await this.removeEnvVariablesFromProjects({
teamId: configuration.teamId,
token: configuration.accessToken,
userId,
configurationId,
});
await this.organizationRepository.bulkUpdatePartnerConfiguration({
userId,
data: orgIdsToProjectIds,
configuration,
});
const organizationIds = Object.keys(orgIdsToProjectIds);
const environments = await this.getEnvironments(organizationIds);
for (const env of environments) {
const projectIds = orgIdsToProjectIds[env._organizationId];
await this.setEnvVariablesOnProjects({
name: env.name,
applicationIdentifier: env.identifier,
privateKey: decryptApiKey(env.apiKeys[0].key),
projectIds,
teamId: configuration.teamId,
token: configuration.accessToken,
});
try {
await this.updateBridgeUrl(
env._id,
env.name,
projectIds[0],
configuration.accessToken,
configuration.teamId,
env._organizationId
);
} catch (error) {
this.logger.error({ err: error }, 'Error updating bridge url');
}
}
this.analyticsService.track('Update Vercel Integration - [Partner Integrations]', userId, {
_organization: organizationId,
});
return { success: true };
} catch (error) {
throw new BadRequestException(error.message);
}
}
private async updateBridgeUrl(
environmentId: string,
environmentName: string,
projectId: string,
accessToken: string,
teamId: string,
organizationId: string
) {
try {
const getDomainsResponse = await lastValueFrom(
this.httpService.get(`${process.env.VERCEL_BASE_URL}/v9/projects/${projectId}?teamId=${teamId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
);
const vercelAvailableTargets = getDomainsResponse.data?.targets;
let vercelTarget;
if (environmentName.toLowerCase() === 'production') {
vercelTarget = vercelAvailableTargets?.production;
} else {
vercelTarget = vercelAvailableTargets?.development;
}
const alias = vercelTarget?.alias?.sort((a, b) => a.length - b.length)[0];
const bridgeAlias = alias || vercelTarget?.meta?.branchAlias || vercelTarget?.automaticAliases[0];
if (!bridgeAlias) {
return;
}
const orgOwner = await this.memberRepository.getOrganizationOwnerAccount(organizationId);
if (!orgOwner) {
throw new BadRequestException('Organization owner not found');
}
const internalUser = await this.communityUserRepository.findOne({ externalId: orgOwner?._userId });
if (!internalUser) {
throw new BadRequestException('User not found');
}
await this.syncUsecase.execute({
organizationId,
userId: internalUser?._id as string,
environmentId,
bridgeUrl: `https://${bridgeAlias}/api/novu`,
source: 'vercel',
});
} catch (error) {
this.logger.error({ err: error }, 'Error updating bridge url');
}
}
private async getEnvironments(organizationIds: string[]): Promise {
return await this.environmentRepository.find(
{
_organizationId: { $in: organizationIds },
},
'apiKeys identifier name _organizationId _id'
);
}
private async setEnvVariablesOnProjects({
name,
applicationIdentifier,
projectIds,
privateKey,
teamId,
token,
}: ISetEnvironment): Promise {
const target = name?.toLowerCase() === 'production' ? ['production'] : ['preview', 'development'];
const type = 'encrypted';
const environmentVariables = [
{
target,
type,
value: applicationIdentifier,
key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID',
legacy: true,
},
{
target,
type,
value: applicationIdentifier,
key: 'NOVU_CLIENT_APP_ID',
legacy: true,
},
{
target,
type,
value: applicationIdentifier,
key: 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER',
},
{
target,
type,
value: privateKey,
key: 'NOVU_SECRET_KEY',
},
];
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
const setEnvVariable = async (projectId: string, variable: (typeof environmentVariables)[0]) => {
if (variable.legacy) {
return;
}
try {
const queryParams = new URLSearchParams();
queryParams.set('upsert', 'true');
if (teamId) {
queryParams.set('teamId', teamId);
}
await lastValueFrom(
this.httpService.post(
`${process.env.VERCEL_BASE_URL}/v10/projects/${projectId}/env?${queryParams.toString()}`,
[variable],
{ headers }
)
);
} catch (error) {
throw new BadRequestException(error.response?.data?.error || error.response?.data);
}
};
await Promise.all(
projectIds.flatMap((projectId) => environmentVariables.map((variable) => setEnvVariable(projectId, variable)))
);
}
async getCurrentOrgPartnerConfiguration({ userId, configurationId }: { userId: string; configurationId: string }) {
const orgsWithIntegration = await this.organizationRepository.findByPartnerConfigurationId({
userId,
configurationId,
});
if (orgsWithIntegration.length === 0) {
throw new BadRequestException({
message: 'No partner configuration found.',
type: 'vercel',
});
}
const firstOrg = orgsWithIntegration[0];
const configuration = firstOrg.partnerConfigurations?.find((config) => config.configurationId === configurationId);
if (!firstOrg.partnerConfigurations?.length || !configuration) {
throw new BadRequestException({
message: 'No partner configuration found.',
type: 'vercel',
});
}
return configuration;
}
private async getVercelLinkedProjects(
accessToken: string,
teamId: string | null,
projectIds: string[]
): Promise {
const response = await lastValueFrom(
this.httpService.get(`${process.env.VERCEL_BASE_URL}/v4/projects${teamId ? `?teamId=${teamId}` : ''}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
);
const vercelProjects = response.data.projects as any[];
const filteredVercelProjects = vercelProjects.filter((project) => projectIds.includes(project.id));
return ['production', 'development'].flatMap((vercelEnvironment) =>
filteredVercelProjects.map((project) => {
const { id } = project;
const vercelEnvs = project?.env;
const nextApplicationIdentifierEnv = vercelEnvs?.find(
(e) => e.key === 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER' && e.target.includes(vercelEnvironment)
);
// Legacy env variable for existing Vercel integrations
const nextClientAppIdEnv = vercelEnvs?.find(
(e) => e.key === 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID' && e.target.includes(vercelEnvironment)
);
// Legacy env variable for existing Vercel integrations
const clientAppIdEnv = vercelEnvs?.find(
(e) => e.key === 'NOVU_CLIENT_APP_ID' && e.target.includes(vercelEnvironment)
);
const secretKeyEnv = vercelEnvs?.find(
(e) => e.key === 'NOVU_SECRET_KEY' && e.target.includes(vercelEnvironment)
);
return {
projectId: id,
clientAppIdEnv: clientAppIdEnv?.id,
secretKeyEnv: secretKeyEnv?.id,
nextClientAppIdEnv: nextClientAppIdEnv?.id,
nextApplicationIdentifierEnv: nextApplicationIdentifierEnv?.id,
};
})
);
}
private async removeEnvVariablesFromProjects({
teamId,
token,
userId,
configurationId,
}: IRemoveEnvironment): Promise {
const orgsWithIntegration = await this.organizationRepository.findByPartnerConfigurationId({
userId,
configurationId,
});
const allOldProjectIds = [
...new Set(
orgsWithIntegration.reduce((acc, org) => {
return acc.concat(org.partnerConfigurations?.[0].projectIds || []);
}, [])
),
];
if (allOldProjectIds.length === 0) {
return;
}
const vercelLinkedProjects = await this.getVercelLinkedProjects(token, teamId, allOldProjectIds);
const projectApiUrl = `${process.env.VERCEL_BASE_URL}/v9/projects`;
await Promise.all(
vercelLinkedProjects.map((detail) => {
const urls: string[] = [];
if (detail.nextApplicationIdentifierEnv) {
urls.push(
`${projectApiUrl}/${detail.projectId}/env/${detail.nextApplicationIdentifierEnv}${teamId ? `?teamId=${teamId}` : ''}`
);
}
if (detail.nextClientAppIdEnv) {
urls.push(
`${projectApiUrl}/${detail.projectId}/env/${detail.nextClientAppIdEnv}${teamId ? `?teamId=${teamId}` : ''}`
);
}
if (detail.clientAppIdEnv) {
urls.push(
`${projectApiUrl}/${detail.projectId}/env/${detail.clientAppIdEnv}${teamId ? `?teamId=${teamId}` : ''}`
);
}
if (detail.secretKeyEnv) {
urls.push(
`${projectApiUrl}/${detail.projectId}/env/${detail.secretKeyEnv}${teamId ? `?teamId=${teamId}` : ''}`
);
}
const requests = urls.map((url) =>
lastValueFrom(
this.httpService.delete(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
)
);
return Promise.all(requests);
})
);
}
}
================================================
FILE: apps/api/src/app/preferences/dtos/preferences.dto.ts
================================================
import { ChannelTypeEnum } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsBoolean, ValidateNested } from 'class-validator';
/**
* @deprecated Use an updated preference structure.
* This class will be removed in future versions.
*/
export class WorkflowPreference {
/**
* @deprecated Use alternative enablement mechanism.
*/
@IsBoolean()
enabled: boolean;
/**
* @deprecated Read-only flag is no longer supported.
*/
@IsBoolean()
readOnly: boolean;
}
/**
* @deprecated Use an updated channel preference structure.
* Will be removed in future versions.
*/
export class ChannelPreference {
/**
* @deprecated Use alternative channel enablement method.
*/
@IsBoolean()
enabled: boolean;
}
/**
* @deprecated Channels configuration is being restructured.
* Use the new channel management approach.
*/
export class Channels {
/**
* @deprecated In-app channel preference is deprecated.
*/
@ValidateNested({ each: true })
@Type(() => ChannelPreference)
[ChannelTypeEnum.IN_APP]: ChannelPreference;
/**
* @deprecated Email channel preference is deprecated.
*/
@ValidateNested({ each: true })
@Type(() => ChannelPreference)
[ChannelTypeEnum.EMAIL]: ChannelPreference;
/**
* @deprecated SMS channel preference is deprecated.
*/
@ValidateNested({ each: true })
@Type(() => ChannelPreference)
[ChannelTypeEnum.SMS]: ChannelPreference;
/**
* @deprecated Chat channel preference is deprecated.
*/
@ValidateNested({ each: true })
@Type(() => ChannelPreference)
[ChannelTypeEnum.CHAT]: ChannelPreference;
/**
* @deprecated Push channel preference is deprecated.
*/
@ValidateNested({ each: true })
@Type(() => ChannelPreference)
[ChannelTypeEnum.PUSH]: ChannelPreference;
}
/**
* @deprecated Preferences DTO is being replaced.
* Use the new preferences management approach.
*/
export class PreferencesDto {
/**
* @deprecated Global workflow preference is no longer used.
*/
@ValidateNested({ each: true })
@Type(() => WorkflowPreference)
all: WorkflowPreference;
/**
* @deprecated Channels configuration is deprecated.
*/
@ValidateNested({ each: true })
@Type(() => Channels)
channels: Channels;
}
// Optional: Runtime deprecation warning
if (process.env.NODE_ENV !== 'production' && !process.env.CI) {
console.warn(
'DEPRECATION WARNING: PreferencesDto and related classes are deprecated ' +
'and will be removed in future versions. Please migrate to the new preferences structure.'
);
}
================================================
FILE: apps/api/src/app/preferences/dtos/upsert-preferences.dto.ts
================================================
import { Type } from 'class-transformer';
import { IsString, ValidateNested } from 'class-validator';
import { PreferencesDto } from './preferences.dto';
/**
* @deprecated This DTO is no longer recommended for use.
* Consider using an alternative implementation or updated data transfer object.
*/
export class UpsertPreferencesDto {
/**
* @deprecated Use an alternative workflow identification method.
*/
@IsString()
workflowId: string;
/**
* @deprecated Preferences structure is outdated.
*/
@ValidateNested({ each: true })
@Type(() => PreferencesDto)
preferences: PreferencesDto;
}
================================================
FILE: apps/api/src/app/preferences/index.ts
================================================
export { PreferencesModule } from './preferences.module';
================================================
FILE: apps/api/src/app/preferences/preferences.controller.ts
================================================
import {
Body,
ClassSerializerInterceptor,
Controller,
Delete,
Get,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import {
DeletePreferencesCommand,
DeletePreferencesUseCase,
GetPreferences,
GetPreferencesCommand,
UpsertPreferences,
UpsertUserWorkflowPreferencesCommand,
UserSession,
} from '@novu/application-generic';
import { PreferencesTypeEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { UpsertPreferencesDto } from './dtos/upsert-preferences.dto';
/**
* @deprecated - set workflow preferences using the `/workflows` endpoint instead
*/
@Controller('/preferences')
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
@ApiExcludeController()
export class PreferencesController {
constructor(
private upsertPreferences: UpsertPreferences,
private getPreferences: GetPreferences,
private deletePreferences: DeletePreferencesUseCase
) {}
@Get('/')
async get(@UserSession() user: UserSessionData, @Query('workflowId') workflowId: string) {
return this.getPreferences.execute(
GetPreferencesCommand.create({
templateId: workflowId,
environmentId: user.environmentId,
organizationId: user.organizationId,
})
);
}
@Post('/')
async upsert(@Body() data: UpsertPreferencesDto, @UserSession() user: UserSessionData) {
return this.upsertPreferences.upsertUserWorkflowPreferences(
UpsertUserWorkflowPreferencesCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
preferences: data.preferences,
templateId: data.workflowId,
})
);
}
@Delete('/')
async delete(@UserSession() user: UserSessionData, @Query('workflowId') workflowId: string) {
return this.deletePreferences.execute(
DeletePreferencesCommand.create({
templateId: workflowId,
environmentId: user.environmentId,
organizationId: user.organizationId,
userId: user._id,
type: PreferencesTypeEnum.USER_WORKFLOW,
})
);
}
}
================================================
FILE: apps/api/src/app/preferences/preferences.module.ts
================================================
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { DeletePreferencesUseCase, GetPreferences, UpsertPreferences } from '@novu/application-generic';
import { PreferencesRepository } from '@novu/dal';
import { SharedModule } from '../shared/shared.module';
import { PreferencesController } from './preferences.controller';
const PROVIDERS = [PreferencesRepository, UpsertPreferences, GetPreferences, DeletePreferencesUseCase];
@Module({
imports: [SharedModule],
providers: [...PROVIDERS],
controllers: [PreferencesController],
exports: [...PROVIDERS],
})
export class PreferencesModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {}
}
================================================
FILE: apps/api/src/app/preferences/preferences.spec.ts
================================================
import { Test } from '@nestjs/testing';
import {
GetPreferences,
UpsertPreferences,
UpsertSubscriberGlobalPreferencesCommand,
UpsertSubscriberWorkflowPreferencesCommand,
UpsertUserWorkflowPreferencesCommand,
UpsertWorkflowPreferencesCommand,
} from '@novu/application-generic';
import { PreferencesRepository, SubscriberRepository } from '@novu/dal';
import { FeatureFlagsKeysEnum, PreferencesTypeEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { AuthModule } from '../auth/auth.module';
import { PreferencesModule } from './preferences.module';
describe('Preferences', () => {
let getPreferences: GetPreferences;
const subscriberId = SubscriberRepository.createObjectId();
const workflowId = PreferencesRepository.createObjectId();
let upsertPreferences: UpsertPreferences;
let session: UserSession;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [PreferencesModule, AuthModule],
providers: [],
}).compile();
session = new UserSession();
await session.initialize();
getPreferences = moduleRef.get(GetPreferences);
upsertPreferences = moduleRef.get(UpsertPreferences);
});
describe('Upsert preferences', () => {
it('should create workflow preferences', async () => {
const workflowPreferences = await upsertPreferences.upsertWorkflowPreferences(
UpsertWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
})
);
expect(workflowPreferences._environmentId).to.equal(session.environment._id);
expect(workflowPreferences._organizationId).to.equal(session.organization._id);
expect(workflowPreferences._templateId).to.equal(workflowId);
expect(workflowPreferences._userId).to.be.undefined;
expect(workflowPreferences._subscriberId).to.be.undefined;
expect(workflowPreferences.type).to.equal(PreferencesTypeEnum.WORKFLOW_RESOURCE);
});
it('should create user workflow preferences', async () => {
const userPreferences = await upsertPreferences.upsertUserWorkflowPreferences(
UpsertUserWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
userId: session.user._id,
})
);
expect(userPreferences._environmentId).to.equal(session.environment._id);
expect(userPreferences._organizationId).to.equal(session.organization._id);
expect(userPreferences._templateId).to.equal(workflowId);
expect(userPreferences._userId).to.equal(session.user._id);
expect(userPreferences._subscriberId).to.be.undefined;
expect(userPreferences.type).to.equal(PreferencesTypeEnum.USER_WORKFLOW);
});
it('should create global subscriber preferences', async () => {
const subscriberGlobalPreferences = await upsertPreferences.upsertSubscriberGlobalPreferences(
UpsertSubscriberGlobalPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
_subscriberId: subscriberId,
})
);
expect(subscriberGlobalPreferences._environmentId).to.equal(session.environment._id);
expect(subscriberGlobalPreferences._organizationId).to.equal(session.organization._id);
expect(subscriberGlobalPreferences._templateId).to.be.undefined;
expect(subscriberGlobalPreferences._userId).to.be.undefined;
expect(subscriberGlobalPreferences._subscriberId).to.equal(subscriberId);
expect(subscriberGlobalPreferences.type).to.equal(PreferencesTypeEnum.SUBSCRIBER_GLOBAL);
});
it('should create subscriber workflow preferences', async () => {
const subscriberWorkflowPreferences = await upsertPreferences.upsertSubscriberWorkflowPreferences(
UpsertSubscriberWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
_subscriberId: subscriberId,
})
);
expect(subscriberWorkflowPreferences._environmentId).to.equal(session.environment._id);
expect(subscriberWorkflowPreferences._organizationId).to.equal(session.organization._id);
expect(subscriberWorkflowPreferences._templateId).to.equal(workflowId);
expect(subscriberWorkflowPreferences._userId).to.be.undefined;
expect(subscriberWorkflowPreferences._subscriberId).to.equal(subscriberId);
expect(subscriberWorkflowPreferences.type).to.equal(PreferencesTypeEnum.SUBSCRIBER_WORKFLOW);
});
it('should update preferences', async () => {
let workflowPreferences = await upsertPreferences.upsertWorkflowPreferences(
UpsertWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
})
);
expect(workflowPreferences._environmentId).to.equal(session.environment._id);
expect(workflowPreferences._organizationId).to.equal(session.organization._id);
expect(workflowPreferences._templateId).to.equal(workflowId);
expect(workflowPreferences._userId).to.be.undefined;
expect(workflowPreferences._subscriberId).to.be.undefined;
expect(workflowPreferences.type).to.equal(PreferencesTypeEnum.WORKFLOW_RESOURCE);
workflowPreferences = await upsertPreferences.upsertWorkflowPreferences(
UpsertWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
})
);
expect(workflowPreferences.preferences.all.readOnly).to.be.true;
});
});
describe('Get preferences', () => {
it('should merge preferences when get preferences', async () => {
// Workflow preferences
await upsertPreferences.upsertWorkflowPreferences(
UpsertWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
})
);
let preferences = await getPreferences.execute({
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
});
expect(preferences).to.deep.equal({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
schedule: undefined,
type: PreferencesTypeEnum.WORKFLOW_RESOURCE,
source: {
[PreferencesTypeEnum.WORKFLOW_RESOURCE]: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.USER_WORKFLOW]: null,
[PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null,
[PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null,
},
});
// User Workflow preferences
await upsertPreferences.upsertUserWorkflowPreferences(
UpsertUserWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
userId: session.user._id,
})
);
preferences = await getPreferences.execute({
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
});
expect(preferences).to.deep.equal({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
schedule: undefined,
type: PreferencesTypeEnum.USER_WORKFLOW,
source: {
[PreferencesTypeEnum.WORKFLOW_RESOURCE]: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.USER_WORKFLOW]: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null,
[PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null,
},
});
// Subscriber global preferences
await upsertPreferences.upsertSubscriberGlobalPreferences(
UpsertSubscriberGlobalPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
_subscriberId: subscriberId,
})
);
preferences = await getPreferences.execute({
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
subscriberId,
});
expect(preferences).to.deep.equal({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
schedule: undefined,
type: PreferencesTypeEnum.USER_WORKFLOW,
source: {
[PreferencesTypeEnum.WORKFLOW_RESOURCE]: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.USER_WORKFLOW]: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null,
},
});
// Subscriber Workflow preferences
await upsertPreferences.upsertSubscriberWorkflowPreferences(
UpsertSubscriberWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
_subscriberId: subscriberId,
})
);
preferences = await getPreferences.execute({
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
subscriberId,
});
expect(preferences).to.deep.equal({
preferences: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
schedule: undefined,
type: PreferencesTypeEnum.USER_WORKFLOW,
source: {
[PreferencesTypeEnum.WORKFLOW_RESOURCE]: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.USER_WORKFLOW]: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: {
all: {
enabled: false,
readOnly: true,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
},
});
});
});
describe('Preferences endpoints', () => {
it('should get preferences', async () => {
const useCase: UpsertPreferences = session.testServer?.getService(UpsertPreferences);
await useCase.upsertWorkflowPreferences(
UpsertWorkflowPreferencesCommand.create({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
environmentId: session.environment._id,
organizationId: session.organization._id,
templateId: workflowId,
})
);
const { body } = await session.testAgent.get(`/v1/preferences?workflowId=${workflowId}`).send();
expect(body.data).to.deep.equal({
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
type: PreferencesTypeEnum.WORKFLOW_RESOURCE,
source: {
[PreferencesTypeEnum.WORKFLOW_RESOURCE]: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
[PreferencesTypeEnum.USER_WORKFLOW]: null,
[PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null,
[PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null,
},
});
});
it('should upsert preferences', async () => {
const { body } = await session.testAgent.post('/v1/preferences').send({
workflowId,
preferences: {
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
},
});
expect(body.data.preferences).to.deep.equal({
all: {
enabled: false,
readOnly: false,
},
channels: {
in_app: {
enabled: false,
},
sms: {
enabled: false,
},
email: {
enabled: false,
},
push: {
enabled: false,
},
chat: {
enabled: false,
},
},
});
});
});
});
================================================
FILE: apps/api/src/app/rate-limiting/e2e/throttler.guard.e2e.ts
================================================
import { HttpResponseHeaderKeysEnum } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ApiServiceLevelEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
const mockSingleCost = 1;
const mockBulkCost = 5;
const mockWindowDuration = 5;
const mockBurstAllowance = 1;
const mockMaximumFreeTrigger = 5;
const mockMaximumFreeGlobal = 3;
const mockMaximumUnlimitedTrigger = 10;
const mockMaximumUnlimitedGlobal = 5;
process.env.API_RATE_LIMIT_COST_SINGLE = `${mockSingleCost}`;
process.env.API_RATE_LIMIT_COST_BULK = `${mockBulkCost}`;
process.env.API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION = `${mockWindowDuration}`;
process.env.API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE = `${mockBurstAllowance}`;
process.env.API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER = `${mockMaximumFreeTrigger}`;
process.env.API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL = `${mockMaximumFreeGlobal}`;
process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER = `${mockMaximumUnlimitedTrigger}`;
process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL = `${mockMaximumUnlimitedGlobal}`;
// Disable Launch Darkly to allow test to define FF state
(process.env as Record).LAUNCH_DARKLY_SDK_KEY = '';
describe('API Rate Limiting #novu-v2', () => {
let session: UserSession;
const pathPrefix = '/v1/rate-limiting';
let request: (
path: string,
authHeader?: string
) => Promise>>;
describe('Guard logic', () => {
beforeEach(async () => {
(process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'true';
session = new UserSession();
await session.initialize();
await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED);
request = (path: string, authHeader = `ApiKey ${session.apiKey}`) =>
session.testAgent.get(path).set('authorization', authHeader);
});
describe('Feature Flag', () => {
it('should set rate limit headers when the Feature Flag is enabled', async () => {
(process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'true';
const response = await request(`${pathPrefix}/no-category-no-cost`);
expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.exist;
});
it('should NOT set rate limit headers when the Feature Flag is disabled', async () => {
(process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'false';
const response = await request(`${pathPrefix}/no-category-no-cost`);
expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist;
});
});
describe('Allowed Authentication Security Schemes', () => {
it('should set rate limit headers when ApiKey security scheme is used to authenticate', async () => {
const response = await request(`${pathPrefix}/no-category-no-cost`, `ApiKey ${session.apiKey}`);
expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.exist;
});
it('should NOT set rate limit headers when a Bearer security scheme is used to authenticate', async () => {
const response = await request(`${pathPrefix}/no-category-no-cost`, session.token);
expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist;
});
it('should NOT set rate limit headers when NO authorization header is present', async () => {
const response = await request(`${pathPrefix}/no-category-no-cost`, '');
expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist;
});
});
describe('RateLimit-Policy', () => {
const testParams: Array<{ name: string; expectedRegex: string }> = [
{ name: 'limit', expectedRegex: `${mockMaximumUnlimitedGlobal * mockWindowDuration}` },
{ name: 'w', expectedRegex: `w=${mockWindowDuration}` },
{
name: 'burst',
expectedRegex: `burst=${mockMaximumUnlimitedGlobal * (1 + mockBurstAllowance) * mockWindowDuration}`,
},
{ name: 'comment', expectedRegex: `comment="[a-zA-Z ]*"` },
{ name: 'category', expectedRegex: `category="(${Object.values(ApiRateLimitCategoryEnum).join('|')})"` },
{ name: 'cost', expectedRegex: `cost="(${Object.values(ApiRateLimitCostEnum).join('|')})"` },
{
name: 'serviceLevel',
expectedRegex: `serviceLevel="[a-zA-Z]*"`,
},
];
testParams.forEach(({ name, expectedRegex }) => {
it(`should include the ${name} parameter`, async () => {
const response = await request(`${pathPrefix}/no-category-no-cost`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.match(new RegExp(expectedRegex));
});
});
it('should separate the params with a semicolon', async () => {
const response = await request(`${pathPrefix}/no-category-no-cost`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader.split(';')).to.have.lengthOf(testParams.length);
});
});
describe('Rate Limit Decorators', () => {
describe('Controller WITHOUT Decorators', () => {
const controllerPathPrefix = '/v1/rate-limiting';
it('should use the global category for an endpoint WITHOUT category decorator', async () => {
const response = await request(`${controllerPathPrefix}/no-category-no-cost`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`);
});
it('should use the single cost for an endpoint WITHOUT cost decorator', async () => {
const response = await request(`${controllerPathPrefix}/no-category-no-cost`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`);
});
});
describe('Controller WITH Decorators', () => {
const controllerPathPrefix = '/v1/rate-limiting-trigger-bulk';
it('should use the category decorator defined on the controller for an endpoint WITHOUT category decorator', async () => {
const response = await request(`${controllerPathPrefix}/no-category-no-cost-override`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.TRIGGER}"`);
});
it('should use the cost decorator defined on the controller for an endpoint WITHOUT cost decorator', async () => {
const response = await request(`${controllerPathPrefix}/no-category-no-cost-override`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.BULK}"`);
});
it('should override the cost decorator defined on the controller for an endpoint WITH cost decorator', async () => {
const response = await request(`${controllerPathPrefix}/no-category-single-cost-override`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`);
});
it('should override the category decorator defined on the controller for an endpoint WITH category decorator', async () => {
const response = await request(`${controllerPathPrefix}/global-category-no-cost-override`);
const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()];
expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`);
});
});
});
});
describe('Scenarios', () => {
type TestCase = {
name: string;
requests: { path: string; count: number }[];
expectedStatus: number;
expectedLimit: number;
expectedCost: number;
expectedReset: number;
expectedRetryAfter?: number;
expectedThrottledRequests: number;
setupTest?: (userSession: UserSession) => Promise;
};
const testCases: TestCase[] = [
{
name: 'single trigger endpoint request',
requests: [{ path: '/trigger-category-single-cost', count: 1 }],
expectedStatus: 200,
expectedLimit: mockMaximumUnlimitedTrigger,
expectedCost: mockSingleCost * 1,
expectedReset: 1,
expectedThrottledRequests: 0,
async setupTest(userSession) {
await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED);
},
},
{
name: 'no category no cost endpoint request',
requests: [{ path: '/no-category-no-cost', count: 1 }],
expectedStatus: 200,
expectedLimit: mockMaximumUnlimitedGlobal,
expectedCost: mockSingleCost * 1,
expectedReset: 1,
expectedThrottledRequests: 0,
async setupTest(userSession) {
await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED);
},
},
{
name: 'single trigger request with service level specified on organization ',
requests: [{ path: '/trigger-category-single-cost', count: 1 }],
expectedStatus: 200,
expectedLimit: mockMaximumFreeTrigger,
expectedCost: mockSingleCost * 1,
expectedReset: 1,
expectedThrottledRequests: 0,
async setupTest(userSession) {
await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE);
},
},
{
name: 'single trigger request with maximum rate limit specified on environment',
requests: [{ path: '/trigger-category-single-cost', count: 1 }],
expectedStatus: 200,
expectedLimit: 60,
expectedCost: mockSingleCost * 1,
expectedReset: 1,
expectedThrottledRequests: 0,
async setupTest(userSession) {
await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED);
await userSession.updateEnvironmentApiRateLimits({ [ApiRateLimitCategoryEnum.TRIGGER]: 60 });
},
},
{
name: 'combination of single trigger and single global endpoint request',
requests: [
{ path: '/trigger-category-single-cost', count: 20 },
{ path: '/global-category-single-cost', count: 100 },
],
expectedStatus: 429,
expectedLimit: mockMaximumUnlimitedGlobal,
expectedCost: mockSingleCost * 100,
expectedReset: 1,
expectedRetryAfter: 1,
expectedThrottledRequests: 50,
async setupTest(userSession) {
await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED);
},
},
];
testCases
.map(
({
name,
requests,
expectedStatus,
expectedLimit,
expectedCost,
expectedReset,
expectedRetryAfter,
expectedThrottledRequests,
setupTest,
}) => {
return () => {
describe(`${expectedStatus === 429 ? 'Throttled' : 'Allowed'} ${name}`, () => {
let lastResponse;
let throttledResponseCount = 0;
const throttledResponseCountTolerance = 0.5;
const expectedWindowLimit = expectedLimit * mockWindowDuration;
const expectedBurstLimit = expectedWindowLimit * (1 + mockBurstAllowance);
const expectedRemaining = Math.max(0, expectedBurstLimit - expectedCost);
before(async () => {
(process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'true';
session = new UserSession();
await session.initialize();
request = (path: string, authHeader = `ApiKey ${session.apiKey}`) =>
session.testAgent.get(path).set('authorization', authHeader);
setupTest && (await setupTest(session));
for (const { path, count } of requests) {
for (let index = 0; index < count; index += 1) {
const response = await request(pathPrefix + path);
lastResponse = response;
if (response.statusCode === 429) {
throttledResponseCount += 1;
}
}
}
});
it(`should return a ${expectedStatus} status code`, async () => {
expect(lastResponse.statusCode).to.equal(expectedStatus);
});
it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT} header of ${expectedWindowLimit}`, async () => {
expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.equal(
`${expectedWindowLimit}`
);
});
it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING} header of ${expectedRemaining}`, async () => {
expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING.toLowerCase()]).to.equal(
`${expectedRemaining}`
);
});
it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_RESET} header of ${expectedReset}`, async () => {
expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_RESET.toLowerCase()]).to.equal(
`${expectedReset}`
);
});
it(`should return a ${HttpResponseHeaderKeysEnum.RETRY_AFTER} header of ${expectedRetryAfter}`, async () => {
expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase()]).to.equal(
expectedRetryAfter && `${expectedRetryAfter}`
);
});
const expectedMinThrottled = Math.floor(
expectedThrottledRequests * (1 - throttledResponseCountTolerance)
);
const expectedMaxThrottled = Math.ceil(expectedThrottledRequests * (1 + throttledResponseCountTolerance));
it(`should have between ${expectedMinThrottled} and ${expectedMaxThrottled} requests throttled`, async () => {
expect(throttledResponseCount).to.be.greaterThanOrEqual(expectedMinThrottled);
expect(throttledResponseCount).to.be.lessThanOrEqual(expectedMaxThrottled);
});
});
};
}
)
.forEach((testCase) => {
testCase();
});
});
});
================================================
FILE: apps/api/src/app/rate-limiting/guards/index.ts
================================================
export * from './throttler.decorator';
export * from './throttler.guard';
================================================
FILE: apps/api/src/app/rate-limiting/guards/throttler.decorator.ts
================================================
import { Reflector } from '@nestjs/core';
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared';
export const ThrottlerCategory = Reflector.createDecorator();
export const ThrottlerCost = Reflector.createDecorator();
================================================
FILE: apps/api/src/app/rate-limiting/guards/throttler.guard.ts
================================================
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
InjectThrottlerOptions,
InjectThrottlerStorage,
ThrottlerException,
ThrottlerGuard,
ThrottlerModuleOptions,
ThrottlerRequest,
ThrottlerStorage,
} from '@nestjs/throttler';
import {
FeatureFlagsService,
HttpRequestHeaderKeysEnum,
HttpResponseHeaderKeysEnum,
Instrument,
PinoLogger,
} from '@novu/application-generic';
import { EnvironmentEntity, OrganizationEntity, UserEntity } from '@novu/dal';
import {
ApiAuthSchemeEnum,
ApiRateLimitCategoryEnum,
ApiRateLimitCostEnum,
FeatureFlagsKeysEnum,
UserSessionData,
} from '@novu/shared';
import { getClientIp } from 'request-ip';
import { checkIsKeylessHeader } from '../../shared/utils/auth.utils';
import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from '../usecases/evaluate-api-rate-limit';
import { ThrottlerCategory, ThrottlerCost } from './throttler.decorator';
export const THROTTLED_EXCEPTION_MESSAGE = 'API rate limit exceeded';
export const ALLOWED_AUTH_SCHEMES = [ApiAuthSchemeEnum.API_KEY, ApiAuthSchemeEnum.KEYLESS];
const defaultApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
const defaultApiRateLimitCost = ApiRateLimitCostEnum.SINGLE;
/**
* An interceptor is used instead of a guard to ensure that Auth context is available.
* This is currently necessary because we do not currently have a global guard configured for Auth,
* therefore the Auth context is not guaranteed to be available in the guard.
*/
@Injectable()
export class ApiRateLimitInterceptor extends ThrottlerGuard implements NestInterceptor {
constructor(
@InjectThrottlerOptions() protected readonly options: ThrottlerModuleOptions,
@InjectThrottlerStorage() protected readonly storageService: ThrottlerStorage,
reflector: Reflector,
private evaluateApiRateLimit: EvaluateApiRateLimit,
private featureFlagService: FeatureFlagsService,
private logger: PinoLogger
) {
super(options, storageService, reflector);
this.logger.setContext(this.constructor.name);
}
/**
* Thin wrapper around the ThrottlerGuard's canActivate method.
*/
async intercept(context: ExecutionContext, next: CallHandler) {
await this.canActivate(context);
return next.handle();
}
@Instrument()
canActivate(context: ExecutionContext): Promise {
return super.canActivate(context);
}
protected async shouldSkip(context: ExecutionContext): Promise {
const req = context.switchToHttp().getRequest();
const isAllowedAuthScheme = this.isAllowedAuthScheme(context);
const isAllowedEnvironment = this.isAllowedEnvironment(context);
const isAllowedRoute = this.isAllowedRoute(context);
if (!isAllowedAuthScheme && !isAllowedEnvironment && !isAllowedRoute) {
this.logger.debug(
{
_nv: {
isAllowedAuthScheme,
isAllowedEnvironment,
isAllowedRoute,
path: req.path,
authScheme: req.authScheme,
},
},
'Rate limiting skipped - request criteria not met'
);
return true;
}
const user = this.getReqUser(context);
// Indicates whether the request originates from a Inbox session initialization
if (!user) {
return false;
}
const { organizationId, environmentId, _id } = user;
const isEnabled = await this.featureFlagService.getFlag({
key: FeatureFlagsKeysEnum.IS_API_RATE_LIMITING_ENABLED,
defaultValue: false,
environment: { _id: environmentId } as EnvironmentEntity,
organization: { _id: organizationId } as OrganizationEntity,
user: { _id } as UserEntity,
});
if (!isEnabled) {
this.logger.debug({
message: 'Rate limiting skipped - feature flag disabled',
_event: {
organizationId,
environmentId,
},
});
}
return !isEnabled;
}
/**
* Throttles incoming HTTP requests.
* All the outgoing requests will contain RFC-compatible RateLimit headers.
* @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
* @throws {ThrottlerException}
*/
protected async handleRequest({ context, throttler }: ThrottlerRequest): Promise {
const { req, res } = this.getRequestResponse(context);
const clientIp = getClientIp(req) || undefined;
const ignoreUserAgents = throttler.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents;
// Return early if the current user agent should be ignored.
if (Array.isArray(ignoreUserAgents)) {
for (const pattern of ignoreUserAgents) {
if (pattern.test(req.headers[HttpRequestHeaderKeysEnum.USER_AGENT.toLowerCase()])) {
return true;
}
}
}
const handler = context.getHandler();
const classRef = context.getClass();
const isKeylessHeader =
checkIsKeylessHeader(req.headers.authorization) ||
checkIsKeylessHeader(req.headers['novu-application-identifier']);
const isKeylessRequest = isKeylessHeader || this.isKeylessRoute(context);
const apiRateLimitCategory =
this.reflector.getAllAndOverride(ThrottlerCategory, [handler, classRef]) || defaultApiRateLimitCategory;
const user = this.getReqUser(context);
const organizationId = user?.organizationId;
const _id = user?._id;
const environmentId = user?.environmentId || req.headers['novu-application-identifier'];
const apiRateLimitCost = isKeylessRequest
? getKeylessCost()
: this.reflector.getAllAndOverride(ThrottlerCost, [handler, classRef]) || defaultApiRateLimitCost;
const evaluateCommand = EvaluateApiRateLimitCommand.create({
organizationId,
environmentId,
apiRateLimitCategory,
apiRateLimitCost,
ip: isKeylessRequest ? clientIp : undefined,
});
const { success, limit, remaining, reset, windowDuration, burstLimit, algorithm, apiServiceLevel } =
await this.evaluateApiRateLimit.execute(evaluateCommand);
const secondsToReset = Math.max(Math.ceil((reset - Date.now()) / 1e3), 0);
this.logger.debug({
message: 'Rate limit evaluated',
_event: {
success,
limit,
remaining,
category: apiRateLimitCategory,
cost: apiRateLimitCost,
isKeyless: isKeylessRequest,
organizationId,
environmentId,
ip: clientIp,
},
});
/**
* The purpose of the dry run is to allow us to observe how
* the rate limiting would behave without actually enforcing it.
*/
const isDryRun = await this.featureFlagService.getFlag({
environment: { _id: environmentId } as EnvironmentEntity,
organization: { _id: organizationId } as OrganizationEntity,
user: { _id } as UserEntity,
key: FeatureFlagsKeysEnum.IS_API_RATE_LIMITING_DRY_RUN_ENABLED,
defaultValue: false,
});
const isKeylessDryRunFlag = await this.featureFlagService.getFlag({
environment: { _id: environmentId } as EnvironmentEntity,
organization: { _id: organizationId } as OrganizationEntity,
user: { _id, email: user?.email } as UserEntity,
key: FeatureFlagsKeysEnum.IS_API_RATE_LIMITING_KEYLESS_DRY_RUN_ENABLED,
defaultValue: false,
});
const isKeylessDryRun = isKeylessRequest && isKeylessDryRunFlag;
res.header(HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING, remaining);
res.header(HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT, limit);
res.header(HttpResponseHeaderKeysEnum.RATELIMIT_RESET, secondsToReset);
res.header(
HttpResponseHeaderKeysEnum.RATELIMIT_POLICY,
this.createPolicyHeader(
limit,
windowDuration,
burstLimit,
algorithm,
apiRateLimitCategory,
apiRateLimitCost,
apiServiceLevel
)
);
res.rateLimitPolicy = {
limit,
windowDuration,
burstLimit,
algorithm,
apiRateLimitCategory,
apiRateLimitCost,
apiServiceLevel,
};
if (isDryRun || isKeylessDryRun) {
if (!success) {
this.logger.warn({
message: `${isKeylessRequest ? '[Dry run] [Keyless]' : '[Dry run]'} Rate limit would be exceeded`,
_event: {
limit,
remaining,
organizationId,
environmentId,
ip: clientIp,
},
});
}
return true;
}
if (success) {
return true;
} else {
res.header(HttpResponseHeaderKeysEnum.RETRY_AFTER, secondsToReset);
this.logger.debug({
message: 'Rate limit exceeded',
_event: {
limit,
remaining,
retryAfter: secondsToReset,
category: apiRateLimitCategory,
organizationId,
environmentId,
ip: clientIp,
isKeyless: isKeylessRequest,
},
});
throw new ThrottlerException(THROTTLED_EXCEPTION_MESSAGE);
}
}
private createPolicyHeader(
limit: number,
windowDuration: number,
burstLimit: number,
algorithm: string,
apiRateLimitCategory: ApiRateLimitCategoryEnum,
apiRateLimitCost: ApiRateLimitCostEnum,
apiServiceLevel: string
): string {
const policyMap = {
w: windowDuration,
burst: burstLimit,
comment: `"${algorithm}"`,
category: `"${apiRateLimitCategory}"`,
cost: `"${apiRateLimitCost}"`,
serviceLevel: `"${apiServiceLevel}"`,
};
const policy = Object.entries(policyMap).reduce((acc, [key, value]) => {
return `${acc};${key}=${value}`;
}, `${limit}`);
return policy;
}
private isAllowedAuthScheme(context: ExecutionContext): boolean {
const { authScheme } = context.switchToHttp().getRequest();
return ALLOWED_AUTH_SCHEMES.some((scheme) => authScheme === scheme);
}
private isAllowedEnvironment(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const applicationIdentifier = req.headers['novu-application-identifier'];
if (!applicationIdentifier) {
return false;
}
return applicationIdentifier.startsWith('pk_keyless_');
}
private isAllowedRoute(context: ExecutionContext): boolean {
return this.isKeylessRoute(context);
}
private isKeylessRoute(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
return req.path === '/v1/inbox/session' && req.method === 'POST';
}
private getReqUser(context: ExecutionContext): UserSessionData | undefined {
const req = context.switchToHttp().getRequest();
return req.user;
}
}
function getKeylessCost() {
// For test environment, we use a higher cost to ensure tests can run without rate limiting issues
return process.env.NODE_ENV === 'test' ? defaultApiRateLimitCost : ApiRateLimitCostEnum.KEYLESS;
}
================================================
FILE: apps/api/src/app/rate-limiting/rate-limiting.module.ts
================================================
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { CommunityOrganizationRepository } from '@novu/dal';
import { SharedModule } from '../shared/shared.module';
import { ApiRateLimitInterceptor } from './guards';
import { USE_CASES } from './usecases';
@Module({
imports: [
SharedModule,
ThrottlerModule.forRoot([
// The following configuration is required for the NestJS ThrottlerModule to work. It has no effect.
{
ttl: 60000,
limit: 10,
},
]),
],
providers: [...USE_CASES, ApiRateLimitInterceptor, CommunityOrganizationRepository],
exports: [...USE_CASES, ApiRateLimitInterceptor],
})
export class RateLimitingModule {}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared';
import { IsDefined, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class EvaluateApiRateLimitCommand extends BaseCommand {
@IsOptional()
@IsString()
readonly environmentId?: string;
@IsOptional()
@IsString()
readonly organizationId?: string;
@IsDefined()
@IsEnum(ApiRateLimitCategoryEnum)
apiRateLimitCategory: ApiRateLimitCategoryEnum;
@IsDefined()
@IsEnum(ApiRateLimitCostEnum)
apiRateLimitCost: ApiRateLimitCostEnum;
@IsOptional()
@IsString()
ip?: string;
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts
================================================
import { Test } from '@nestjs/testing';
import {
ApiRateLimitAlgorithmEnum,
ApiRateLimitCategoryEnum,
ApiRateLimitCostEnum,
ApiServiceLevelEnum,
IApiRateLimitAlgorithm,
IApiRateLimitCost,
} from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import sinon from 'sinon';
import { SharedModule } from '../../../shared/shared.module';
import { RateLimitingModule } from '../../rate-limiting.module';
import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit';
import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config';
import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config';
import { GetApiRateLimitMaximum } from '../get-api-rate-limit-maximum';
import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from './index';
const mockApiRateLimitAlgorithm: IApiRateLimitAlgorithm = {
[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: 0.2,
[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: 2,
};
const mockApiRateLimitCost = ApiRateLimitCostEnum.SINGLE;
const mockApiServiceLevel = ApiServiceLevelEnum.FREE;
const mockCost = 1;
const mockApiRateLimitCostConfig: Partial = {
[mockApiRateLimitCost]: mockCost,
};
const mockMaxLimit = 10;
const mockRemaining = 9;
const mockReset = 1;
const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
describe('EvaluateApiRateLimit', async () => {
let useCase: EvaluateApiRateLimit;
let session: UserSession;
let getApiRateLimitMaximum: GetApiRateLimitMaximum;
let getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig;
let getApiRateLimitCostConfig: GetApiRateLimitCostConfig;
let evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit;
let getApiRateLimitMaximumStub: sinon.SinonStub;
let getApiRateLimitAlgorithmConfigStub: sinon.SinonStub;
let getApiRateLimitCostConfigStub: sinon.SinonStub;
let evaluateTokenBucketRateLimitStub: sinon.SinonStub;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [SharedModule, RateLimitingModule],
}).compile();
session = new UserSession();
await session.initialize();
useCase = moduleRef.get(EvaluateApiRateLimit);
getApiRateLimitMaximum = moduleRef.get(GetApiRateLimitMaximum);
getApiRateLimitAlgorithmConfig = moduleRef.get(GetApiRateLimitAlgorithmConfig);
getApiRateLimitCostConfig = moduleRef.get(GetApiRateLimitCostConfig);
evaluateTokenBucketRateLimit = moduleRef.get(EvaluateTokenBucketRateLimit);
getApiRateLimitMaximumStub = sinon
.stub(getApiRateLimitMaximum, 'execute')
.resolves([mockMaxLimit, mockApiServiceLevel]);
getApiRateLimitAlgorithmConfigStub = sinon
.stub(getApiRateLimitAlgorithmConfig, 'default')
.value(mockApiRateLimitAlgorithm);
getApiRateLimitCostConfigStub = sinon.stub(getApiRateLimitCostConfig, 'default').value(mockApiRateLimitCostConfig);
evaluateTokenBucketRateLimitStub = sinon.stub(evaluateTokenBucketRateLimit, 'execute').resolves({
success: true,
limit: mockMaxLimit,
remaining: mockRemaining,
reset: mockReset,
});
});
afterEach(() => {
getApiRateLimitMaximumStub.restore();
getApiRateLimitAlgorithmConfigStub.restore();
getApiRateLimitCostConfigStub.restore();
});
describe('Evaluation Values', () => {
it('should return a boolean success value', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(typeof result.success).to.equal('boolean');
});
it('should return a positive limit', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.limit).to.be.greaterThan(0);
});
it('should return a positive remaining tokens ', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.remaining).to.be.greaterThan(0);
});
it('should return a positive reset', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.reset).to.be.greaterThan(0);
});
});
describe('Static Values', () => {
it('should return a string type algorithm value', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(typeof result.algorithm).to.equal('string');
});
it('should return the correct window duration', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.windowDuration).to.equal(mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]);
});
});
describe('Computed Values', () => {
it('should return the correct cost', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.cost).to.equal(mockApiRateLimitCostConfig[mockApiRateLimitCost]);
});
it('should return the correct refill rate', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.refillRate).to.equal(
mockMaxLimit * mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]
);
});
it('should return the correct burst limit', async () => {
const result = await useCase.execute(
EvaluateApiRateLimitCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
apiRateLimitCost: mockApiRateLimitCost,
})
);
expect(result.burstLimit).to.equal(
mockMaxLimit *
mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] *
(1 + mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE])
);
});
});
});
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts
================================================
export type EvaluateApiRateLimitResponseDto = {
/**
* Whether the request may pass(true) or exceeded the limit(false)
*/
success: boolean;
/**
* Maximum number of requests allowed within a window.
*/
limit: number;
/**
* How many requests the client has left within the current window.
*/
remaining: number;
/**
* Unix timestamp in milliseconds when the limits are reset.
*/
reset: number;
/**
* The duration of the window in seconds.
*/
windowDuration: number;
/**
* The maximum number of requests allowed within a window, including the burst allowance.
*/
burstLimit: number;
/**
* The number of requests that will be refilled per window.
*/
refillRate: number;
/**
* The name of the algorithm used to calculate the rate limit.
*/
algorithm: string;
/**
* The cost of the request.
*/
cost: number;
/**
* The API service level used to evaluate the request.
*/
apiServiceLevel: string;
};
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import { buildEvaluateApiRateLimitKey, InstrumentUsecase } from '@novu/application-generic';
import {
ApiRateLimitAlgorithmEnum,
ApiServiceLevelEnum,
FeatureNameEnum,
getFeatureForTierAsNumber,
} from '@novu/shared';
import { EvaluateTokenBucketRateLimitCommand } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command';
import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase';
import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config';
import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config';
import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from '../get-api-rate-limit-maximum';
import type { ApiServiceLevel } from '../get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto';
import { EvaluateApiRateLimitCommand } from './evaluate-api-rate-limit.command';
import { EvaluateApiRateLimitResponseDto } from './evaluate-api-rate-limit.types';
@Injectable()
export class EvaluateApiRateLimit {
constructor(
private getApiRateLimitMaximum: GetApiRateLimitMaximum,
private getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig,
private getApiRateLimitCostConfig: GetApiRateLimitCostConfig,
private evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit
) {}
@InstrumentUsecase()
async execute(command: EvaluateApiRateLimitCommand): Promise {
let maxLimitPerSecond: number;
let apiServiceLevel: ApiServiceLevel;
// For keyless environments, we implement strict rate limiting to prevent abuse:
if (!command.organizationId || !command.environmentId) {
maxLimitPerSecond = 3000;
apiServiceLevel = ApiServiceLevelEnum.ENTERPRISE;
} else {
[maxLimitPerSecond, apiServiceLevel] = await this.getApiRateLimitMaximum.execute(
GetApiRateLimitMaximumCommand.create({
apiRateLimitCategory: command.apiRateLimitCategory,
environmentId: command.environmentId,
organizationId: command.organizationId,
})
);
}
const windowDuration = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.WINDOW_DURATION];
const burstAllowance = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE];
const cost = this.getApiRateLimitCostConfig.default[command.apiRateLimitCost];
const maxTokensPerWindow = this.getMaxTokensPerWindow(maxLimitPerSecond, windowDuration);
const refillRate = this.getRefillRate(maxLimitPerSecond, windowDuration);
const burstLimit = this.getBurstLimit(maxTokensPerWindow, burstAllowance);
// For keyless authentication, we'll use both environment and IP-based rate limiting
const identifier = buildEvaluateApiRateLimitKey({
_environmentId: command.environmentId || 'keyless_env',
apiRateLimitCategory: command.ip
? `${command.apiRateLimitCategory}:ip=${command.ip}`
: command.apiRateLimitCategory,
});
const { success, remaining, reset } = await this.evaluateTokenBucketRateLimit.execute(
EvaluateTokenBucketRateLimitCommand.create({
identifier,
maxTokens: burstLimit,
windowDuration,
cost,
refillRate,
})
);
return {
success,
limit: maxTokensPerWindow,
remaining,
reset,
windowDuration,
burstLimit,
refillRate,
algorithm: this.evaluateTokenBucketRateLimit.algorithm,
cost,
apiServiceLevel,
};
}
private getMaxTokensPerWindow(maxLimit: number, windowDuration: number): number {
return maxLimit * windowDuration;
}
private getRefillRate(maxLimit: number, windowDuration: number): number {
/*
* Refill rate is currently set to the max tokens per window.
* This can be changed to a different value to implement adaptive rate limiting.
*/
return this.getMaxTokensPerWindow(maxLimit, windowDuration);
}
private getBurstLimit(maxTokensPerWindow: number, burstAllowance: number): number {
return Math.floor(maxTokensPerWindow * (1 + burstAllowance));
}
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts
================================================
export * from './evaluate-api-rate-limit.command';
export * from './evaluate-api-rate-limit.types';
export * from './evaluate-api-rate-limit.usecase';
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { IsDefined, IsNumber, IsString } from 'class-validator';
export class EvaluateTokenBucketRateLimitCommand extends BaseCommand {
@IsDefined()
@IsString()
identifier: string;
@IsDefined()
@IsNumber()
maxTokens: number;
@IsDefined()
@IsNumber()
windowDuration: number;
@IsDefined()
@IsNumber()
cost: number;
@IsDefined()
@IsNumber()
refillRate: number;
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts
================================================
import { Test } from '@nestjs/testing';
import { CacheService, cacheService as inMemoryCacheService } from '@novu/application-generic';
import { expect } from 'chai';
import sinon from 'sinon';
import { v4 as uuid } from 'uuid';
import { SharedModule } from '../../../shared/shared.module';
import { RateLimitingModule } from '../../rate-limiting.module';
import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command';
import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit.usecase';
describe('EvaluateTokenBucketRateLimit', () => {
let useCase: EvaluateTokenBucketRateLimit;
let cacheService: CacheService;
const mockCommand = EvaluateTokenBucketRateLimitCommand.create({
identifier: 'test',
maxTokens: 10,
windowDuration: 1,
cost: 1,
refillRate: 1,
});
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [SharedModule, RateLimitingModule],
}).compile();
useCase = moduleRef.get(EvaluateTokenBucketRateLimit);
cacheService = moduleRef.get(CacheService);
});
describe('Static values', () => {
it('should have a static algorithm value', () => {
expect(useCase.algorithm).to.equal('token bucket');
});
});
describe('Cache invocation', () => {
let cacheServiceEvalStub: sinon.SinonStub;
let cacheServiceSaddStub: sinon.SinonStub;
let cacheServiceIsEnabledStub: sinon.SinonStub;
beforeEach(async () => {
cacheServiceEvalStub = sinon.stub(cacheService, 'eval');
cacheServiceSaddStub = sinon.stub(cacheService, 'sadd');
cacheServiceIsEnabledStub = sinon.stub(cacheService, 'cacheEnabled').returns(true);
});
afterEach(() => {
cacheServiceEvalStub.restore();
cacheServiceSaddStub.restore();
cacheServiceIsEnabledStub.restore();
});
describe('Cache Errors', () => {
it('should throw error when a cache operation fails', async () => {
cacheServiceEvalStub.resolves(new Error());
try {
await useCase.execute(mockCommand);
throw new Error('Should not reach here');
} catch (e) {
expect(e.message).to.equal('Failed to evaluate rate limit');
}
});
it('should throw error when cache is not enabled', async () => {
cacheServiceIsEnabledStub.returns(false);
try {
await useCase.execute(mockCommand);
throw new Error('Should not reach here');
} catch (e) {
expect(e.message).to.equal('Rate limiting cache service is not available');
}
});
});
describe('Cache Service Adapter', () => {
it('should invoke the SADD method with members casted to string', async () => {
const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService);
const key = 'testKey';
const members = [1, 2];
await cacheClient.sadd(key, ...members);
expect(cacheServiceSaddStub.calledWith(key, ...['1', '2'])).to.equal(true);
});
it('should invoke the EVAL function with args casted to string', async () => {
const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService);
const script = 'return 1';
const keys = ['key1', 'key2'];
const args = [1, 2];
await cacheClient.eval(script, keys, args);
expect(cacheServiceEvalStub.calledWith(script, keys, ['1', '2'])).to.equal(true);
});
});
describe.skip('Redis EVAL script benchmarks', () => {
type TestCase = {
/**
* Test scenario description
*/
description: string;
/**
* Total number of requests to simulate
*/
totalRequests: number;
/**
* Proportion of requests that have a unique identifier
*/
proportionUniqueIds: number;
/**
* Proportion of requests that are throttled
*/
proportionThrottled: number;
/**
* Proportion of requests that are high cost
*/
proportionHighCost: number;
/**
* The proportion of the window duration to jitter the request duration by.
* Low value to simulate burst request patterns.
* High value to simulate sustained request patterns.
*/
proportionJitter: number;
/**
* Expected maximum total evaluation duration in milliseconds
*/
expectedTotalTimeMs: number;
/**
* Expected average evaluation duration in milliseconds
*/
expectedAverageTimeMs: number;
/**
* Expected nth percentile evaluation duration in milliseconds
*/
expectedNthPercentileTimeMs: number;
};
const testCases: TestCase[] = [
{
description: 'Low Load - 0% Throttled - Sustained Single Window',
totalRequests: 5000,
proportionUniqueIds: 0.5,
proportionThrottled: 0,
proportionHighCost: 0,
proportionJitter: 0.8,
expectedTotalTimeMs: 1000,
expectedAverageTimeMs: 10,
expectedNthPercentileTimeMs: 30,
},
{
description: 'Medium Load - 0% Throttled - Sustained Single Window',
totalRequests: 10000,
proportionUniqueIds: 0.5,
proportionThrottled: 0,
proportionHighCost: 0,
proportionJitter: 0.8,
expectedTotalTimeMs: 1000,
expectedAverageTimeMs: 20,
expectedNthPercentileTimeMs: 50,
},
{
description: 'High Load - 0% Throttled - Sustained Single Window',
totalRequests: 20000,
proportionUniqueIds: 0.5,
proportionThrottled: 0,
proportionHighCost: 0,
proportionJitter: 0.8,
expectedTotalTimeMs: 1000,
expectedAverageTimeMs: 200,
expectedNthPercentileTimeMs: 500,
},
{
description: 'Extreme Load - 0% Throttled - Sustained Single Window',
totalRequests: 40000,
proportionUniqueIds: 0.5,
proportionThrottled: 0,
proportionHighCost: 0,
proportionJitter: 0.8,
expectedTotalTimeMs: 2000,
expectedAverageTimeMs: 500,
expectedNthPercentileTimeMs: 2000,
},
{
description: 'High Load - 0% Throttled - Burst Single Window',
totalRequests: 20000,
proportionUniqueIds: 0.5,
proportionThrottled: 0,
proportionHighCost: 0,
proportionJitter: 0.2,
expectedTotalTimeMs: 1000,
expectedAverageTimeMs: 500,
expectedNthPercentileTimeMs: 1000,
},
{
description: 'Extreme Load - 0% Throttled - Burst Single Window',
totalRequests: 40000,
proportionUniqueIds: 0.5,
proportionThrottled: 0,
proportionHighCost: 0,
proportionJitter: 0.2,
expectedTotalTimeMs: 3000,
expectedAverageTimeMs: 1500,
expectedNthPercentileTimeMs: 2000,
},
{
description: 'High Load - 50% Throttled - Burst Single Window',
totalRequests: 20000,
proportionUniqueIds: 0.5,
proportionThrottled: 0.5,
proportionHighCost: 0,
proportionJitter: 0.2,
expectedTotalTimeMs: 1000,
expectedAverageTimeMs: 500,
expectedNthPercentileTimeMs: 1000,
},
{
description: 'High Load - 50% Throttled - Sustained Single Window',
totalRequests: 20000,
proportionUniqueIds: 0.5,
proportionThrottled: 0.5,
proportionHighCost: 0,
proportionJitter: 0.8,
expectedTotalTimeMs: 1000,
expectedAverageTimeMs: 500,
expectedNthPercentileTimeMs: 500,
},
{
description: 'High Load - 50% Throttled & 50% High-Cost - Sustained Multiple Windows',
totalRequests: 40000,
proportionUniqueIds: 0.5,
proportionThrottled: 0.5,
proportionHighCost: 0.5,
proportionJitter: 2.2,
expectedTotalTimeMs: 3000,
expectedAverageTimeMs: 30,
expectedNthPercentileTimeMs: 100,
},
{
description: 'Extreme Load - 50% Throttled & 50% High-Cost - Sustained Multiple Windows',
totalRequests: 80000,
proportionUniqueIds: 0.5,
proportionThrottled: 0.5,
proportionHighCost: 0.5,
proportionJitter: 2.2,
expectedTotalTimeMs: 4000,
expectedAverageTimeMs: 1000,
expectedNthPercentileTimeMs: 1500,
},
{
description: 'High Load - 50% Throttled & 90% High-Cost - Sustained Multiple Windows',
totalRequests: 40000,
proportionUniqueIds: 0.5,
proportionThrottled: 0.5,
proportionHighCost: 0.9,
proportionJitter: 2.2,
expectedTotalTimeMs: 3000,
expectedAverageTimeMs: 50,
expectedNthPercentileTimeMs: 200,
},
{
description: 'High Load - 50% Throttled & 0% Unique - Sustained Multiple Windows',
totalRequests: 40000,
proportionUniqueIds: 0,
proportionThrottled: 0.5,
proportionHighCost: 0,
proportionJitter: 2.2,
expectedTotalTimeMs: 3000,
expectedAverageTimeMs: 30,
expectedNthPercentileTimeMs: 200,
},
{
description: 'High Load - 50% Throttled & 100% Unique - Sustained Multiple Windows',
totalRequests: 40000,
proportionUniqueIds: 1,
proportionThrottled: 0.5,
proportionHighCost: 0,
proportionJitter: 2.2,
expectedTotalTimeMs: 3000,
expectedAverageTimeMs: 30,
expectedNthPercentileTimeMs: 100,
},
];
const mockLowCost = 1;
const mockHighCost = 10;
const mockWindowDuration = 1;
const mockWindowDurationMs = mockWindowDuration * 1000;
const mockProportionRefill = 0.5;
const testThrottledCountErrorTolerance = 0.2;
const testPercentile = 0.95;
function printHistogram(results) {
// Define the number of bins for the histogram
const bins = 10;
// Find the maximum duration to scale the histogram
const maxDuration = Math.max(...results.map((result) => result.duration));
// Initialize an array for the histogram bins
const histogram = Array(bins).fill(0);
// Populate the histogram bins
results.forEach((result) => {
const index = Math.floor((result.duration / maxDuration) * bins);
histogram[index < bins ? index : bins - 1] += 1;
});
// Find the maximum bin count to scale the histogram height
const maxCount = Math.max(...histogram);
// Print the histogram
console.log(`\t Request Time (ms)`);
histogram.forEach((count, i) => {
const bar = '*'.repeat((count / maxCount) * 50); // Scale to a max width of 50 "*"
console.log(`\t ${(((i + 1) / bins) * maxDuration).toFixed(2).padStart(7)}: ${bar}`);
});
}
testCases
.map(
({
description,
totalRequests,
proportionUniqueIds,
proportionThrottled,
proportionHighCost,
proportionJitter,
expectedAverageTimeMs,
expectedNthPercentileTimeMs,
expectedTotalTimeMs,
}) => {
return () => {
describe(description, () => {
let testContext;
let results: Array<{ duration: number; success: boolean }>;
let totalTime: number;
let averageTime: number;
let successCount: number;
let throttledCount: number;
let variance: number;
let stdev: number;
let nthPercentile: number;
const maxTokens = Math.ceil(totalRequests * (1 - proportionThrottled));
const uniqueIdRequests = Math.max(1, Math.floor(totalRequests * proportionUniqueIds));
const uniqueIds = Array.from({ length: uniqueIdRequests }).map(() => uuid());
const mockRepeatId = uuid();
const maxJitterMs = mockWindowDurationMs * proportionJitter;
const refillPerWindow = (maxTokens * mockProportionRefill) / mockWindowDuration;
before(async () => {
const cacheServiceInitialized = await inMemoryCacheService.useFactory();
testContext = {
redis: EvaluateTokenBucketRateLimit.getCacheClient(cacheServiceInitialized),
};
const proms = Array.from({ length: totalRequests }).map(async (_val, index) => {
const cost = Math.random() < proportionHighCost ? mockHighCost : mockLowCost;
/**
* Distribute unique ids with request allocation skewed left.
* matching an expected distribution of requests per unique API client, where:
* - the majority of clients make a small number of requests
* - a small number of clients make a large number of requests
*
* Number of Requests per Unique Id
* ID Requests
* 1 *
* 2 **
* 3 ****
* 4 ******
* 5 *********
* 6 *************
* 7 *****************
* 8 ***********************
* 9 ********************************
* 10 *******************************************
*/
const id =
Math.random() < proportionUniqueIds
? uniqueIds[Math.floor((index / totalRequests) * uniqueIds.length)]
: mockRepeatId;
const jitter = Math.floor(Math.random() * maxJitterMs);
await new Promise((resolve) => {
setTimeout(resolve, jitter);
});
const start = Date.now();
const limit = EvaluateTokenBucketRateLimit.tokenBucketLimiter(
refillPerWindow,
mockWindowDuration,
maxTokens,
cost
);
const { success } = await limit(testContext, id);
const end = Date.now();
const duration = end - start;
return {
duration,
success,
};
});
const startAll = Date.now();
results = await Promise.all(proms);
const endAll = Date.now();
totalTime = endAll - startAll;
averageTime = results.reduce((acc, val) => acc + val.duration, 0) / results.length;
variance = results.reduce((acc, val) => acc + (val.duration - averageTime) ** 2, 0) / results.length;
stdev = Math.sqrt(variance);
nthPercentile = results.sort((a, b) => a.duration - b.duration)[
Math.floor(results.length * testPercentile)
].duration;
successCount = results.filter(({ success }) => success).length;
throttledCount = totalRequests - successCount;
console.log(
`\t Params: Total Req: ${totalRequests.toLocaleString()}\tUsers: ${uniqueIdRequests.toLocaleString()}\tThrottled: ${
proportionThrottled * 100
}%\tHigh Cost: ${proportionHighCost * 100}%\tJitter: ${maxJitterMs}ms`
);
console.log(
`\t Stats: Total Time: ${totalTime.toLocaleString()}ms\tAvg: ${averageTime.toFixed(
1
)}ms\tStdev: ${stdev.toFixed(1)}\tp(${
testPercentile * 100
}): ${nthPercentile}\tThrottled: ${throttledCount.toLocaleString()}`
);
printHistogram(results);
});
describe('Script Performance', () => {
it(`should be able to process ${totalRequests.toLocaleString()} evaluations in less than ${expectedTotalTimeMs}ms`, async () => {
expect(totalTime).to.be.lessThan(expectedTotalTimeMs);
});
it(`should have average evaluation duration less than ${expectedAverageTimeMs}ms`, async () => {
expect(averageTime).to.be.lessThan(expectedAverageTimeMs);
});
it(`should have ${
testPercentile * 100
}th percentile evaluation duration less than ${expectedNthPercentileTimeMs}ms`, async () => {
expect(nthPercentile).to.be.lessThan(expectedNthPercentileTimeMs);
});
});
describe('Script Throttle Evaluation', () => {
const proportionRequestsPerWindow =
maxJitterMs > mockWindowDurationMs ? mockWindowDurationMs / maxJitterMs : 1;
const totalRequestsPerWindow = Math.floor(totalRequests * proportionRequestsPerWindow);
const uniqueRequestsPerWindow = Math.floor(totalRequestsPerWindow * (1 - proportionThrottled));
const expectedPerRequestCost =
(1 - proportionHighCost) * mockLowCost + proportionHighCost * mockHighCost;
const expectedWindowCost = uniqueRequestsPerWindow * expectedPerRequestCost;
const firstWindowThrottledRequests =
expectedWindowCost > maxTokens ? (expectedWindowCost - maxTokens) / expectedPerRequestCost : 0;
const secondWindowMaxTokens = Math.max(
maxTokens,
maxTokens - firstWindowThrottledRequests + refillPerWindow
);
const secondWindowThrottledRequests =
expectedWindowCost > secondWindowMaxTokens
? (expectedWindowCost - secondWindowMaxTokens) / expectedPerRequestCost
: 0;
const expectedThrottledCount = firstWindowThrottledRequests + secondWindowThrottledRequests;
const expectedThrottledCountMin = Math.floor(
expectedThrottledCount * (1 - testThrottledCountErrorTolerance)
);
const expectedThrottledCountMax = Math.floor(
expectedThrottledCount * (1 + testThrottledCountErrorTolerance)
);
it(`should throttle between ${expectedThrottledCountMin} and ${expectedThrottledCountMax} requests`, async () => {
expect(throttledCount).to.be.greaterThanOrEqual(expectedThrottledCountMin);
expect(throttledCount).to.be.lessThanOrEqual(expectedThrottledCountMax);
});
});
});
};
}
)
.forEach((testCase) => {
testCase();
});
});
});
});
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts
================================================
import { Ratelimit } from '@upstash/ratelimit';
export type UpstashRedisClient = ConstructorParameters[0]['redis'];
export type EvaluateTokenBucketRateLimitResponseDto = {
/**
* Whether the request may pass(true) or exceeded the limit(false)
*/
success: boolean;
/**
* Maximum number of requests allowed within a window.
*/
limit: number;
/**
* How many requests the client has left within the current window.
*/
remaining: number;
/**
* Unix timestamp in milliseconds when the limits are reset.
*/
reset: number;
};
export type RegionLimiter = ReturnType;
/**
* You have a bucket filled with `{maxTokens}` tokens that refills constantly
* at `{refillRate}` per `{interval}`.
* Every request will remove `{cost}` token(s) from the bucket and if there is no
* token to take, the request is rejected.
*
* **Pro:**
*
* - Bursts of requests are smoothed out and you can process them at a constant
* rate.
* - Allows to set a higher initial burst limit by setting `maxTokens` higher
* than `refillRate`
*/
export type CostLimiter = (
/**
* How many tokens are refilled per `interval`
*
* An interval of `10s` and refillRate of 5 will cause a new token to be added every 2 seconds.
*/
refillRate: number,
/**
* The interval in seconds for the `refillRate`
*/
interval: number,
/**
* Maximum number of tokens.
* A newly created bucket starts with this many tokens.
* Useful to allow higher burst limits.
*/
maxTokens: number,
/**
* The number of tokens used in the request.
*/
cost: number
) => RegionLimiter;
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts
================================================
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { CacheService, InstrumentUsecase, PinoLogger } from '@novu/application-generic';
import { Ratelimit } from '@upstash/ratelimit';
import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command';
import {
EvaluateTokenBucketRateLimitResponseDto,
RegionLimiter,
UpstashRedisClient,
} from './evaluate-token-bucket-rate-limit.types';
const LOG_CONTEXT = 'EvaluateTokenBucketRateLimit';
@Injectable()
export class EvaluateTokenBucketRateLimit {
private ephemeralCache = new Map();
public algorithm = 'token bucket';
constructor(
private cacheService: CacheService,
private logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
}
@InstrumentUsecase()
async execute(command: EvaluateTokenBucketRateLimitCommand): Promise {
if (!this.cacheService.cacheEnabled()) {
const message = 'Rate limiting cache service is not available';
this.logger.error(message);
throw new ServiceUnavailableException(message);
}
const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(this.cacheService);
const ratelimit = new Ratelimit({
redis: cacheClient,
limiter: EvaluateTokenBucketRateLimit.tokenBucketLimiter(
command.refillRate,
command.windowDuration,
command.maxTokens,
command.cost
),
prefix: '', // Empty cache key prefix to give us full control over the key format
ephemeralCache: this.ephemeralCache,
});
try {
const { success, limit, remaining, reset } = await ratelimit.limit(command.identifier);
return {
success,
limit,
remaining,
reset,
};
} catch (error) {
const apiMessage = 'Failed to evaluate rate limit';
const logMessage = `${apiMessage} for identifier: "${command.identifier}". Error: "${error}"`;
this.logger.error(logMessage);
throw new ServiceUnavailableException(apiMessage);
}
}
public static getCacheClient(cacheService: CacheService): UpstashRedisClient {
// Adapter for the @upstash/redis client -> cache client
return {
sadd: async (key, ...members) => cacheService.sadd(key, ...members.map((member) => String(member))),
eval: async (script, keys, args) =>
cacheService.eval(
script,
keys,
args.map((arg) => String(arg))
),
};
}
/**
* Token Bucket algorithm with variable cost. Adapted from @upstash/ratelimit and modified to support variable cost.
* Also influenced by Krakend's token bucket implementation to delay refills until bucket is empty.
*
* @see https://github.com/upstash/ratelimit/blob/3a8cfb00e827188734ac347965cb743a75fcb98a/src/single.ts#L292
* @see https://github.com/krakend/krakend-ratelimit/blob/369f0be9b51a4fb8ab7d43e4833d076b461a4374/rate.go#L85
*/
public static tokenBucketLimiter(
refillRate: number,
interval: number,
maxTokens: number,
cost: number
): RegionLimiter {
const script = /* Lua */ `
local key = KEYS[1] -- current interval identifier including prefixes
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
local fillInterval = tonumber(ARGV[3]) -- time between refills in milliseconds
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
local cost = tonumber(ARGV[5]) -- cost of request
local remaining = 0 -- remaining number of tokens
local reset = 0 -- timestamp when next request of {cost} token(s) can be accepted
local resetCost = 0 -- multiplier for the next reset time
local lastRefill = 0 -- timestamp of last refill
local bucket = redis.call("HMGET", key, "lastRefill", "tokens")
if bucket[1] == false then
-- The bucket does not exist yet, so we create it and add a ttl.
lastRefill = now
remaining = maxTokens - cost
resetCost = (remaining < cost) and (cost - remaining) or cost
redis.call("HMSET", key, "lastRefill", lastRefill, "tokens", remaining)
redis.call("PEXPIRE", key, interval * 2)
else
-- The current bucket does exist
lastRefill = tonumber(bucket[1])
local tokens = tonumber(bucket[2])
if tokens >= cost then
-- Delay refill until bucket is empty
remaining = tokens - cost
resetCost = (remaining < cost) and (cost - remaining) or cost
redis.call("HMSET", key, "tokens", remaining)
else
local elapsed = now - lastRefill
local tokensToAdd = math.floor(elapsed / fillInterval)
local newTokens = math.min(maxTokens, tokens + tokensToAdd)
remaining = newTokens - cost
if remaining >= 0 then
-- Update the time of the last refill depending on how many tokens we added
lastRefill = lastRefill + tokensToAdd * fillInterval
resetCost = (remaining < cost) and (cost - remaining) or cost
redis.call("HMSET", key, "lastRefill", lastRefill, "tokens", remaining)
redis.call("PEXPIRE", key, interval * 2)
else
resetCost = cost - tokens
end
end
end
reset = lastRefill + resetCost * fillInterval
return {remaining, reset}
`;
const intervalDurationMs = interval * 1e3;
const fillInterval = intervalDurationMs / refillRate;
return async (ctx, identifier) => {
// Cost needs to be included in local cache identifier to ensure lower cost requests are not blocked
const localCacheIdentifier = `${identifier}:${cost}`;
if (ctx.cache) {
const { blocked, reset } = ctx.cache.isBlocked(localCacheIdentifier);
if (blocked) {
return {
success: false,
limit: refillRate,
remaining: 0,
reset,
pending: Promise.resolve(),
};
}
}
const now = Date.now();
const [remaining, reset] = (await ctx.redis.eval(
script,
[identifier],
[maxTokens, intervalDurationMs, fillInterval, now, cost]
)) as [number, number];
const success = remaining >= 0;
const nonNegativeRemaining = Math.max(0, remaining);
if (ctx.cache && !success) {
ctx.cache.blockUntil(localCacheIdentifier, reset);
}
return {
success,
limit: refillRate,
remaining: nonNegativeRemaining,
reset,
pending: Promise.resolve(),
};
};
}
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts
================================================
export * from './evaluate-token-bucket-rate-limit.command';
export * from './evaluate-token-bucket-rate-limit.types';
export * from './evaluate-token-bucket-rate-limit.usecase';
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts
================================================
import { Test } from '@nestjs/testing';
import {
ApiRateLimitAlgorithmEnum,
ApiRateLimitAlgorithmEnvVarFormat,
DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG,
} from '@novu/shared';
import { expect } from 'chai';
import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config.usecase';
describe('GetApiRateLimitAlgorithmConfig', () => {
let useCase: GetApiRateLimitAlgorithmConfig;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [GetApiRateLimitAlgorithmConfig],
}).compile();
useCase = moduleRef.get(GetApiRateLimitAlgorithmConfig);
});
it('should use the default rate limit algorithm config when no environment variables are set', () => {
expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG);
});
it('should override default rate limit algorithm config with environment variables', () => {
const mockOverrideBurstAllowance = 0.2;
const mockApiRateLimitConfigurationKey = ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE;
const envVarName: ApiRateLimitAlgorithmEnvVarFormat = `API_RATE_LIMIT_ALGORITHM_${
mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase
}`;
process.env[envVarName] = `${mockOverrideBurstAllowance}`;
// Re-initialize the defaultApiRateLimits after setting the environment variable
useCase.loadDefault();
const result = useCase.default;
expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBurstAllowance);
delete process.env[envVarName]; // cleanup
});
});
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import {
ApiRateLimitAlgorithmEnum,
ApiRateLimitAlgorithmEnvVarFormat,
DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG,
IApiRateLimitAlgorithm,
} from '@novu/shared';
@Injectable()
export class GetApiRateLimitAlgorithmConfig {
public default: IApiRateLimitAlgorithm;
constructor() {
this.loadDefault();
}
public loadDefault(): void {
this.default = this.createDefault();
}
private createDefault(): IApiRateLimitAlgorithm {
const mergedConfig: IApiRateLimitAlgorithm = { ...DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG };
// Read process environment only once for performance
const processEnv = process.env;
Object.values(ApiRateLimitAlgorithmEnum).forEach((algorithmOption) => {
const envVarName = this.getEnvVarName(algorithmOption);
const envVarValue = processEnv[envVarName];
if (envVarValue) {
mergedConfig[algorithmOption] = Number(envVarValue);
}
});
return mergedConfig;
}
private getEnvVarName(algorithmOption: ApiRateLimitAlgorithmEnum): ApiRateLimitAlgorithmEnvVarFormat {
return `API_RATE_LIMIT_ALGORITHM_${algorithmOption.toUpperCase() as Uppercase}`;
}
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts
================================================
export * from './get-api-rate-limit-algorithm-config.usecase';
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts
================================================
import { Test } from '@nestjs/testing';
import { ApiRateLimitCostEnum, ApiRateLimitCostEnvVarFormat, DEFAULT_API_RATE_LIMIT_COST_CONFIG } from '@novu/shared';
import { expect } from 'chai';
import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config.usecase';
describe('GetApiRateLimitCostConfig', () => {
let useCase: GetApiRateLimitCostConfig;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [GetApiRateLimitCostConfig],
}).compile();
useCase = moduleRef.get(GetApiRateLimitCostConfig);
});
it('should use the default rate limit cost configuration when no environment variables are set', () => {
expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_COST_CONFIG);
});
it('should override default rate limit cost configuration with environment variables', () => {
const mockOverrideBulkCost = 15;
const mockApiRateLimitConfigurationKey = ApiRateLimitCostEnum.BULK;
const envVarName: ApiRateLimitCostEnvVarFormat = `API_RATE_LIMIT_COST_${
mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase
}`;
process.env[envVarName] = `${mockOverrideBulkCost}`;
// Re-initialize the defaultApiRateLimits after setting the environment variable
useCase.loadDefault();
const result = useCase.default;
expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBulkCost);
delete process.env[envVarName]; // cleanup
});
});
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts
================================================
import { Injectable } from '@nestjs/common';
import {
ApiRateLimitCostEnum,
ApiRateLimitCostEnvVarFormat,
DEFAULT_API_RATE_LIMIT_COST_CONFIG,
IApiRateLimitCost,
} from '@novu/shared';
@Injectable()
export class GetApiRateLimitCostConfig {
public default: IApiRateLimitCost;
constructor() {
this.loadDefault();
}
public loadDefault(): void {
this.default = this.createDefault();
}
private createDefault(): IApiRateLimitCost {
const mergedConfig: IApiRateLimitCost = { ...DEFAULT_API_RATE_LIMIT_COST_CONFIG };
// Read process environment only once for performance
const processEnv = process.env;
Object.values(ApiRateLimitCostEnum).forEach((costOption) => {
const envVarName = this.getEnvVarName(costOption);
const envVarValue = processEnv[envVarName];
if (envVarValue) {
mergedConfig[costOption] = Number(envVarValue);
}
});
return mergedConfig;
}
private getEnvVarName(costOption: ApiRateLimitCostEnum): ApiRateLimitCostEnvVarFormat {
return `API_RATE_LIMIT_COST_${costOption.toUpperCase() as Uppercase}`;
}
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts
================================================
export * from './get-api-rate-limit-cost-config.usecase';
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts
================================================
import { ApiRateLimitCategoryEnum } from '@novu/shared';
import { IsDefined, IsEnum } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
export class GetApiRateLimitMaximumCommand extends EnvironmentCommand {
@IsDefined()
@IsEnum(ApiRateLimitCategoryEnum)
apiRateLimitCategory: ApiRateLimitCategoryEnum;
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto.ts
================================================
import { ApiServiceLevelEnum } from '@novu/shared';
export const CUSTOM_API_SERVICE_LEVEL = 'custom';
export type ApiServiceLevel = ApiServiceLevelEnum | typeof CUSTOM_API_SERVICE_LEVEL;
// Array type to keep the cached entity as small as possible for more performant caching
export type GetApiRateLimitMaximumDto = [apiRateLimitMaximum: number, apiServiceLevel: ApiServiceLevel];
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts
================================================
import { Test } from '@nestjs/testing';
import { CacheService, MockCacheService } from '@novu/application-generic';
import { CommunityOrganizationRepository, EnvironmentRepository } from '@novu/dal';
import {
ApiRateLimitCategoryEnum,
ApiRateLimitCategoryToFeatureName,
ApiServiceLevelEnum,
FeatureFlagsKeysEnum,
getFeatureForTierAsNumber,
} from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import sinon from 'sinon';
import { SharedModule } from '../../../shared/shared.module';
import { RateLimitingModule } from '../../rate-limiting.module';
import { CUSTOM_API_SERVICE_LEVEL } from './get-api-rate-limit-maximum.dto';
import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from './index';
const mockDefaultApiRateLimits = {
[ApiServiceLevelEnum.FREE]: {
[ApiRateLimitCategoryEnum.GLOBAL]: 60,
[ApiRateLimitCategoryEnum.TRIGGER]: 60,
[ApiRateLimitCategoryEnum.CONFIGURATION]: 60,
},
[ApiServiceLevelEnum.UNLIMITED]: {
[ApiRateLimitCategoryEnum.GLOBAL]: 600,
[ApiRateLimitCategoryEnum.TRIGGER]: 600,
[ApiRateLimitCategoryEnum.CONFIGURATION]: 600,
},
};
describe('GetApiRateLimitMaximum', async () => {
let useCase: GetApiRateLimitMaximum;
let session: UserSession;
let organizationRepository: CommunityOrganizationRepository;
let environmentRepository: EnvironmentRepository;
let findOneEnvironmentStub: sinon.SinonStub;
let findOneOrganizationStub: sinon.SinonStub;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [SharedModule, RateLimitingModule],
providers: [],
})
.overrideProvider(CacheService)
.useValue(MockCacheService.createClient())
.compile();
await moduleRef.init(); // Trigger OnModuleInit
session = new UserSession();
await session.initialize();
useCase = moduleRef.get(GetApiRateLimitMaximum);
organizationRepository = moduleRef.get(CommunityOrganizationRepository);
environmentRepository = moduleRef.get(EnvironmentRepository);
findOneEnvironmentStub = sinon.stub(environmentRepository, 'findOne');
findOneOrganizationStub = sinon.stub(organizationRepository, 'findById');
});
afterEach(() => {
findOneEnvironmentStub.restore();
findOneOrganizationStub.restore();
});
it('should throw error when environment is not found', async () => {
findOneEnvironmentStub.resolves(undefined);
try {
await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: ApiRateLimitCategoryEnum.GLOBAL,
})
);
throw new Error('Should not reach here');
} catch (e) {
expect(e.message).to.equal(`Environment id: ${session.environment._id} not found`);
}
});
describe('Environment DOES have rate limits specified', () => {
const mockGlobalLimit = 65;
const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
beforeEach(() => {
findOneEnvironmentStub.resolves({
apiRateLimits: {
[mockApiRateLimitCategory]: mockGlobalLimit,
},
});
});
it('should return api rate limit for the category set on environment', async () => {
const [rateLimit] = await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
expect(rateLimit).to.equal(mockGlobalLimit);
});
it('should return api service level of CUSTOM', async () => {
const [, apiServiceLevel] = await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
expect(apiServiceLevel).to.equal(CUSTOM_API_SERVICE_LEVEL);
});
});
describe('Environment DOES NOT have rate limits specified', () => {
const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL;
beforeEach(() => {
findOneEnvironmentStub.resolves({
apiRateLimits: undefined,
});
});
describe('Organization DOES have api service level specified', () => {
const mockApiServiceLevel = ApiServiceLevelEnum.FREE;
beforeEach(() => {
findOneOrganizationStub.resolves({
apiServiceLevel: mockApiServiceLevel,
});
});
it('should return default api rate limit for the organizations apiServiceLevel when apiServiceLevel IS set on organization', async () => {
const defaultApiRateLimit = getFeatureForTierAsNumber(
ApiRateLimitCategoryToFeatureName[mockApiRateLimitCategory],
mockApiServiceLevel,
false
);
const [rateLimit] = await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
expect(rateLimit).to.equal(defaultApiRateLimit);
});
it('should return the api service level set on organization when apiServiceLevel IS set on organization', async () => {
const [, apiServiceLevel] = await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
expect(apiServiceLevel).to.equal(mockApiServiceLevel);
});
});
describe('Organization DOES NOT have api service level specified', () => {
beforeEach(() => {
findOneOrganizationStub.resolves({
apiServiceLevel: undefined,
});
});
it('should return default api rate limit for the UNLIMITED service level when apiServiceLevel IS NOT set on organization', async () => {
const defaultApiRateLimit = getFeatureForTierAsNumber(
ApiRateLimitCategoryToFeatureName[mockApiRateLimitCategory],
ApiServiceLevelEnum.UNLIMITED,
false
);
const [rateLimit] = await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
expect(rateLimit).to.equal(defaultApiRateLimit);
});
it('should return the default api service level of UNLIMITED when apiServiceLevel IS NOT set on organization', async () => {
const defaultApiServiceLevel = ApiServiceLevelEnum.UNLIMITED;
const [, apiServiceLevel] = await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
expect(apiServiceLevel).to.equal(defaultApiServiceLevel);
});
});
it('should throw an error when the organization is not found', async () => {
findOneOrganizationStub.resolves(undefined);
try {
await useCase.execute(
GetApiRateLimitMaximumCommand.create({
organizationId: session.organization._id,
environmentId: session.environment._id,
apiRateLimitCategory: mockApiRateLimitCategory,
})
);
throw new Error('Should not reach here');
} catch (e) {
expect(e.message).to.equal(`Organization id: ${session.organization._id} not found`);
}
});
});
});
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts
================================================
import { Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common';
import {
buildMaximumApiRateLimitKey,
CachedResponse,
Instrument,
InstrumentUsecase,
PinoLogger,
} from '@novu/application-generic';
import { CommunityOrganizationRepository, EnvironmentRepository } from '@novu/dal';
import {
ApiRateLimitCategoryEnum,
ApiRateLimitCategoryToFeatureName,
ApiRateLimitServiceMaximumEnvVarFormat,
ApiServiceLevelEnum,
getFeatureForTierAsNumber,
IApiRateLimitServiceMaximum,
} from '@novu/shared';
import { GetApiRateLimitMaximumCommand } from './get-api-rate-limit-maximum.command';
import { CUSTOM_API_SERVICE_LEVEL, GetApiRateLimitMaximumDto } from './get-api-rate-limit-maximum.dto';
@Injectable()
export class GetApiRateLimitMaximum implements OnModuleInit {
private apiRateLimitRecord: IApiRateLimitServiceMaximum;
constructor(
private environmentRepository: EnvironmentRepository,
private organizationRepository: CommunityOrganizationRepository,
private logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
}
onModuleInit() {
this.apiRateLimitRecord = this.buildApiRateLimitRecord();
}
@InstrumentUsecase()
async execute(command: GetApiRateLimitMaximumCommand): Promise {
return await this.getApiRateLimit({
apiRateLimitCategory: command.apiRateLimitCategory,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
});
}
@CachedResponse({
builder: (command: { apiRateLimitCategory: ApiRateLimitCategoryEnum; _environmentId: string }) =>
buildMaximumApiRateLimitKey({
_environmentId: command._environmentId,
apiRateLimitCategory: command.apiRateLimitCategory,
}),
})
private async getApiRateLimit({
apiRateLimitCategory,
_environmentId,
_organizationId,
}: {
apiRateLimitCategory: ApiRateLimitCategoryEnum;
_environmentId: string;
_organizationId: string;
}): Promise {
const environment = await this.getEnvironment(_environmentId);
if (environment.apiRateLimits) {
return [environment.apiRateLimits[apiRateLimitCategory], CUSTOM_API_SERVICE_LEVEL];
}
const apiServiceLevel = await this.getOrganizationApiServiceLevel(_organizationId);
const apiRateLimitRecord = this.apiRateLimitRecord[apiServiceLevel];
return [apiRateLimitRecord[apiRateLimitCategory], apiServiceLevel];
}
private async getOrganizationApiServiceLevel(_organizationId: string): Promise {
const organization = await this.organizationRepository.findById(_organizationId, '_id apiServiceLevel');
if (!organization) {
const message = `Organization id: ${_organizationId} not found`;
this.logger.error(message);
throw new InternalServerErrorException(message);
}
if (organization.apiServiceLevel) {
return organization.apiServiceLevel;
}
return ApiServiceLevelEnum.UNLIMITED;
}
private async getEnvironment(_environmentId: string) {
const environment = await this.environmentRepository.findOne({ _id: _environmentId }, '_id apiRateLimits', {
readPreference: 'secondaryPreferred',
});
if (!environment) {
const message = `Environment id: ${_environmentId} not found`;
this.logger.error(message);
throw new InternalServerErrorException(message);
}
return environment;
}
@Instrument()
private buildApiRateLimitRecord(): IApiRateLimitServiceMaximum {
// Read process environment only once for performance
const processEnv = process.env;
return Object.values(ApiServiceLevelEnum).reduce((acc, apiServiceLevel) => {
acc[apiServiceLevel] = Object.values(ApiRateLimitCategoryEnum).reduce(
(categoryAcc, apiRateLimitCategory) => {
const featureName = ApiRateLimitCategoryToFeatureName[apiRateLimitCategory];
const featureForTierAsNumber = getFeatureForTierAsNumber(featureName, apiServiceLevel);
const envVarName = this.getEnvVarName(apiServiceLevel, apiRateLimitCategory);
const envVarValue = processEnv[envVarName];
categoryAcc[apiRateLimitCategory] = envVarValue ? Number(envVarValue) : featureForTierAsNumber;
return categoryAcc;
},
{} as Record
);
return acc;
}, {} as IApiRateLimitServiceMaximum);
}
private getEnvVarName(
apiServiceLevel: ApiServiceLevelEnum,
apiRateLimitCategory: ApiRateLimitCategoryEnum
): ApiRateLimitServiceMaximumEnvVarFormat {
return `API_RATE_LIMIT_MAXIMUM_${apiServiceLevel.toUpperCase() as Uppercase}_${
apiRateLimitCategory.toUpperCase() as Uppercase
}`;
}
}
================================================
FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts
================================================
export * from './get-api-rate-limit-maximum.command';
export * from './get-api-rate-limit-maximum.usecase';
================================================
FILE: apps/api/src/app/rate-limiting/usecases/index.ts
================================================
import { EvaluateApiRateLimit } from './evaluate-api-rate-limit';
import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit';
import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config';
import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config';
import { GetApiRateLimitMaximum } from './get-api-rate-limit-maximum';
export const USE_CASES = [
//
GetApiRateLimitMaximum,
GetApiRateLimitAlgorithmConfig,
GetApiRateLimitCostConfig,
EvaluateApiRateLimit,
EvaluateTokenBucketRateLimit,
];
================================================
FILE: apps/api/src/app/shared/commands/authenticated.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { IsNotEmpty } from 'class-validator';
export abstract class AuthenticatedCommand extends BaseCommand {
@IsNotEmpty()
public readonly userId: string;
}
================================================
FILE: apps/api/src/app/shared/commands/organization.command.ts
================================================
import { IsNotEmpty } from 'class-validator';
import { AuthenticatedCommand } from './authenticated.command';
export abstract class OrganizationCommand extends AuthenticatedCommand {
@IsNotEmpty()
readonly organizationId: string;
}
================================================
FILE: apps/api/src/app/shared/commands/project.command.ts
================================================
import { BaseCommand } from '@novu/application-generic';
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export abstract class EnvironmentCommand extends BaseCommand {
@IsNotEmpty()
readonly environmentId: string;
@IsNotEmpty()
readonly organizationId: string;
}
export abstract class EnvironmentWithUserCommand extends EnvironmentCommand {
@IsNotEmpty()
readonly userId: string;
}
export abstract class EnvironmentWithSubscriber extends EnvironmentCommand {
@IsNotEmpty()
readonly environmentId: string;
@IsNotEmpty()
readonly organizationId: string;
@IsNotEmpty()
readonly subscriberId: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
readonly contextKeys?: string[];
}
================================================
FILE: apps/api/src/app/shared/constants.ts
================================================
export const TRANSLATIONS_SERVICE = 'TRANSLATIONS_SERVICE';
export const MANAGE_TRANSLATIONS = 'MANAGE_TRANSLATIONS';
================================================
FILE: apps/api/src/app/shared/dtos/api-key.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
export class ApiKey {
@ApiProperty()
key: string;
@ApiProperty()
_userId: string;
}
================================================
FILE: apps/api/src/app/shared/dtos/base-responses.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
export enum DirectionEnum {
ASC = 'ASC',
DESC = 'DESC',
}
export class ResponseError {
@ApiProperty({
description: 'The error code or identifier.',
type: String,
})
error: string;
@ApiProperty({
description: 'Detailed error message.',
type: String,
})
message: string;
@ApiProperty({
description: 'HTTP status code associated with the error.',
type: Number,
})
statusCode: number;
}
export class PaginatedResponse {
@ApiProperty({
description: 'Array of data items of type T.',
type: 'array', // Use 'array' instead of Array
items: { type: 'object' }, // Define the type of items in the array
})
data: T[];
@ApiProperty({
description: 'Indicates if there are more items available.',
type: Boolean,
})
hasMore: boolean;
@ApiProperty({
description: 'Total number of items available.',
type: Number,
})
totalCount: number;
@ApiProperty({
description: 'Number of items per page.',
type: Number,
})
pageSize: number;
@ApiProperty({
description: 'Current page number.',
type: Number,
})
page: number;
}
export type KeysOfT = keyof T;
export class CursorPaginationQueryDto {
@ApiProperty({
description: 'Maximum number of items to return.',
type: Number,
})
limit?: number;
@ApiProperty({
description: 'Cursor for pagination, used to fetch the next set of results.',
type: String,
})
cursor?: string;
@ApiProperty({
description: 'Direction for ordering results.',
enum: DirectionEnum,
})
orderDirection?: DirectionEnum;
@ApiProperty({
description: 'Field by which to order the results.',
type: String,
})
orderBy?: K;
}
export class LimitOffsetPaginationDto> {
@ApiProperty({
description: 'Maximum number of items to return.',
type: String,
})
limit: string;
@ApiProperty({
description: 'Number of items to skip before starting to collect the result set.',
type: String,
})
offset: string;
@ApiProperty({
description: 'Direction for ordering results.',
enum: DirectionEnum,
})
orderDirection?: DirectionEnum;
@ApiProperty({
description: 'Field by which to order the results.',
type: String,
})
orderBy?: K;
}
export class PaginationParams {
@ApiProperty({
description: 'Current page number.',
type: Number,
})
page: number;
@ApiProperty({
description: 'Number of items per page.',
type: Number,
})
limit: number;
}
export class PaginationWithQueryParams extends PaginationParams {
@ApiProperty({
description: 'Optional search query string.',
type: String,
required: false,
})
query?: string;
}
export enum OrderDirectionEnum {
ASC = 1,
DESC = -1,
}
export enum OrderByEnum {
ASC = 'ASC',
DESC = 'DESC',
}
================================================
FILE: apps/api/src/app/shared/dtos/base-subscriber-fields.dto.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { SubscriberCustomData } from '@novu/shared';
import { IsEmail, IsLocale, IsObject, IsOptional, IsString, IsTimeZone, ValidateIf } from 'class-validator';
export class BaseSubscriberFieldsDto {
@ApiPropertyOptional({
description: 'First name of the subscriber',
example: 'John',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.firstName !== null)
@IsString()
firstName?: string | null;
@ApiPropertyOptional({
description: 'Last name of the subscriber',
example: 'Doe',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.lastName !== null)
@IsString()
lastName?: string | null;
@ApiPropertyOptional({
description: 'Email address of the subscriber',
example: 'john.doe@example.com',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.email !== null)
@IsEmail()
email?: string | null;
@ApiPropertyOptional({
description: 'Phone number of the subscriber',
example: '+1234567890',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.phone !== null)
@IsString()
phone?: string | null;
@ApiPropertyOptional({
description: 'Avatar URL or identifier',
example: 'https://example.com/avatar.jpg',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.avatar !== null)
@IsString()
avatar?: string | null;
@ApiPropertyOptional({
description: 'Locale of the subscriber',
example: 'en-US',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.locale !== null)
@IsLocale()
locale?: string | null;
@ApiPropertyOptional({
description: 'Timezone of the subscriber',
example: 'America/New_York',
nullable: true,
type: String,
})
@IsOptional()
@ValidateIf((obj) => obj.timezone !== null)
@IsTimeZone()
timezone?: string | null;
@ApiPropertyOptional({
type: Object,
description: 'Additional custom data associated with the subscriber',
nullable: true,
additionalProperties: true,
})
@IsOptional()
@ValidateIf((obj) => obj.data !== null)
@IsObject()
data?: SubscriberCustomData | null;
}
================================================
FILE: apps/api/src/app/shared/dtos/channel-preference.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { ChannelTypeEnum } from '@novu/shared';
import { IsBoolean, IsDefined, IsEnum } from 'class-validator';
export class ChannelPreference {
@ApiProperty({
enum: [...Object.values(ChannelTypeEnum)],
enumName: 'ChannelTypeEnum',
description: 'The type of channel that is enabled or not',
})
@IsDefined()
@IsEnum(ChannelTypeEnum)
type: ChannelTypeEnum;
@ApiProperty({
type: Boolean,
description: 'If channel is enabled or not',
})
@IsBoolean()
@IsDefined()
enabled: boolean;
}
================================================
FILE: apps/api/src/app/shared/dtos/cursor-paginated-response.ts
================================================
import { mixin } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
type Constructor = new (...args: any[]) => T;
export function withCursorPagination(Base: TBase, options?: ApiPropertyOptions | undefined) {
class ResponseDTO {
@ApiProperty({
isArray: true,
type: Base,
...options,
})
@Type(() => Base)
@ValidateNested({ each: true })
data!: Array>;
@ApiProperty({
description: 'The cursor for the next page of results, or null if there are no more pages.',
type: String,
nullable: true,
})
next: string | null;
@ApiProperty({
description: 'The cursor for the previous page of results, or null if this is the first page.',
type: String,
nullable: true,
})
previous: string | null;
@ApiProperty({
description: 'The total count of items (up to 50,000)',
type: Number,
})
totalCount: number;
@ApiProperty({
description: 'Whether there are more than 50,000 results available',
type: Boolean,
})
totalCountCapped: boolean;
}
return mixin(ResponseDTO); // This is important otherwise you will get always the same instance
}
================================================
FILE: apps/api/src/app/shared/dtos/cursor-pagination-request.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsMongoId, IsOptional, Max, Min } from 'class-validator';
import type { Constructor, CursorPaginationParams } from '../types';
export function CursorPaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor {
class CursorPaginationRequest {
@ApiPropertyOptional({
type: Number,
required: false,
default: defaultLimit,
maximum: maxLimit,
example: 10,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(maxLimit)
limit = defaultLimit;
@ApiPropertyOptional()
@IsOptional()
@IsMongoId({
message: 'The after cursor must be a valid MongoDB ObjectId',
})
after?: string;
@ApiPropertyOptional({
type: Number,
example: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset = 0;
}
return CursorPaginationRequest;
}
================================================
FILE: apps/api/src/app/shared/dtos/data-wrapper-dto.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
export class DataWrapperDto {
@ApiProperty()
data: T;
}
export class DataBooleanDto {
@ApiProperty()
data: boolean;
}
export class DataNumberDto {
@ApiProperty()
data: number;
}
================================================
FILE: apps/api/src/app/shared/dtos/limit-offset-pagination.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
// Enum for sorting direction
export enum DirectionEnum {
ASC = 'ASC',
DESC = 'DESC',
}
export function LimitOffsetPaginationQueryDto(
BaseClass: new (...args: any[]) => T,
allowedFields: K[]
): new () => {
limit?: number;
offset?: number;
orderDirection?: DirectionEnum;
orderBy?: K;
} {
class PaginationDto {
@ApiProperty({
description: 'Number of items to return per page',
type: 'number',
required: false,
example: 10,
})
@Transform(({ value }) => {
// Convert to number, handle different input types
const parsed = Number(value);
return !Number.isNaN(parsed) ? parsed : undefined;
})
@IsNumber()
@IsInt()
@Min(1) // Optional: ensure minimum limit
@IsOptional()
limit?: number;
@ApiProperty({
description: 'Number of items to skip before starting to return results',
type: 'number',
required: false,
example: 0,
})
@Transform(({ value }) => {
// Convert to number, handle different input types
const parsed = Number(value);
return !Number.isNaN(parsed) ? parsed : undefined;
})
@IsInt()
@IsNumber()
@Min(0) // Ensure non-negative offset
@IsOptional()
offset?: number;
@ApiPropertyOptional({
description: 'Direction of sorting',
enum: DirectionEnum,
enumName: 'DirectionEnum',
required: false,
})
@IsOptional()
@IsEnum(DirectionEnum)
orderDirection?: DirectionEnum;
@ApiPropertyOptional({
description: 'Field to sort the results by',
enum: allowedFields,
enumName: `${BaseClass.name}SortField`,
type: 'string',
required: false,
})
@IsOptional()
@IsString()
@IsEnum(Object.fromEntries(allowedFields.map((field) => [field, field])))
orderBy?: K;
}
return PaginationDto;
}
================================================
FILE: apps/api/src/app/shared/dtos/message-template.ts
================================================
import {
ActorTypeEnum,
IActor,
IEmailBlock,
IMessageCTA,
ITemplateVariable,
MessageTemplateContentType,
StepTypeEnum,
} from '@novu/shared';
import { IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator';
export class MessageTemplate {
@IsOptional()
@IsEnum(StepTypeEnum)
type: StepTypeEnum;
@IsOptional()
variables?: ITemplateVariable[];
@IsDefined()
content: string | IEmailBlock[];
@IsOptional()
contentType?: MessageTemplateContentType;
@IsOptional()
@ValidateNested()
cta?: IMessageCTA;
@IsOptional()
@IsString()
feedId?: string;
@IsOptional()
layoutId?: string | null;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
subject?: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
preheader?: string;
@IsOptional()
@IsString()
senderName?: string;
@IsOptional()
actor?: IActor;
@IsOptional()
_creatorId?: string;
}
================================================
FILE: apps/api/src/app/shared/dtos/message.template.dto.ts
================================================
import {
ActorTypeEnum,
IEmailBlock,
IMessageCTADto,
ITemplateVariable,
MessageTemplateContentType,
StepTypeEnum,
} from '@novu/shared';
export class MessageTemplateDto {
type: StepTypeEnum;
content: string | IEmailBlock[];
contentType?: MessageTemplateContentType;
cta?: IMessageCTADto;
actor?: {
type: ActorTypeEnum;
data: string | null;
};
variables?: ITemplateVariable[];
_feedId?: string;
_layoutId?: string | null;
name?: string;
subject?: string;
title?: string;
preheader?: string;
senderName?: string;
_creatorId?: string;
}
================================================
FILE: apps/api/src/app/shared/dtos/notification-step-dto.ts
================================================
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger';
import { StepFilterDto } from '@novu/application-generic';
import {
DaysEnum,
DelayTypeEnum,
DigestTypeEnum,
DigestUnitEnum,
IDelayRegularMetadata,
IDelayScheduledMetadata,
IDigestBaseMetadata,
IDigestRegularMetadata,
IDigestTimedMetadata,
ITimedConfig,
IWorkflowStepMetadata,
MonthlyTypeEnum,
OrdinalEnum,
OrdinalValueEnum,
StepVariantDto,
} from '@novu/shared';
import { Type } from 'class-transformer';
import { IsBoolean, IsString, ValidateNested } from 'class-validator';
import { MessageTemplate } from './message-template';
class TimedConfig implements ITimedConfig {
@ApiPropertyOptional()
atTime?: string;
@ApiPropertyOptional({ enum: [...Object.values(DaysEnum)], isArray: true })
weekDays?: DaysEnum[];
@ApiPropertyOptional()
monthDays?: number[];
@ApiPropertyOptional({ enum: [...Object.values(OrdinalEnum)] })
ordinal?: OrdinalEnum;
@ApiPropertyOptional({ enum: [...Object.values(OrdinalValueEnum)] })
ordinalValue?: OrdinalValueEnum;
@ApiPropertyOptional({ enum: [...Object.values(MonthlyTypeEnum)] })
monthlyType?: MonthlyTypeEnum;
}
class AmountAndUnit {
@ApiPropertyOptional()
amount: number;
@ApiPropertyOptional({
enum: [...Object.values(DigestUnitEnum)],
})
unit: DigestUnitEnum;
}
class DigestBaseMetadata extends AmountAndUnit implements IDigestBaseMetadata {
@ApiPropertyOptional()
digestKey?: string;
}
class DigestRegularMetadata extends DigestBaseMetadata implements IDigestRegularMetadata {
@ApiProperty({ enum: [DigestTypeEnum.REGULAR, DigestTypeEnum.BACKOFF] })
type: DigestTypeEnum.REGULAR | DigestTypeEnum.BACKOFF;
@ApiPropertyOptional()
backoff?: boolean;
@ApiPropertyOptional()
backoffAmount?: number;
@ApiPropertyOptional({
enum: [...Object.values(DigestUnitEnum)],
})
backoffUnit?: DigestUnitEnum;
@ApiPropertyOptional()
updateMode?: boolean;
}
class DigestTimedMetadata extends DigestBaseMetadata implements IDigestTimedMetadata {
@ApiProperty({
enum: [DigestTypeEnum.TIMED],
})
type: DigestTypeEnum.TIMED;
@ApiPropertyOptional()
@ValidateNested()
timed?: TimedConfig;
}
class DelayRegularMetadata extends AmountAndUnit implements IDelayRegularMetadata {
@ApiProperty({
enum: [DelayTypeEnum.REGULAR],
})
type: DelayTypeEnum.REGULAR;
}
class DelayScheduledMetadata implements IDelayScheduledMetadata {
@ApiProperty({
enum: [DelayTypeEnum.SCHEDULED],
})
type: DelayTypeEnum.SCHEDULED;
@ApiProperty()
delayPath: string;
}
// Define the ReplyCallback type with OpenAPI annotations
export class ReplyCallback {
@ApiPropertyOptional({
description: 'Indicates whether the reply callback is active.',
type: Boolean,
})
@IsBoolean()
active: boolean;
@ApiPropertyOptional({
description: 'The URL to which replies should be sent.',
type: String,
})
@IsString()
url: string;
}
@ApiExtraModels(DigestRegularMetadata, DigestTimedMetadata, DelayRegularMetadata, DelayScheduledMetadata)
export class NotificationStepData implements StepVariantDto {
@ApiPropertyOptional({
description: 'Unique identifier for the notification step.',
type: String,
})
_id?: string;
@ApiPropertyOptional({
description: 'Universally unique identifier for the notification step.',
type: String,
})
uuid?: string;
@ApiPropertyOptional({
description: 'Name of the notification step.',
type: String,
})
name?: string;
@ApiPropertyOptional({
description: 'ID of the template associated with this notification step.',
type: String,
})
_templateId?: string;
@ApiPropertyOptional({
description: 'Indicates whether the notification step is active.',
type: Boolean,
})
@IsBoolean()
active?: boolean;
@ApiPropertyOptional({
description: 'Determines if the process should stop on failure.',
type: Boolean,
})
shouldStopOnFail?: boolean;
@ApiPropertyOptional({
description: 'Message template used in this notification step.',
type: () => MessageTemplate, // Assuming MessageTemplate is a class
})
@ValidateNested()
template?: MessageTemplate;
@ApiPropertyOptional({
description: 'Filters applied to this notification step.',
type: [StepFilterDto],
})
@ValidateNested({ each: true })
filters?: StepFilterDto[];
@ApiPropertyOptional({
description: 'ID of the parent notification step, if applicable.',
type: String,
})
_parentId?: string | null;
@ApiPropertyOptional({
description: 'Metadata associated with the workflow step. Can vary based on the type of step.',
oneOf: [
{ $ref: getSchemaPath(DigestRegularMetadata) },
{ $ref: getSchemaPath(DigestTimedMetadata) },
{ $ref: getSchemaPath(DelayRegularMetadata) },
{ $ref: getSchemaPath(DelayScheduledMetadata) },
],
})
metadata?: IWorkflowStepMetadata;
@ApiPropertyOptional({
description: 'Callback information for replies, including whether it is active and the callback URL.',
type: () => ReplyCallback,
})
replyCallback?: ReplyCallback;
}
export class NotificationStepDto extends NotificationStepData {
@ApiPropertyOptional({
type: () => [NotificationStepData], // Specify that this is an array of NotificationStepData
})
@ValidateNested({ each: true }) // Validate each nested variant
@Type(() => NotificationStepData) // Transform to NotificationStepData instances
variants?: NotificationStepData[];
}
================================================
FILE: apps/api/src/app/shared/dtos/pagination-request.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IPaginationParams } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsInt, Max, Min } from 'class-validator';
import { Constructor } from '../types';
export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor {
class PaginationRequest {
@ApiPropertyOptional({
type: Number,
required: false,
example: 0,
})
@Type(() => Number)
@IsInt()
page = 0;
@ApiPropertyOptional({
type: Number,
required: false,
default: defaultLimit,
maximum: maxLimit,
example: 10,
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(maxLimit)
limit = defaultLimit;
}
return PaginationRequest;
}
================================================
FILE: apps/api/src/app/shared/dtos/pagination-response.ts
================================================
import { ApiProperty } from '@nestjs/swagger';
import { IPaginatedResponseDto } from '@novu/shared';
export class PaginatedResponseDto implements IPaginatedResponseDto {
@ApiProperty({
description: 'The current page of the paginated response',
})
page: number;
@ApiProperty({
description: 'Does the list have more items to fetch',
})
hasMore: boolean;
@ApiProperty({
description: 'Number of items on each page',
})
pageSize: number;
@ApiProperty({
description: 'The list of items matching the query',
isArray: true,
type: Object,
})
data: T[];
}
================================================
FILE: apps/api/src/app/shared/dtos/pagination-with-filters-request.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IPaginationWithQueryParams } from '@novu/shared';
import { IsOptional, IsString } from 'class-validator';
import { Constructor } from '../types';
import { PaginationRequestDto } from './pagination-request';
export function PaginationWithFiltersRequestDto({
defaultLimit = 10,
maxLimit = 100,
queryDescription,
}: {
defaultLimit: number;
maxLimit: number;
queryDescription: string;
}): Constructor {
class PaginationWithFiltersRequest extends PaginationRequestDto(defaultLimit, maxLimit) {
@ApiPropertyOptional({
type: String,
required: false,
description: `A query string to filter the results. ${queryDescription}`,
})
@IsOptional()
@IsString()
query?: string;
}
return PaginationWithFiltersRequest;
}
================================================
FILE: apps/api/src/app/shared/dtos/preference-channels.ts
================================================
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
export class SubscriberPreferenceChannels {
@ApiPropertyOptional({
type: Boolean,
description: 'Email channel preference',
example: true,
})
@IsBoolean()
@IsOptional()
email?: boolean;
@ApiPropertyOptional({
type: Boolean,
description: 'SMS channel preference',
example: false,
})
@IsBoolean()
@IsOptional()
sms?: boolean;
@ApiPropertyOptional({
type: Boolean,
description: 'In-app channel preference',
example: true,
})
@IsBoolean()
@IsOptional()
in_app?: boolean;
@ApiPropertyOptional({
type: Boolean,
description: 'Chat channel preference',
example: false,
})
@IsBoolean()
@IsOptional()
chat?: boolean;
@ApiPropertyOptional({
type: Boolean,
description: 'Push notification channel preference',
example: true,
})
@IsBoolean()
@IsOptional()
push?: boolean;
}
================================================
FILE: apps/api/src/app/shared/dtos/schedule.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsTime12HourFormat } from '../validators/is-time-12-hour-format.validator';
import { WeeklyScheduleValidation } from '../validators/weekly-schedule-disabled.validator';
export class TimeRangeDto {
@ApiProperty({
type: String,
description: 'Start time',
example: '09:00 AM',
})
@IsString()
@IsTime12HourFormat()
readonly start: string;
@ApiProperty({
type: String,
description: 'End time',
example: '05:00 PM',
})
@IsString()
@IsTime12HourFormat()
readonly end: string;
}
export class DayScheduleDto {
@ApiProperty({
type: Boolean,
description: 'Day schedule enabled',
example: true,
})
@IsBoolean()
readonly isEnabled: boolean;
@ApiPropertyOptional({
type: [TimeRangeDto],
description: 'Hours',
example: [{ start: '09:00 AM', end: '05:00 PM' }],
})
@IsOptional()
@ValidateNested()
@Type(() => TimeRangeDto)
readonly hours?: TimeRangeDto[];
}
export class WeeklyScheduleDto {
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Monday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly monday?: DayScheduleDto;
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Tuesday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly tuesday?: DayScheduleDto;
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Wednesday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly wednesday?: DayScheduleDto;
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Thursday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly thursday?: DayScheduleDto;
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Friday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly friday?: DayScheduleDto;
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Saturday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly saturday?: DayScheduleDto;
@ApiPropertyOptional({
type: DayScheduleDto,
description: 'Sunday schedule',
example: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
})
@IsOptional()
@ValidateNested()
@Type(() => DayScheduleDto)
readonly sunday?: DayScheduleDto;
}
export class ScheduleDto {
@ApiProperty({
type: Boolean,
description: 'Schedule enabled',
example: true,
})
@IsBoolean()
readonly isEnabled: boolean;
@ApiPropertyOptional({
type: WeeklyScheduleDto,
description: 'Weekly schedule',
example: {
monday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
tuesday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
wednesday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
thursday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
friday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
saturday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
sunday: {
isEnabled: true,
hours: [{ start: '09:00 AM', end: '05:00 PM' }],
},
},
})
@IsOptional()
@ValidateNested()
@Type(() => WeeklyScheduleDto)
@WeeklyScheduleValidation()
readonly weeklySchedule?: WeeklyScheduleDto;
}
================================================
FILE: apps/api/src/app/shared/dtos/subscription-details-response.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsString, ValidateNested } from 'class-validator';
import { SubscriptionPreferenceDto } from './subscriptions/create-subscriptions-response.dto';
export class SubscriptionDetailsResponseDto {
@ApiProperty({
description: 'The unique identifier of the subscription',
example: '64f5e95d3d7946d80d0cb679',
})
@IsString()
id: string;
@ApiProperty({
description: 'The identifier of the subscription',
example: 'subscription-identifier',
})
@IsString()
identifier?: string;
@ApiPropertyOptional({
description: 'The name of the subscription',
example: 'My Subscription',
})
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({
description: 'The preferences/rules for the subscription',
type: [SubscriptionPreferenceDto],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => SubscriptionPreferenceDto)
@IsOptional()
preferences?: SubscriptionPreferenceDto[];
@ApiPropertyOptional({
description: 'Context keys that scope this subscription (e.g., tenant:org-a, project:proj-123)',
example: ['tenant:org-a', 'project:proj-123'],
type: [String],
})
contextKeys?: string[];
}
================================================
FILE: apps/api/src/app/shared/dtos/subscriptions/create-subscriptions-response.dto.ts
================================================
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsDefined, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator';
import { RulesLogic } from 'json-logic-js';
import { WorkflowDto } from '../../../inbox/dtos/workflow.dto';
export class TopicDto {
@ApiProperty({
description: 'The internal unique identifier of the topic',
example: '64f5e95d3d7946d80d0cb677',
})
@IsString()
_id: string;
@ApiProperty({
description: 'The key identifier of the topic used in your application. Should be unique on the environment level.',
example: 'product-updates',
})
@IsString()
key: string;
@ApiPropertyOptional({
description: 'The name of the topic',
example: 'Product Updates',
})
@IsString()
@IsOptional()
name?: string;
}
export class SubscriberDto {
@ApiProperty({
description: 'The unique identifier of the subscriber',
example: '64f5e95d3d7946d80d0cb678',
})
@IsString()
_id: string;
@ApiProperty({
description: 'The external identifier of the subscriber',
example: 'external-subscriber-id',
})
@IsString()
subscriberId: string;
@ApiPropertyOptional({
description: 'The avatar URL of the subscriber',
example: 'https://example.com/avatar.png',
})
@IsString()
@IsOptional()
avatar?: string;
@ApiPropertyOptional({
description: 'The first name of the subscriber',
example: 'John',
})
@IsString()
@IsOptional()
firstName?: string;
@ApiPropertyOptional({
description: 'The last name of the subscriber',
example: 'Doe',
})
@IsString()
@IsOptional()
lastName?: string;
@ApiPropertyOptional({
description: 'The email of the subscriber',
example: 'john.doe@example.com',
})
@IsString()
@IsOptional()
email?: string;
@ApiPropertyOptional({
description: 'The creation date of the subscriber',
example: '2025-04-24T05:40:21Z',
})
@IsString()
@IsOptional()
createdAt?: string;
@ApiPropertyOptional({
description: 'The last update date of the subscriber',
example: '2025-04-24T05:40:21Z',
})
@IsString()
@IsOptional()
updatedAt?: string;
}
export class SubscriptionPreferenceDto {
@ApiProperty({
description: 'The unique identifier of the subscription',
example: '64f5e95d3d7946d80d0cb679',
})
@IsString()
subscriptionId: string;
@ApiPropertyOptional({
type: () => WorkflowDto,
description: 'Workflow information if this is a template-level preference',
nullable: true,
})
@IsOptional()
@ValidateNested()
@Type(() => WorkflowDto)
workflow?: WorkflowDto;
@ApiProperty({
type: Boolean,
description: 'Whether the preference is enabled',
example: true,
})
@IsDefined()
enabled: boolean;
@ApiPropertyOptional({
description: 'Optional condition using JSON Logic rules',
required: false,
type: 'object',
additionalProperties: true,
example: { and: [{ '===': [{ var: 'tier' }, 'premium'] }] },
})
@ValidateIf((o) => o.condition !== undefined)
@IsOptional()
condition?: RulesLogic;
}
export class SubscriptionResponseDto {
@ApiProperty({
description: 'The unique identifier of the subscription',
example: '64f5e95d3d7946d80d0cb679',
})
@IsString()
_id: string;
@ApiProperty({
description: 'The identifier of the subscription',
example: 'tk=product-updates:si=subscriber-123',
})
@IsString()
@IsOptional()
identifier?: string;
@ApiPropertyOptional({
description: 'The name of the subscription',
example: 'My Subscription',
})
@IsString()
@IsOptional()
name?: string;
@ApiProperty({
description: 'The topic information',
type: () => TopicDto,
})
topic: TopicDto;
@ApiProperty({
description: 'The subscriber information',
type: () => SubscriberDto,
nullable: true,
})
subscriber: SubscriberDto | null;
@ApiPropertyOptional({
description: 'The preferences for workflows in this subscription',
type: () => [SubscriptionPreferenceDto],
})
@IsArray()
@IsOptional()
preferences?: SubscriptionPreferenceDto[];
@ApiPropertyOptional({
description: 'Context keys that scope this subscription (e.g., tenant:org-a, project:proj-123)',
example: ['tenant:org-a', 'project:proj-123'],
type: [String],
})
contextKeys?: string[];
@ApiProperty({
description: 'The creation date of the subscription',
example: '2025-04-24T05:40:21Z',
})
createdAt: string;
@ApiProperty({
description: 'The last update date of the subscription',
example: '2025-04-24T05:40:21Z',
})
updatedAt: string;
}
export class SubscriptionErrorDto {
@ApiProperty({
description: 'The subscriber ID that failed',
example: 'invalid-subscriber-id',
})
subscriberId: string;
@ApiProperty({
description: 'The error code',
example: 'SUBSCRIBER_NOT_FOUND',
})
code: string;
@ApiProperty({
description: 'The error message',
example: 'Subscriber with ID invalid-subscriber-id could not be found',
})
message: string;
}
export class MetaDto {
@ApiProperty({
description: 'The total count of subscriber IDs provided',
example: 3,
})
totalCount: number;
@ApiProperty({
description: 'The count of successfully created subscriptions',
example: 2,
})
successful: number;
@ApiProperty({
description: 'The count of failed subscription attempts',
example: 1,
})
failed: number;
}
export class CreateSubscriptionsResponseDto {
@ApiProperty({
description: 'The list of successfully created subscriptions',
type: () => [SubscriptionResponseDto],
})
data: SubscriptionResponseDto[];
@ApiProperty({
description: 'Metadata about the operation',
type: MetaDto,
})
meta: MetaDto;
@ApiPropertyOptional({
description: 'The list of errors for failed subscription attempts',
type: [SubscriptionErrorDto],
})
errors?: SubscriptionErrorDto[];
}
================================================
FILE: apps/api/src/app/shared/dtos/subscriptions/create-subscriptions.dto.ts
================================================
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsDefined,
IsOptional,
IsString,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { RulesLogic } from 'json-logic-js';
export class TopicSubscriberIdentifierDto {
@ApiProperty({
description: 'Unique identifier for this subscription',
example: 'subscriber-123-subscription-a',
})
@IsString()
@IsDefined()
identifier: string;
@ApiProperty({
description: 'The subscriber ID',
example: 'subscriber-123',
})
@IsString()
@IsDefined()
subscriberId: string;
@ApiPropertyOptional({
description: 'The name of the subscription',
example: 'My Subscription',
})
@IsString()
@IsOptional()
name?: string;
}
export class BasePreferenceDto {
@ApiProperty({
description: 'Whether the preference is enabled. Used when condition is not provided.',
required: false,
type: Boolean,
example: true,
})
@IsOptional()
enabled?: boolean;
@ApiProperty({
description: 'Optional condition using JSON Logic rules',
required: false,
type: 'object',
additionalProperties: true,
example: { and: [{ '===': [{ var: 'tier' }, 'premium'] }] },
})
@ValidateIf((o) => o.condition !== undefined)
@IsOptional()
condition?: RulesLogic;
}
export class WorkflowPreferenceRequestDto extends BasePreferenceDto {
@ApiProperty({
description: 'The workflow identifier',
example: 'workflow-123',
})
@IsString()
@IsDefined()
workflowId: string;
}
export class GroupPreferenceFilterDetailsDto {
@ApiProperty({
description: 'List of workflow identifiers',
type: [String],
example: ['workflow-1', 'workflow-2'],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
workflowIds?: string[];
@ApiProperty({
description: 'List of tags',
type: [String],
example: ['tag1', 'tag2'],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
export class GroupPreferenceFilterDto extends BasePreferenceDto {
@ApiProperty({
description: 'Filter criteria for workflow IDs and tags',
type: GroupPreferenceFilterDetailsDto,
})
@ValidateNested()
@Type(() => GroupPreferenceFilterDetailsDto)
@IsDefined()
filter: GroupPreferenceFilterDetailsDto;
}
@ApiExtraModels(WorkflowPreferenceRequestDto, GroupPreferenceFilterDto, TopicSubscriberIdentifierDto)
export class CreateSubscriptionsRequestDto {
@ApiProperty({
description:
'List of subscriber IDs to subscribe to the topic (max: 100). @deprecated Use the "subscriptions" property instead.',
type: [String],
example: ['subscriberId1', 'subscriberId2'],
deprecated: true,
})
@IsArray()
@IsString({ each: true })
@IsOptional()
@ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscribers at once' })
@ArrayMinSize(1, { message: 'At least one subscriber identifier is required' })
subscriberIds?: string[];
@ApiProperty({
description:
'List of subscriptions to subscribe to the topic (max: 100). Can be either a string array of subscriber IDs or an array of objects with identifier and subscriberId',
type: 'array',
items: {
oneOf: [{ type: 'string' }, { $ref: getSchemaPath(TopicSubscriberIdentifierDto) }],
},
example: [
{ identifier: 'subscriber-123-subscription-a', subscriberId: 'subscriber-123' },
{ identifier: 'subscriber-456-subscription-b', subscriberId: 'subscriber-456' },
],
})
@IsArray()
@IsOptional()
@ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscriptions at once' })
@ArrayMinSize(1, { message: 'At least one subscription is required' })
subscriptions?: Array;
@ApiProperty({
description: 'The name of the topic',
example: 'My Topic',
})
@IsString()
@IsOptional()
name?: string;
@ApiProperty({
description:
'The preferences of the topic. Can be a simple workflow ID string, workflow preference object, or group filter object',
type: 'array',
items: {
oneOf: [
{ type: 'string' },
{ $ref: getSchemaPath(WorkflowPreferenceRequestDto) },
{ $ref: getSchemaPath(GroupPreferenceFilterDto) },
],
},
example: [{ workflowId: 'workflow-123', condition: { '===': [{ var: 'tier' }, 'premium'] } }],
})
@IsArray()
@IsOptional()
preferences?: Array;
}
================================================
FILE: apps/api/src/app/shared/dtos/subscriptions/update-subscription.dto.ts
================================================
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { GroupPreferenceFilterDto, WorkflowPreferenceRequestDto } from './create-subscriptions.dto';
@ApiExtraModels(WorkflowPreferenceRequestDto, GroupPreferenceFilterDto)
export class UpdateSubscriptionRequestDto {
@ApiProperty({
description: 'The name of the subscription',
example: 'My Subscription',
})
@IsString()
@IsOptional()
name?: string;
@ApiProperty({
description:
'The preferences of the subscription. Can be a simple workflow ID string, workflow preference object, or group filter object',
type: 'array',
items: {
oneOf: [
{ type: 'string' },
{ $ref: getSchemaPath(WorkflowPreferenceRequestDto) },
{ $ref: getSchemaPath(GroupPreferenceFilterDto) },
],
},
example: [{ workflowId: 'workflow-123', condition: { '===': [{ var: 'tier' }, 'premium'] } }],
})
@IsArray()
@IsOptional()
preferences?: Array;
}
================================================
FILE: apps/api/src/app/shared/framework/analytics-logs.guard.ts
================================================
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
const LOG_ANALYTICS_KEY = 'logAnalytics';
/**
* Analytics Logs Guard
*
* This guard sets the `_shouldLogAnalytics` flag on incoming requests early in the NestJS lifecycle.
* It runs BEFORE interceptors, ensuring the flag is available even if interceptors throw exceptions.
*
* Why use a Guard instead of an Interceptor?
* - Guards execute before interceptors in the NestJS request lifecycle
* - If any interceptor throws an exception, subsequent interceptors never run, so the flag
* cannot be reliably set by an interceptor that might not execute
* - By setting the flag in a guard, AllExceptionsFilter can always check for analytics logging
* regardless of which interceptor threw the exception
* - AllExceptionsFilter cannot access decorator metadata directly since it operates outside
* the normal request lifecycle and doesn't have access to the original ExecutionContext
*
* Example execution order:
* 1. Guard runs → sets _shouldLogAnalytics = true
* 2. QuotaThrottlerInterceptor runs → throws exception
* 3. AllExceptionsFilter runs → finds _shouldLogAnalytics = true → logs analytics
*/
@Injectable()
export class AnalyticsLogsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const shouldLogAnalytics = this.shouldLogAnalytics(context);
if (shouldLogAnalytics) {
const request = context.switchToHttp().getRequest();
request._shouldLogAnalytics = true;
}
// Always return true - this guard never blocks requests, it only sets metadata
return true;
}
private shouldLogAnalytics(context: ExecutionContext): boolean {
// Check if @LogAnalytics() decorator is present on the handler or controller
const handlerMetadata = this.reflector.get(LOG_ANALYTICS_KEY, context.getHandler());
const classMetadata = this.reflector.get(LOG_ANALYTICS_KEY, context.getClass());
return handlerMetadata !== undefined || classMetadata !== undefined;
}
}
================================================
FILE: apps/api/src/app/shared/framework/analytics-logs.interceptor.ts
================================================
import {
applyDecorators,
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PinoLogger, RequestLog, RequestLogRepository } from '@novu/application-generic';
import { UserSessionData } from '@novu/shared';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { TriggerEventResponseDto } from '../../events/dtos/trigger-event-response.dto';
import { buildLog } from '../utils/mappers';
const LOG_ANALYTICS_KEY = 'logAnalytics';
export enum AnalyticsStrategyEnum {
BASIC = 'basic',
EVENTS = 'events',
EVENTS_BULK = 'events_bulk',
}
export function LogAnalytics(strategy: AnalyticsStrategyEnum = AnalyticsStrategyEnum.BASIC): MethodDecorator {
return applyDecorators(SetMetadata(LOG_ANALYTICS_KEY, strategy));
}
@Injectable()
export class AnalyticsLogsInterceptor implements NestInterceptor {
constructor(
private readonly requestLogRepository: RequestLogRepository,
private readonly logger: PinoLogger,
private readonly reflector: Reflector
) {
this.logger.setContext(this.constructor.name);
}
private shouldLogAnalytics(context: ExecutionContext): boolean {
const strategy = this.getAnalyticsStrategy(context);
this.logger.debug(`Analytics logs should log strategy: ${strategy}`);
return strategy !== undefined;
}
private getAnalyticsStrategy(context: ExecutionContext): AnalyticsStrategyEnum {
const globalHandler = context.getHandler && Reflect.getMetadata(LOG_ANALYTICS_KEY, context.getHandler());
const handlerMetadata = this.reflector.get(LOG_ANALYTICS_KEY, context.getHandler());
const handler = context.getHandler();
const customDecorator = handler && (handler as any)._analyticsStrategy;
this.logger.debug(`Analytics logs globalHandler strategy: ${globalHandler}`);
this.logger.debug(`Analytics logs handlerMetadata strategy: ${handlerMetadata}`);
this.logger.debug(`Analytics logs customDecorator strategy: ${customDecorator}`);
return globalHandler || handlerMetadata || customDecorator;
}
async intercept(context: ExecutionContext, next: CallHandler): Promise> {
const shouldRun = await this.shouldRun(context);
this.logger.debug(`Analytics logs should run LOG_ANALYTICS_KEY: ${shouldRun}`);
if (!shouldRun) {
return next.handle();
}
const req = context.switchToHttp().getRequest();
const user = req.user as UserSessionData;
const start = Date.now();
const res = context.switchToHttp().getResponse();
this.logger.debug('Analytics logs interceptor started');
return next.handle().pipe(
tap(async (data) => {
const duration = Date.now() - start;
const basicLog = buildLog(req, res.statusCode, data, user, duration);
if (!basicLog) {
this.logger.warn('Analytics log construction failed - unable to track request metrics');
return;
}
const analyticsLog = this.buildLogByStrategy(context, basicLog, data);
try {
this.logger.debug({ analyticsLog }, 'Analytics log Inserting');
await this.requestLogRepository.create(analyticsLog, {
organizationId: user?.organizationId,
environmentId: user?.environmentId,
userId: user?._id,
});
this.logger.debug('Analytics log Inserted');
} catch (err) {
this.logger.error({ err }, 'Failed to log analytics to ClickHouse after retries');
}
})
);
}
private async shouldRun(context: ExecutionContext): Promise {
const shouldLog = this.shouldLogAnalytics(context);
if (!shouldLog) return false;
const isEnabled = process.env.IS_ANALYTICS_LOGS_ENABLED === 'true';
this.logger.debug(
`Analytics logs should run IS_ANALYTICS_LOGS_ENABLED: ${process.env.IS_ANALYTICS_LOGS_ENABLED}, isEnabled: ${isEnabled}`
);
if (!isEnabled) return false;
return true;
}
private buildLogByStrategy(
context: ExecutionContext,
analyticsLog: Omit,
res: unknown
): Omit {
const strategy = this.getAnalyticsStrategy(context);
if (strategy === AnalyticsStrategyEnum.EVENTS) {
const eventResponse = (res as any).data as TriggerEventResponseDto;
if (eventResponse.transactionId) {
return {
...analyticsLog,
transaction_id: eventResponse.transactionId,
};
}
}
if (strategy === AnalyticsStrategyEnum.EVENTS_BULK) {
const bulkEventResponse = (res as any).data as TriggerEventResponseDto[];
if (Array.isArray(bulkEventResponse)) {
const transactionIds = bulkEventResponse
.map((response) => response.transactionId)
.filter(Boolean)
.join(',');
if (transactionIds) {
return {
...analyticsLog,
transaction_id: transactionIds,
};
}
}
}
return analyticsLog;
}
}
================================================
FILE: apps/api/src/app/shared/framework/constants/headers.schema.ts
================================================
import { HeaderObject, HttpResponseHeaderKeysEnum } from '@novu/application-generic';
export const COMMON_RESPONSE_HEADERS: Array = [
HttpResponseHeaderKeysEnum.CONTENT_TYPE,
HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT,
HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING,
HttpResponseHeaderKeysEnum.RATELIMIT_RESET,
HttpResponseHeaderKeysEnum.RATELIMIT_POLICY,
HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY,
HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY,
];
export const RESPONSE_HEADER_CONFIG: Record = {
[HttpResponseHeaderKeysEnum.CONTENT_TYPE]: {
required: true,
description: 'The MIME type of the response body.',
schema: { type: 'string' },
example: 'application/json',
},
[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT]: {
required: false,
description:
'The number of requests that the client is permitted to make per second. The actual maximum may differ when burst is enabled.',
schema: { type: 'string' },
example: '100',
},
[HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING]: {
required: false,
description: 'The number of requests remaining until the next window.',
schema: { type: 'string' },
example: '93',
},
[HttpResponseHeaderKeysEnum.RATELIMIT_RESET]: {
required: false,
description: 'The remaining seconds until a request of the same cost will be refreshed.',
schema: { type: 'string' },
example: '8',
},
[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY]: {
required: false,
description: 'The rate limit policy that was used to evaluate the request.',
schema: { type: 'string' },
example: '100;w=1;burst=110;comment="token bucket";category="trigger";cost="single"',
},
[HttpResponseHeaderKeysEnum.RETRY_AFTER]: {
required: false,
description: 'The number of seconds after which the client may retry the request that was previously rejected.',
schema: { type: 'string' },
example: '8',
},
[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: {
required: false,
description: 'The idempotency key used to evaluate the request.',
schema: { type: 'string' },
example: '8',
},
[HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY]: {
required: false,
description: 'Whether the request was a replay of a previous request.',
schema: { type: 'string' },
example: 'true',
},
[HttpResponseHeaderKeysEnum.LINK]: {
required: false,
description: 'A link to the documentation.',
schema: { type: 'string' },
example: 'https://docs.novu.co/',
},
};
================================================
FILE: apps/api/src/app/shared/framework/constants/index.ts
================================================
export * from './headers.schema';
export * from './responses.schema';
================================================
FILE: apps/api/src/app/shared/framework/constants/responses.schema.ts
================================================
import { ApiResponseOptions } from '@nestjs/swagger';
import { ApiResponseDecoratorName, HttpResponseHeaderKeysEnum } from '@novu/application-generic';
import { THROTTLED_EXCEPTION_MESSAGE } from '../../../rate-limiting/guards';
import { createReusableHeaders } from '../swagger';
export const COMMON_RESPONSES: Partial> = {
ApiConflictResponse: {
description: 'The request could not be completed due to a conflict with the current state of the target resource.',
schema: {
type: 'string',
example:
'Request with key 3909d656-d4fe-4e80-ba86-90d3861afcd7 is currently being processed. Please retry after 1 second',
},
headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER, HttpResponseHeaderKeysEnum.LINK]),
},
ApiTooManyRequestsResponse: {
description: 'The client has sent too many requests in a given amount of time. ',
schema: { type: 'string', example: THROTTLED_EXCEPTION_MESSAGE },
headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER]),
},
ApiServiceUnavailableResponse: {
description:
'The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.',
schema: { type: 'string', example: 'Please wait some time, then try again.' },
headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER]),
},
};
================================================
FILE: apps/api/src/app/shared/framework/exclude-from-idempotency.ts
================================================
import { applyDecorators, SetMetadata } from '@nestjs/common';
export const EXCLUDE_FROM_IDEMPOTENCY = 'exclude_from_idempotency';
export function ExcludeFromIdempotency() {
return applyDecorators(SetMetadata(EXCLUDE_FROM_IDEMPOTENCY, true));
}
================================================
FILE: apps/api/src/app/shared/framework/idempotency.e2e.ts
================================================
import { CacheService, HttpResponseHeaderKeysEnum } from '@novu/application-generic';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import {
IdempotenceTestingResponse,
IdempotencyBehaviorEnum,
IdempotencyTestingDto,
} from '../../testing/dtos/idempotency.dto';
import { expectSdkExceptionGeneric } from '../helpers/e2e/sdk/e2e-sdk.helper';
const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency';
// @ts-ignore
process.env.LAUNCH_DARKLY_SDK_KEY = ''; // disable Launch Darkly to allow test to define FF state
const idempotancyKey = HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase();
const retryAfterHeaderKey = HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase();
const IDEMPOTENCE_IMMEDIATE_EXCEPTION = {
expectedBehavior: IdempotencyBehaviorEnum.IMMEDIATE_EXCEPTION,
};
const IDEMPOTENCE_IMMEDIATE_RESPONSE = {
expectedBehavior: IdempotencyBehaviorEnum.IMMEDIATE_RESPONSE,
};
const IDEMPOTENCE_DELAYED_RESPONSE = {
expectedBehavior: IdempotencyBehaviorEnum.DELAYED_RESPONSE,
};
const idempotancyReplayKey = HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY.toLowerCase();
describe('Idempotency Test', async () => {
let session: UserSession;
const path = '/v1/health-check/test-idempotency';
let cacheService: CacheService | null = null;
async function testIdempotencyPost(
idempotencyTestingDto: IdempotencyTestingDto,
key: string
): Promise<{ body: IdempotenceTestingResponse; headers: Record }> {
const { body, headers } = await session.testAgent
.post(path)
.set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send(idempotencyTestingDto);
return { body: body.data, headers };
}
async function testIdempotencyGet(
key: string
): Promise<{ body: IdempotenceTestingResponse; headers: Record }> {
const { body, headers } = await session.testAgent
.get(path)
.set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.set('authorization', `ApiKey ${session.apiKey}`)
.send();
return { body: body.data, headers };
}
before(async () => {
session = new UserSession();
await session.initialize();
cacheService = session.testServer?.getService(CacheService);
process.env.IS_API_IDEMPOTENCY_ENABLED = 'true';
});
it('should return cached same response for duplicate requests', async () => {
const key = `IdempotencyKey1`;
const res1 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
const res2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
expect(res1.body.number).to.equal(res2.body.number);
expect(res1.headers[idempotancyKey]).to.eq(key);
expect(res2.headers[idempotancyKey]).to.eq(key);
expect(res2.headers[idempotancyReplayKey]).to.eq('true');
});
it('should return cached and use correct cache key when apiKey is used', async () => {
const key = `IdempotencyKey2`;
const res1 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
const cacheKey = `test-${session.organization._id}-${key}`;
session.testServer?.getHttpServer();
const cacheVal = JSON.stringify(JSON.parse(await cacheService?.get(cacheKey)!).data);
expect(res1.body.number, cacheVal).to.eq(JSON.parse(cacheVal).data.number);
const res2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
expect(res1.body.number).to.equal(res2.body.number);
expect(res1.headers[idempotancyKey]).to.eq(key);
expect(res2.headers[idempotancyKey]).to.eq(key);
expect(res2.headers[idempotancyReplayKey]).to.eq('true');
});
it('should return cached and use correct cache key when authToken and apiKey combination is used', async () => {
const key = `3`;
const res1 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
const cacheKey = `test-${session.organization._id}-${key}`;
session.testServer?.getHttpServer();
const cacheVal = JSON.stringify(JSON.parse(await cacheService?.get(cacheKey)!).data);
expect(res1.body.number).to.eq(JSON.parse(cacheVal).data.number);
const res2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
expect(res1.body.number).to.equal(res2.body.number);
expect(res1.headers[idempotancyKey]).to.eq(key);
expect(res2.headers[idempotancyKey]).to.eq(key);
expect(res2.headers[idempotancyReplayKey]).to.eq('true');
});
it('should return conflict when concurrent requests are made', async () => {
const key = `4`;
const [{ headers, body, status }, { headers: headerDupe, body: bodyDupe, status: statusDupe }] = await Promise.all([
session.testAgent
.post(path)
.set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.send(IDEMPOTENCE_DELAYED_RESPONSE),
session.testAgent
.post(path)
.set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key)
.send(IDEMPOTENCE_DELAYED_RESPONSE),
]);
const oneSuccess = status === 201 || statusDupe === 201;
const oneConflict = status === 409 || statusDupe === 409;
const conflictBody = status === 201 ? bodyDupe : body;
const retryHeader = headers[retryAfterHeaderKey] || headerDupe[retryAfterHeaderKey];
expect(oneSuccess).to.be.true;
expect(oneConflict).to.be.true;
expect(headers[idempotancyKey]).to.eq(key);
expect(headerDupe[idempotancyKey], JSON.stringify(headerDupe)).to.eq(key);
expect(headerDupe[HttpResponseHeaderKeysEnum.LINK.toLowerCase()], JSON.stringify(headerDupe)).to.eq(DOCS_LINK);
expect(retryHeader).to.eq(`1`);
expect(conflictBody.message).to.eq(
`Request with key "${key}" is currently being processed. Please retry after 1 second`
);
expect(conflictBody.error).to.eq('Conflict');
expect(conflictBody.statusCode).to.eq(409);
});
it('should return UnprocessableEntity when different body is sent for same key', async () => {
const key = '5';
await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
const { error } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key));
expect(error?.statusCode).to.eq(422);
});
it('should return non cached response for unique requests', async () => {
const key = '6';
const key1 = '7';
const response = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
const response2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key1);
expect(response.body.number).to.not.eq(response2.body.number);
expect(response.headers[idempotancyKey]).to.eq(key);
expect(response2.headers[idempotancyKey]).to.eq(key1);
});
it('should return non cached response for GET requests', async () => {
const key = '8';
const response = await testIdempotencyGet(key);
const response2 = await testIdempotencyGet(key);
expect(response.body.number).to.not.eq(response2.body.number);
});
it('should return cached error response for duplicate requests', async () => {
const key = '9';
const { error } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key));
const { error: error2 } = await expectSdkExceptionGeneric(() =>
testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key)
);
expect(error?.message).to.eq(error2?.message);
});
it('should return 400 when key bigger than allowed limit', async () => {
const key = Array.from({ length: 256 })
.fill(0)
.map((i) => i)
.join('');
const { error } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key));
expect(error?.statusCode).to.eq(400);
expect(error?.message).to.include(`has exceeded`);
});
describe('Allowed Authentication Security Schemes', () => {
it('should set Idempotency-Key header when ApiKey security scheme is used to authenticate', async () => {
const key = '10';
const { headers } = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
expect(headers[idempotancyKey]).to.exist;
});
it('should set rate limit headers when a Bearer security scheme is used to authenticate', async () => {
const key = '10';
const { headers } = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
expect(headers[idempotancyKey]).to.exist;
});
it('should NOT set rate limit headers when NO authorization header is present', async () => {
const key = '10';
const { headers } = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key);
expect(headers[idempotancyKey]).not.to.exist;
});
});
});
================================================
FILE: apps/api/src/app/shared/framework/idempotency.interceptor.ts
================================================
import {
BadRequestException,
CallHandler,
ConflictException,
ExecutionContext,
HttpException,
Injectable,
InternalServerErrorException,
NestInterceptor,
ServiceUnavailableException,
UnprocessableEntityException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
CacheService,
FeatureFlagsService,
HttpResponseHeaderKeysEnum,
Instrument,
PinoLogger,
} from '@novu/application-generic';
import { ApiAuthSchemeEnum, FeatureFlagsKeysEnum, UserSessionData } from '@novu/shared';
import { createHash } from 'crypto';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { EXCLUDE_FROM_IDEMPOTENCY } from './exclude-from-idempotency';
const IDEMPOTENCY_CACHE_TTL = 60 * 60 * 24; // 24h
const IDEMPOTENCY_PROGRESS_TTL = 60 * 5; // 5min
enum ReqStatusEnum {
PROGRESS = 'in-progress',
SUCCESS = 'success',
ERROR = 'error',
}
export const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency';
export const ALLOWED_AUTH_SCHEMES = [ApiAuthSchemeEnum.API_KEY];
const ALLOWED_METHODS = ['post', 'patch'];
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly cacheService: CacheService,
private featureFlagService: FeatureFlagsService,
private logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
}
protected async isEnabled(context: ExecutionContext): Promise {
const isExcluded = this.reflector.getAllAndOverride(EXCLUDE_FROM_IDEMPOTENCY, [
context.getHandler(),
context.getClass(),
]);
if (isExcluded) {
return false;
}
const isAllowedAuthScheme = this.isAllowedAuthScheme(context);
if (!isAllowedAuthScheme) {
return true;
}
const user = this.getReqUser(context);
const { organizationId, environmentId, _id } = user;
return await this.featureFlagService.getFlag({
key: FeatureFlagsKeysEnum.IS_API_IDEMPOTENCY_ENABLED,
defaultValue: false,
environment: { _id: environmentId },
organization: { _id: organizationId },
user: { _id },
});
}
@Instrument()
async intercept(context: ExecutionContext, next: CallHandler): Promise> {
const request = context.switchToHttp().getRequest();
const isAllowedMethod = ALLOWED_METHODS.includes(request.method.toLowerCase());
const idempotencyKey = this.getIdempotencyKey(context);
const isEnabled = await this.isEnabled(context);
if (!idempotencyKey || !isAllowedMethod || !isEnabled) {
return next.handle();
}
if (idempotencyKey?.length > 255) {
return throwError(
() =>
new BadRequestException(
`idempotencyKey "${idempotencyKey}" has exceeded the maximum allowed length of 255 characters`
)
);
}
const cacheKey = this.getCacheKey(context);
try {
const bodyHash = this.hashRequestBody(request.body);
// if 1st time we are seeing the request, marks the request as in-progress if not, does nothing
const isNewReq = await this.setCache(
cacheKey,
{ status: ReqStatusEnum.PROGRESS, bodyHash },
IDEMPOTENCY_PROGRESS_TTL,
true
);
// Check if the idempotency key is in the cache
if (isNewReq) {
return await this.handleNewRequest(context, next, bodyHash);
} else {
return await this.handlerDuplicateRequest(context, bodyHash);
}
} catch (err) {
this.logger.warn(
`An error occurred while making idempotency check, key:${idempotencyKey}. error: ${err.message}`
);
if (err instanceof HttpException) {
return throwError(() => err);
}
}
// something unexpected happened, both cached response and handler did not execute as expected
return throwError(() => new ServiceUnavailableException());
}
private getIdempotencyKey(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
return request.headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLocaleLowerCase()];
}
private getReqUser(context: ExecutionContext): UserSessionData {
const req = context.switchToHttp().getRequest();
return req.user;
}
private isAllowedAuthScheme(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const { authScheme } = req;
return ALLOWED_AUTH_SCHEMES.some((scheme) => authScheme === scheme);
}
private getCacheKey(context: ExecutionContext): string {
const user = this.getReqUser(context);
if (user === undefined) {
const message = 'Cannot build idempotency cache key without user';
this.logger.error(message);
throw new InternalServerErrorException(message);
}
const env = process.env.NODE_ENV;
return `${env}-${user.organizationId}-${this.getIdempotencyKey(context)}`;
}
async setCache(
key: string,
val: { status: ReqStatusEnum; bodyHash: string; data?: any; statusCode?: number },
ttl: number,
ifNotExists?: boolean
): Promise {
try {
if (ifNotExists) {
return await this.cacheService.setIfNotExist(key, JSON.stringify(val), { ttl });
}
await this.cacheService.set(key, JSON.stringify(val), { ttl });
} catch (err) {
this.logger.warn(`An error occurred while setting idempotency cache, key:${key} error: ${err.message}`);
}
return null;
}
private setHeaders(response: any, headers: Record) {
Object.keys(headers).forEach((key) => {
if (headers[key]) {
response.set(key, headers[key]);
}
});
}
private hashRequestBody(body: object): string {
const hash = createHash('blake2s256');
try {
hash.update(Buffer.from(JSON.stringify(body)));
} catch (error) {
// For multipart/form-data or other non-serializable bodies,
// create a hash from the object's string representation
hash.update(Buffer.from(String(body)));
}
return hash.digest('hex');
}
private async handlerDuplicateRequest(context: ExecutionContext, bodyHash: string): Promise> {
const cacheKey = this.getCacheKey(context);
const idempotencyKey = this.getIdempotencyKey(context)!;
const data = await this.cacheService.get(cacheKey);
this.setHeaders(context.switchToHttp().getResponse(), {
[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey,
});
const parsed = JSON.parse(data);
if (parsed.status === ReqStatusEnum.PROGRESS) {
// api call is in progress, so client need to handle this case
this.logger.trace(`previous api call in progress rejecting the request. key: "${idempotencyKey}"`);
this.setHeaders(context.switchToHttp().getResponse(), {
[HttpResponseHeaderKeysEnum.RETRY_AFTER]: `1`,
[HttpResponseHeaderKeysEnum.LINK]: DOCS_LINK,
});
throw new ConflictException(
`Request with key "${idempotencyKey}" is currently being processed. Please retry after 1 second`
);
}
if (bodyHash !== parsed.bodyHash) {
// different body sent than before
this.logger.trace(`idempotency key is being reused for different bodies. key: "${idempotencyKey}"`);
this.setHeaders(context.switchToHttp().getResponse(), {
[HttpResponseHeaderKeysEnum.LINK]: DOCS_LINK,
});
throw new UnprocessableEntityException(
`Request with key "${idempotencyKey}" is being reused for a different body`
);
}
this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY]: 'true' });
// already seen the request return cached response
if (parsed.status === ReqStatusEnum.ERROR) {
this.logger.trace(`returning cached error response. key: "${idempotencyKey}"`);
throw parsed.data;
}
return of(parsed.data);
}
private async handleNewRequest(
context: ExecutionContext,
next: CallHandler,
bodyHash: string
): Promise> {
const cacheKey = this.getCacheKey(context);
const idempotencyKey = this.getIdempotencyKey(context)!;
return next.handle().pipe(
map(async (response) => {
const httpResponse = context.switchToHttp().getResponse();
const { statusCode } = httpResponse;
// Cache the success response and return it
await this.setCache(
cacheKey,
{ status: ReqStatusEnum.SUCCESS, bodyHash, statusCode, data: response },
IDEMPOTENCY_CACHE_TTL
);
this.logger.trace(`cached the success response for idempotency key: "${idempotencyKey}"`);
this.setHeaders(httpResponse, { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey });
return response;
}),
catchError((err) => {
this.setCache(
cacheKey,
{
status: ReqStatusEnum.ERROR,
bodyHash,
data: err,
},
IDEMPOTENCY_CACHE_TTL
).catch(() => {});
this.logger.trace(`cached the error response for idempotency key: "${idempotencyKey}"`);
this.setHeaders(context.switchToHttp().getResponse(), {
[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey,
});
throw err;
})
);
}
}
================================================
FILE: apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts
================================================
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, getSchemaPath } from '@nestjs/swagger';
import { PaginatedResponseDto } from '../dtos/pagination-response';
import { ApiOkResponse } from './response.decorator';
export const ApiOkPaginatedResponse = >(dataDto: DataDto) =>
applyDecorators(
ApiExtraModels(PaginatedResponseDto, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedResponseDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
})
);
================================================
FILE: apps/api/src/app/shared/framework/response.decorator.ts
================================================
import { applyDecorators, Type } from '@nestjs/common';
import {
ApiExpectationFailedResponse,
ApiExtraModels,
ApiHttpVersionNotSupportedResponse,
ApiLengthRequiredResponse,
ApiNonAuthoritativeInformationResponse,
ApiNotModifiedResponse,
ApiPartialContentResponse,
ApiPaymentRequiredResponse,
ApiPermanentRedirectResponse,
ApiProxyAuthenticationRequiredResponse,
ApiRequestedRangeNotSatisfiableResponse,
ApiResetContentResponse,
ApiResponseOptions,
ApiSeeOtherResponse,
ApiUriTooLongResponse,
getSchemaPath,
} from '@nestjs/swagger';
import { ErrorDto, ValidationErrorDto } from '../../../error-dto';
import { DataWrapperDto } from '../dtos/data-wrapper-dto';
import { COMMON_RESPONSES } from './constants/responses.schema';
import { customResponseDecorators } from './swagger/responses.decorator';
export const { ApiOkResponse }: { ApiOkResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const { ApiCreatedResponse }: { ApiCreatedResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const { ApiAcceptedResponse }: { ApiAcceptedResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const { ApiNoContentResponse }: { ApiNoContentResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const {
ApiMovedPermanentlyResponse,
}: { ApiMovedPermanentlyResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiTemporaryRedirectResponse,
}: { ApiTemporaryRedirectResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const { ApiFoundResponse }: { ApiFoundResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const { ApiBadRequestResponse }: { ApiBadRequestResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const {
ApiUnauthorizedResponse,
}: { ApiUnauthorizedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiTooManyRequestsResponse,
}: { ApiTooManyRequestsResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const { ApiNotFoundResponse }: { ApiNotFoundResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const {
ApiInternalServerErrorResponse,
}: { ApiInternalServerErrorResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const { ApiBadGatewayResponse }: { ApiBadGatewayResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const { ApiConflictResponse }: { ApiConflictResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const { ApiForbiddenResponse }: { ApiForbiddenResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const {
ApiGatewayTimeoutResponse,
}: { ApiGatewayTimeoutResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const { ApiGoneResponse }: { ApiGoneResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
export const {
ApiMethodNotAllowedResponse,
}: { ApiMethodNotAllowedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiNotAcceptableResponse,
}: { ApiNotAcceptableResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiNotImplementedResponse,
}: { ApiNotImplementedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiPreconditionFailedResponse,
}: { ApiPreconditionFailedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiPayloadTooLargeResponse,
}: { ApiPayloadTooLargeResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiRequestTimeoutResponse,
}: { ApiRequestTimeoutResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiServiceUnavailableResponse,
}: { ApiServiceUnavailableResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiUnprocessableEntityResponse,
}: { ApiUnprocessableEntityResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const {
ApiUnsupportedMediaTypeResponse,
}: { ApiUnsupportedMediaTypeResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators;
export const { ApiDefaultResponse }: { ApiDefaultResponse: (options?: ApiResponseOptions) => MethodDecorator } =
customResponseDecorators;
function buildEnvelopeProperties>(isResponseArray: boolean, dataDto: DataDto) {
if (isResponseArray) {
return {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
};
} else {
return { data: { $ref: getSchemaPath(dataDto) } };
}
}
function buildSchema>(
shouldEnvelope: boolean,
isResponseArray: boolean,
dataDto: DataDto
) {
if (shouldEnvelope) {
return {
properties: buildEnvelopeProperties(isResponseArray, dataDto),
};
}
return { $ref: getSchemaPath(dataDto) };
}
export const ApiResponse = >(
dataDto: DataDto,
statusCode: number = 200,
isResponseArray = false,
shouldEnvelope = true,
options?: ApiResponseOptions
) => {
let responseDecoratorFunction;
let description = 'Ok'; // Default description
switch (statusCode) {
// 2XX Success
case 200:
responseDecoratorFunction = ApiOkResponse;
description = 'OK';
break;
case 201:
responseDecoratorFunction = ApiCreatedResponse;
description = 'Created';
break;
case 202:
responseDecoratorFunction = ApiAcceptedResponse;
description = 'Accepted';
break;
case 203:
responseDecoratorFunction = ApiNonAuthoritativeInformationResponse;
description = 'Non-Authoritative Information';
break;
case 204:
responseDecoratorFunction = ApiNoContentResponse;
description = 'No Content';
break;
case 205:
responseDecoratorFunction = ApiResetContentResponse;
description = 'Reset Content';
break;
case 206:
responseDecoratorFunction = ApiPartialContentResponse;
description = 'Partial Content';
break;
// 3XX Redirection
case 301:
responseDecoratorFunction = ApiMovedPermanentlyResponse;
description = 'Moved Permanently';
break;
case 302:
responseDecoratorFunction = ApiFoundResponse;
description = 'Found';
break;
case 303:
responseDecoratorFunction = ApiSeeOtherResponse;
description = 'See Other';
break;
case 304:
responseDecoratorFunction = ApiNotModifiedResponse;
description = 'Not Modified';
break;
case 305:
responseDecoratorFunction = ApiProxyAuthenticationRequiredResponse;
description = 'Use Proxy';
break;
case 307:
responseDecoratorFunction = ApiTemporaryRedirectResponse;
description = 'Temporary Redirect';
break;
case 308:
responseDecoratorFunction = ApiPermanentRedirectResponse;
description = 'Permanent Redirect';
break;
// 4XX Client Errors
case 400:
responseDecoratorFunction = ApiBadRequestResponse;
description = 'Bad Request';
break;
case 401:
responseDecoratorFunction = ApiUnauthorizedResponse;
description = 'Unauthorized';
break;
case 402:
responseDecoratorFunction = ApiPaymentRequiredResponse;
description = 'Payment Required';
break;
case 403:
responseDecoratorFunction = ApiForbiddenResponse;
description = 'Forbidden';
break;
case 404:
responseDecoratorFunction = ApiNotFoundResponse;
description = 'Not Found';
break;
case 405:
responseDecoratorFunction = ApiMethodNotAllowedResponse;
description = 'Method Not Allowed';
break;
case 406:
responseDecoratorFunction = ApiNotAcceptableResponse;
description = 'Not Acceptable';
break;
case 407:
responseDecoratorFunction = ApiProxyAuthenticationRequiredResponse;
description = 'Proxy Authentication Required';
break;
case 408:
responseDecoratorFunction = ApiRequestTimeoutResponse;
description = 'Request Timeout';
break;
case 409:
responseDecoratorFunction = ApiConflictResponse;
description = 'Conflict';
break;
case 410:
responseDecoratorFunction = ApiGoneResponse;
description = 'Gone';
break;
case 411:
responseDecoratorFunction = ApiLengthRequiredResponse;
description = 'Length Required';
break;
case 412:
responseDecoratorFunction = ApiPreconditionFailedResponse;
description = 'Precondition Failed';
break;
case 413:
responseDecoratorFunction = ApiPayloadTooLargeResponse;
description = 'Payload Too Large';
break;
case 414:
responseDecoratorFunction = ApiUriTooLongResponse;
description = 'URI Too Long';
break;
case 415:
responseDecoratorFunction = ApiUnsupportedMediaTypeResponse;
description = 'Unsupported Media Type';
break;
case 416:
responseDecoratorFunction = ApiRequestedRangeNotSatisfiableResponse;
description = 'Range Not Satisfiable';
break;
case 417:
responseDecoratorFunction = ApiExpectationFailedResponse;
description = 'Expectation Failed';
break;
case 422:
responseDecoratorFunction = ApiUnprocessableEntityResponse;
description = 'Unprocessable Entity';
break;
// 5XX Server Errors
case 500:
responseDecoratorFunction = ApiInternalServerErrorResponse;
description = 'Internal Server Error';
break;
case 501:
responseDecoratorFunction = ApiNotImplementedResponse;
description = 'Not Implemented';
break;
case 502:
responseDecoratorFunction = ApiBadGatewayResponse;
description = 'Bad Gateway';
break;
case 503:
responseDecoratorFunction = ApiServiceUnavailableResponse;
description = 'Service Unavailable';
break;
case 504:
responseDecoratorFunction = ApiGatewayTimeoutResponse;
description = 'Gateway Timeout';
break;
case 505:
responseDecoratorFunction = ApiHttpVersionNotSupportedResponse;
description = 'HTTP Version Not Supported';
break;
// Default case
default:
responseDecoratorFunction = ApiOkResponse; // Fallback to a default response
description = 'OK'; // Default description
break;
}
return applyDecorators(
ApiExtraModels(DataWrapperDto, dataDto),
responseDecoratorFunction({
description,
schema: buildSchema(shouldEnvelope, isResponseArray, dataDto),
...options,
})
);
};
export const ApiCommonResponses = () => {
const decorators: any = [];
for (const [decoratorName, responseOptions] of Object.entries(COMMON_RESPONSES)) {
const decorator = customResponseDecorators[decoratorName](responseOptions);
decorators.push(decorator);
}
return applyDecorators(
...decorators,
ApiResponse(ErrorDto, 400, false, false),
ApiResponse(ErrorDto, 401, false, false),
ApiResponse(ErrorDto, 403, false, false),
ApiResponse(ErrorDto, 404, false, false),
ApiResponse(ErrorDto, 405, false, false),
ApiResponse(ErrorDto, 409, false, false),
ApiResponse(ErrorDto, 413, false, false),
ApiResponse(ErrorDto, 414, false, false),
ApiResponse(ErrorDto, 415, false, false),
ApiResponse(ErrorDto, 500, false, false),
ApiResponse(ValidationErrorDto, 422, false, false)
);
};
================================================
FILE: apps/api/src/app/shared/framework/response.interceptor.ts
================================================
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import { isArray, isObject } from 'lodash';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response {
data: T;
}
@Injectable()
export class ResponseInterceptor implements NestInterceptor> {
intercept(context, next: CallHandler): Observable> {
if (context.getType() === 'graphql') return next.handle();
return next.handle().pipe(
map((data) => {
if (this.returnWholeObject(data)) {
return {
...data,
data: isObject(data.data) ? this.transformResponse(data.data) : data.data,
};
}
return {
data: isObject(data) ? this.transformResponse(data) : data,
};
})
);
}
/**
* This method is used to determine if the entire object should be returned or just the data property
* for paginated results that already contain the data wrapper, true.
* for single entity result that *could* contain data object, false.
* @param data
* @private
*/
private returnWholeObject(data) {
const isPaginatedResult = data?.data;
const isEntityObject = data?._id || data?.id;
return isPaginatedResult && !isEntityObject;
}
private transformResponse(response) {
if (isArray(response)) {
return response.map((item) => this.transformToPlain(item));
}
return this.transformToPlain(response);
}
private transformToPlain(plainOrClass) {
return plainOrClass && plainOrClass.constructor !== Object ? instanceToPlain(plainOrClass) : plainOrClass;
}
}
================================================
FILE: apps/api/src/app/shared/framework/swagger/headers.decorator.ts
================================================
import { OpenAPIObject } from '@nestjs/swagger';
import { HeaderObjects, HttpResponseHeaderKeysEnum } from '@novu/application-generic';
import { RESPONSE_HEADER_CONFIG } from '../constants/headers.schema';
export const injectReusableHeaders = (document: OpenAPIObject): OpenAPIObject => {
const newDocument = { ...document };
newDocument.components = {
...document.components,
headers: Object.entries(RESPONSE_HEADER_CONFIG).reduce((acc, [name, header]) => {
return {
...acc,
[name]: header,
};
}, {} as HeaderObjects),
};
return newDocument;
};
export const createReusableHeaders = (headers: Array) => {
return headers.reduce((acc, header) => {
return {
...acc,
[header]: {
$ref: `#/components/headers/${header}`,
},
};
}, {} as HeaderObjects);
};
================================================
FILE: apps/api/src/app/shared/framework/swagger/index.ts
================================================
export * from './headers.decorator';
export * from './injection';
export * from './responses.decorator';
================================================
FILE: apps/api/src/app/shared/framework/swagger/injection.ts
================================================
import { OpenAPIObject } from '@nestjs/swagger';
import { injectReusableHeaders } from './headers.decorator';
export const injectDocumentComponents = (document: OpenAPIObject): OpenAPIObject => {
const injectedResponseHeadersDocument = injectReusableHeaders(document);
return injectedResponseHeadersDocument;
};
================================================
FILE: apps/api/src/app/shared/framework/swagger/keyless.security.ts
================================================
import { applyDecorators, SetMetadata } from '@nestjs/common';
export const KEYLESS_ACCESSIBLE = 'keyless_accessible';
export function KeylessAccessible() {
return applyDecorators(SetMetadata(KEYLESS_ACCESSIBLE, true));
}
================================================
FILE: apps/api/src/app/shared/framework/swagger/open.api.manipulation.component.ts
================================================
import { OpenAPIObject } from '@nestjs/swagger';
import { OperationObject, PathItemObject, PathsObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import { API_KEY_SWAGGER_SECURITY_NAME } from '@novu/application-generic';
import Nimma from 'nimma';
const jpath = '$.paths..responses["200","201"].content["application/json"]';
/**
* @param {import("nimma").EmittedScope} scope
*/
function liftDataProperty(scope) {
if (
typeof scope.value !== 'object' ||
!scope.value ||
!('schema' in scope.value) ||
typeof scope.value.schema !== 'object' ||
!scope.value.schema
) {
return;
}
const { schema } = scope.value;
const data =
'properties' in schema &&
typeof schema.properties === 'object' &&
schema.properties &&
'data' in schema.properties &&
typeof schema.properties.data === 'object'
? schema.properties.data
: null;
if (!data) {
return;
}
scope.value.schema = data;
}
export function removeEndpointsWithoutApiKey(openApiDocument: T): T {
const parsedDocument = JSON.parse(JSON.stringify(openApiDocument));
if (!parsedDocument.paths) {
throw new Error('Invalid OpenAPI document');
}
for (const path in parsedDocument.paths) {
const operations = parsedDocument.paths[path];
for (const method in operations) {
const operation = operations[method];
if (operation.security) {
const hasApiKey = operation.security.some((sec: { [key: string]: string[] }) =>
Object.keys(sec).includes(API_KEY_SWAGGER_SECURITY_NAME)
);
operation.security = operation.security.filter((sec: { [key: string]: string[] }) =>
Object.keys(sec).includes(API_KEY_SWAGGER_SECURITY_NAME)
);
if (!hasApiKey) {
delete operations[method];
}
}
}
if (Object.keys(operations).length === 0) {
delete parsedDocument.paths[path];
}
}
return parsedDocument;
}
function unwrapDataAttribute(inputDocument: OpenAPIObject) {
Nimma.query(inputDocument, {
[jpath]: liftDataProperty,
});
}
function filterBearerOnlyIfExternal(isForInternalSdk: boolean, inputDocument: OpenAPIObject) {
let openAPIObject: OpenAPIObject;
if (isForInternalSdk) {
return inputDocument;
} else {
return removeEndpointsWithoutApiKey(inputDocument) as OpenAPIObject;
}
}
export function overloadDocumentForSdkGeneration(inputDocument: OpenAPIObject, isForInternalSdk: boolean = false) {
unwrapDataAttribute(inputDocument);
const openAPIObject = filterBearerOnlyIfExternal(isForInternalSdk, inputDocument);
return addIdempotencyKeyHeader(openAPIObject) as OpenAPIObject;
}
export function addIdempotencyKeyHeader(openApiDocument: T): T {
const parsedDocument = JSON.parse(JSON.stringify(openApiDocument));
if (!parsedDocument.paths) {
throw new Error('Invalid OpenAPI document');
}
const idempotencyKeyHeader = {
name: 'idempotency-key',
in: 'header',
description: 'A header for idempotency purposes',
required: false,
schema: {
type: 'string',
},
};
const paths = Object.keys(parsedDocument.paths);
for (const path of paths) {
const operations = parsedDocument.paths[path];
const methods = Object.keys(operations);
for (const method of methods) {
const operation = operations[method];
if (!operation.parameters) {
operation.parameters = [];
}
const hasIdempotencyKey = operation.parameters.some(
(param) => param.name === 'Idempotency-Key' && param.in === 'header'
);
if (!hasIdempotencyKey) {
operation.parameters.push(idempotencyKeyHeader);
}
}
}
return parsedDocument;
}
export function sortOpenAPIDocument(openApiDoc: OpenAPIObject): OpenAPIObject {
// Create a deep copy of the original document
const sortedDoc: OpenAPIObject = JSON.parse(JSON.stringify(openApiDoc));
// Remove empty tag references
if (sortedDoc.tags) {
sortedDoc.tags = sortedDoc.tags.filter((tag) => tag.name && tag.name.trim() !== '');
}
// Sort paths
if (sortedDoc.paths) {
const sortedPaths: PathsObject = {};
// Sort path keys based on version (v2 before v1) and then alphabetically
const sortedPathKeys = Object.keys(sortedDoc.paths).sort((a, b) => {
// Extract version from path
const getVersion = (path: string) => {
const versionMatch = path.match(/\/v(\d+)/);
return versionMatch ? parseInt(versionMatch[1], 10) : 0;
};
const versionA = getVersion(a);
const versionB = getVersion(b);
// Sort by version (newer first)
if (versionA !== versionB) {
return versionB - versionA;
}
// If versions are the same, sort alphabetically
return a.localeCompare(b);
});
// Reconstruct paths with sorted keys and sorted methods within each path
sortedPathKeys.forEach((pathKey) => {
const pathItem = sortedDoc.paths[pathKey];
// Define method order priority
const methodPriority = ['post', 'put', 'patch', 'get', 'delete', 'options', 'head', 'trace'];
// Sort methods within the path item
sortedPaths[pathKey] = {
...pathItem,
...Object.fromEntries(
methodPriority
.map((method) => {
const operation = pathItem[method as keyof PathItemObject];
return operation ? [method, operation] : null;
})
.filter((entry): entry is [string, OperationObject] => entry !== null)
.sort((a, b) => {
const opIdA = a[1].operationId || '';
const opIdB = b[1].operationId || '';
return opIdA.localeCompare(opIdB);
})
),
};
});
sortedDoc.paths = sortedPaths;
}
return sortedDoc;
}
================================================
FILE: apps/api/src/app/shared/framework/swagger/responses.decorator.ts
================================================
import { applyDecorators } from '@nestjs/common';
import * as nestSwagger from '@nestjs/swagger';
import { ApiResponseOptions } from '@nestjs/swagger';
import type { ApiResponseDecoratorName } from '@novu/application-generic';
import { COMMON_RESPONSE_HEADERS, COMMON_RESPONSES } from '../constants';
import { createReusableHeaders } from './headers.decorator';
const createCustomResponseDecorator = (decoratorName: ApiResponseDecoratorName) => {
return (options?: ApiResponseOptions) => {
return applyDecorators(
nestSwagger[decoratorName]({
...COMMON_RESPONSES[decoratorName],
...options,
headers: {
...createReusableHeaders(COMMON_RESPONSE_HEADERS),
...options?.headers,
},
})
);
};
};
const nestSwaggerResponseExports = Object.keys(nestSwagger).filter(
(key) => key.match(/^Api([a-zA-Z]+)Response$/) !== null
) as Array;
export const customResponseDecorators = nestSwaggerResponseExports.reduce(
(acc, decoratorName) => {
return {
...acc,
[decoratorName]: createCustomResponseDecorator(decoratorName),
};
},
{} as Record ReturnType>
);
================================================
FILE: apps/api/src/app/shared/framework/swagger/sdk.decorators.ts
================================================
import { applyDecorators } from '@nestjs/common';
import { ApiExtension, ApiParam, ApiProperty } from '@nestjs/swagger';
import { ApiParamOptions } from '@nestjs/swagger/dist/decorators/api-param.decorator';
import { ApiPropertyOptions } from '@nestjs/swagger/dist/decorators/api-property.decorator';
/**
* Sets the method name for the SDK.
* @param {string} methodName - The name of the method.
* @returns {Decorator} The decorator to be used on the method.
*/
export function SdkMethodName(methodName: string) {
return applyDecorators(ApiExtension('x-speakeasy-name-override', methodName));
}
/**
* Sets the group name for the SDK.
* @param {string} methodName - The name of the group.
* @returns {Decorator} The decorator to be used on the method.
*/
export function SdkGroupName(methodName: string) {
return applyDecorators(ApiExtension('x-speakeasy-group', methodName));
}
/**
* A decorator function that marks a path or operation to be ignored in OpenAPI documentation.
*
* This function applies the `x-ignore` extension to the OpenAPI specification,
* indicating that the decorated path or operation should not be included in the generated documentation.
*
* @returns {Function} A decorator function that applies the `x-ignore` extension.
*/
export function DocumentationIgnore() {
return applyDecorators(ApiExtension('x-ignore', true));
}
/**
* Ignores the path for the SDK.
* @param {string} methodName - The name of the method.
* @returns {Decorator} The decorator to be used on the method.
*/
export function SdkIgnorePath(methodName: string) {
return applyDecorators(ApiExtension('x-speakeasy-ignore', 'true'));
}
/**
* Sets the usage example for the SDK.
* @param {string} title - The title of the example.
* @param {string} description - The description of the example.
* @param {number} position - The position of the example.
* @returns {Decorator} The decorator to be used on the method.
*/
export function SdkUsageExample(title?: string, description?: string, position?: number) {
return applyDecorators(ApiExtension('x-speakeasy-usage-example', { title, description, position }));
}
/**
* Sets the maximum number of parameters for the SDK method.
* @param {number} maxParamsBeforeCollapseToObject - The maximum number of parameters before they are collapsed into an object.
* @returns {Decorator} The decorator to be used on the method.
*/
export function SdkMethodMaxParamsOverride(maxParamsBeforeCollapseToObject?: number) {
return applyDecorators(ApiExtension('x-speakeasy-max-method-params', maxParamsBeforeCollapseToObject));
}
class SDKOverrideOptions {
nameOverride?: string;
}
export function SdkApiParam(options: ApiParamOptions, sdkOverrideOptions?: SDKOverrideOptions) {
let finalOptions: ApiParamOptions;
if (sdkOverrideOptions) {
finalOptions = sdkOverrideOptions.nameOverride
? ({ ...options, 'x-speakeasy-name-override': sdkOverrideOptions.nameOverride } as unknown as ApiParamOptions)
: options;
} else {
finalOptions = options;
}
return applyDecorators(ApiParam(finalOptions));
}
export function SdkApiProperty(options: ApiPropertyOptions, sdkOverrideOptions?: SDKOverrideOptions) {
let finalOptions: ApiPropertyOptions;
if (sdkOverrideOptions) {
finalOptions = sdkOverrideOptions.nameOverride
? ({ ...options, 'x-speakeasy-name-override': sdkOverrideOptions.nameOverride } as unknown as ApiPropertyOptions)
: options;
} else {
finalOptions = options;
}
return applyDecorators(ApiProperty(finalOptions));
}
/**
* Sets the pagination for the SDK.
* @param {string} override - The override for the limit parameter.
* @returns {Decorator} The decorator to be used on the method.
*/
export function SdkUsePagination(override?: string) {
return applyDecorators(
ApiExtension('x-speakeasy-pagination', {
type: 'offsetLimit',
inputs: [
{
name: 'page',
in: 'parameters',
type: 'page',
},
{
name: override || 'limit',
in: 'parameters',
type: 'limit',
},
],
outputs: {
results: '$.data.resultArray',
},
})
);
}
================================================
FILE: apps/api/src/app/shared/framework/swagger/swagger.controller.ts
================================================
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
import { SecuritySchemeObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import { API_KEY_SWAGGER_SECURITY_NAME, BEARER_SWAGGER_SECURITY_NAME } from '@novu/application-generic';
import packageJson from '../../../../../package.json';
import metadata from '../../../../metadata';
import { webhookEvents } from '../../../outbound-webhooks/webhooks.const';
import { injectDocumentComponents } from './injection';
import {
overloadDocumentForSdkGeneration,
removeEndpointsWithoutApiKey,
sortOpenAPIDocument,
} from './open.api.manipulation.component';
export const API_KEY_SECURITY_DEFINITIONS: SecuritySchemeObject = {
type: 'apiKey',
name: 'Authorization',
in: 'header',
description: 'API key authentication. Allowed headers-- "Authorization: ApiKey ".',
'x-speakeasy-example': 'YOUR_SECRET_KEY_HERE',
} as unknown as SecuritySchemeObject;
export const BEARER_SECURITY_DEFINITIONS: SecuritySchemeObject = {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
};
function buildBaseOptions() {
const options = new DocumentBuilder()
.setTitle('Novu API')
.setDescription('Novu REST API. Please see https://docs.novu.co/api-reference for more details.')
.setVersion(packageJson.version)
.setContact('Novu Support', 'https://discord.gg/novu', 'support@novu.co')
.setExternalDoc('Novu Documentation', 'https://docs.novu.co')
.setTermsOfService('https://novu.co/terms')
.setLicense('MIT', 'https://opensource.org/license/mit')
.addServer('https://api.novu.co')
.addServer('https://eu.api.novu.co')
.addSecurity(API_KEY_SWAGGER_SECURITY_NAME, API_KEY_SECURITY_DEFINITIONS)
.addSecurityRequirements(API_KEY_SWAGGER_SECURITY_NAME)
.addTag(
'Events',
`Events represent a change in state of a subscriber. They are used to trigger workflows, and enable you to send notifications to subscribers based on their actions.`,
{ url: 'https://docs.novu.co/workflows' }
)
.addTag(
'Subscribers',
`A subscriber in Novu represents someone who should receive a message. A subscriber's profile information contains important attributes about the subscriber that will be used in messages (name, email). The subscriber object can contain other key-value pairs that can be used to further personalize your messages.`,
{ url: 'https://docs.novu.co/subscribers/subscribers' }
)
.addTag(
'Topics',
`Topics are a way to group subscribers together so that they can be notified of events at once. A topic is identified by a custom key. This can be helpful for things like sending out marketing emails or notifying users of new features. Topics can also be used to send notifications to the subscribers who have been grouped together based on their interests, location, activities and much more.`,
{ url: 'https://docs.novu.co/subscribers/topics' }
)
.addTag(
'Integrations',
`With the help of the Integration Store, you can easily integrate your favorite delivery provider. During the runtime of the API, the Integrations Store is responsible for storing the configurations of all the providers.`,
{ url: 'https://docs.novu.co/platform/integrations/overview' }
)
.addTag(
'Workflows',
`All notifications are sent via a workflow. Each workflow acts as a container for the logic and blueprint that are associated with a type of notification in your system.`,
{ url: 'https://docs.novu.co/workflows' }
)
.addTag(
'Messages',
`A message in Novu represents a notification delivered to a recipient on a particular channel. Messages contain information about the request that triggered its delivery, a view of the data sent to the recipient, and a timeline of its lifecycle events. Learn more about messages.`,
{ url: 'https://docs.novu.co/workflows/messages' }
)
.addTag(
'Environments',
`Environments allow you to manage different stages of your application development lifecycle. Each environment has its own set of API keys and configurations, enabling you to separate development, staging, and production workflows.`,
{ url: 'https://docs.novu.co/platform/environments' }
)
.addTag('Layouts', `Layouts are reusable wrappers for your email notifications.`, {
url: 'https://docs.novu.co/platform/workflow/layouts',
})
.addTag('Translations', `Used to localize your notifications to different languages.`, {
url: 'https://docs.novu.co/platform/workflow/advanced-features/translations',
});
return options;
}
function buildOpenApiBaseDocument(internalSdkGeneration: boolean | undefined) {
const options = buildBaseOptions();
if (internalSdkGeneration) {
options.addSecurity(BEARER_SWAGGER_SECURITY_NAME, BEARER_SECURITY_DEFINITIONS);
options.addSecurityRequirements(BEARER_SWAGGER_SECURITY_NAME);
}
return options.build();
}
function buildFullDocumentWithPath(app: INestApplication, baseDocument: Omit) {
// Define extraModels to ensure webhook payload DTOs are included in the schema definitions
// Add other relevant payload DTOs here if more webhooks are defined
const allWebhookPayloadDtos = [...new Set(webhookEvents.map((event) => event.payloadDto))];
const document = injectDocumentComponents(
SwaggerModule.createDocument(app, baseDocument, {
operationIdFactory: (controllerKey: string, methodKey: string) => `${controllerKey}_${methodKey}`,
deepScanRoutes: true,
ignoreGlobalPrefix: false,
include: [],
extraModels: [...allWebhookPayloadDtos], // Make sure payload DTOs are processed
})
);
return document;
}
function publishDeprecatedDocument(app: INestApplication, document: OpenAPIObject) {
SwaggerModule.setup('api', app, {
...document,
info: {
...document.info,
title: `DEPRECATED: ${document.info.title}. Use /openapi.{json,yaml} instead.`,
},
});
}
function publishLegacyOpenApiDoc(app: INestApplication, document: OpenAPIObject) {
SwaggerModule.setup('openapi', app, removeEndpointsWithoutApiKey(document), {
jsonDocumentUrl: 'openapi.json',
yamlDocumentUrl: 'openapi.yaml',
explorer: process.env.NODE_ENV !== 'production',
});
}
/**
* Generates the `x-webhooks` section for the OpenAPI document based on defined events and DTOs.
* Follows the OpenAPI specification for webhooks: https://spec.openapis.org/oas/v3.1.0#fixed-fields-1:~:text=Webhooks%20Object
*/
function generateWebhookDefinitions(document: OpenAPIObject) {
const webhooksDefinition: Record = {}; // Structure matches Path Item Object
webhookEvents.forEach((webhook) => {
// Assume the schema name matches the DTO class name (generated by Swagger)
const payloadSchemaRef = `#/components/schemas/${(webhook.payloadDto as Function).name}`;
const wrapperSchemaName = `${(webhook.payloadDto as Function).name}WebhookPayloadWrapper`; // Unique name for the wrapper schema
// Define the wrapper schema in components/schemas if it doesn't exist
if (document.components && !document.components.schemas?.[wrapperSchemaName]) {
if (!document.components.schemas) {
document.components.schemas = {};
}
document.components.schemas[wrapperSchemaName] = {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Unique identifier of the webhook event (evt_✱).',
},
type: { type: 'string', enum: [webhook.event], description: 'The type of the webhook event.' },
data: {
description: 'The actual event data payload.',
allOf: [{ $ref: payloadSchemaRef }], // Use allOf to correctly reference the payload schema
},
timestamp: { type: 'string', format: 'date-time', description: 'ISO timestamp of when the event occurred.' },
environmentId: { type: 'string', description: 'The ID of the environment associated with the event.' },
object: {
type: 'string',
enum: [webhook.objectType],
description: 'The type of object the event relates to.',
},
},
required: ['type', 'data', 'timestamp', 'environmentId', 'object'],
};
}
webhooksDefinition[webhook.event] = {
// This structure represents a Path Item Object, describing the webhook POST request.
post: {
summary: `Event: ${webhook.event}`,
description: `This webhook is triggered when a \`${webhook.objectType}\` event (\`${
webhook.event
}\`) occurs. The payload contains the details of the event. Configure your webhook endpoint URL in the Novu dashboard.`,
requestBody: {
description: `Webhook payload for the \`${webhook.event}\` event.`,
required: true,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${wrapperSchemaName}` }, // Reference the wrapper schema
},
},
},
responses: {
'200': {
description: 'Acknowledges successful receipt of the webhook. No response body is expected.',
},
// Consider adding other responses (e.g., 4xx for signature validation failure, 5xx for processing errors)
},
tags: ['Webhooks'], // Assign to a 'Webhooks' tag
},
};
});
document['x-webhooks'] = webhooksDefinition;
}
export const setupSwagger = async (app: INestApplication, internalSdkGeneration?: boolean) => {
await SwaggerModule.loadPluginMetadata(metadata);
const baseDocument = buildOpenApiBaseDocument(internalSdkGeneration);
const document = buildFullDocumentWithPath(app, baseDocument);
// Generate and add x-webhooks section FIRST
generateWebhookDefinitions(document);
publishDeprecatedDocument(app, document);
publishLegacyOpenApiDoc(app, document);
return publishSdkSpecificDocumentAndReturnDocument(app, document, internalSdkGeneration);
};
function overloadNamingGuidelines(document: OpenAPIObject) {
document['x-speakeasy-name-override'] = [
{ operationId: '^.*get.*', methodNameOverride: 'retrieve' },
{ operationId: '^.*retrieve.*', methodNameOverride: 'retrieve' },
{ operationId: '^.*create.*', methodNameOverride: 'create' },
{ operationId: '^.*update.*', methodNameOverride: 'update' },
{ operationId: '^.*list.*', methodNameOverride: 'list' },
{ operationId: '^.*delete.*', methodNameOverride: 'delete' },
{ operationId: '^.*remove.*', methodNameOverride: 'delete' },
];
}
function overloadGlobalSdkRetrySettings(document: OpenAPIObject) {
document['x-speakeasy-retries'] = {
strategy: 'backoff',
backoff: {
initialInterval: 1000,
maxInterval: 30000,
maxElapsedTime: 3600000,
exponent: 1.5,
},
statusCodes: [408, 409, 429, '5XX'],
retryConnectionErrors: true,
};
}
function patchOpenEnumSchemas(document: OpenAPIObject) {
const openEnumSchemas = ['UiComponentEnum'];
for (const schemaName of openEnumSchemas) {
const schema = document.components?.schemas?.[schemaName];
if (schema) {
(schema as Record)['x-speakeasy-unknown-values'] = 'allow';
}
}
}
function publishSdkSpecificDocumentAndReturnDocument(
app: INestApplication,
document: OpenAPIObject,
internalSdkGeneration?: boolean
) {
overloadNamingGuidelines(document);
overloadGlobalSdkRetrySettings(document);
patchOpenEnumSchemas(document);
let sdkDocument: OpenAPIObject = overloadDocumentForSdkGeneration(document, internalSdkGeneration);
sdkDocument = sortOpenAPIDocument(sdkDocument);
SwaggerModule.setup('openapi.sdk', app, sdkDocument, {
jsonDocumentUrl: 'openapi.sdk.json',
yamlDocumentUrl: 'openapi.sdk.yaml',
explorer: process.env.NODE_ENV !== 'production',
});
return sdkDocument;
}
================================================
FILE: apps/api/src/app/shared/framework/user.decorator.ts
================================================
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
import { UserSession } from '@novu/application-generic';
import { SubscriberEntity } from '@novu/dal';
import { ApiAuthSchemeEnum } from '@novu/shared';
import jwt from 'jsonwebtoken';
export { UserSession };
export interface SubscriberSession extends SubscriberEntity {
organizationId: string;
environmentId: string;
contextKeys: string[];
scheme: ApiAuthSchemeEnum;
}
export const SubscriberSession = createParamDecorator((data, ctx) => {
const req = ctx.getType() === 'graphql' ? ctx.getArgs()[2].req : ctx.switchToHttp().getRequest();
if (req.user) {
return req.user;
}
const authorization = req.headers?.authorization;
if (!authorization) {
return null;
}
const tokenParts = authorization.split(' ');
if (tokenParts[0] !== 'Bearer' || !tokenParts[1]) {
throw new UnauthorizedException('bad_token');
}
return jwt.decode(tokenParts[1]);
});
================================================
FILE: apps/api/src/app/shared/helpers/content.service.spec.ts
================================================
import { ContentService } from '@novu/application-generic';
import {
DelayTypeEnum,
DigestTypeEnum,
DigestUnitEnum,
FieldLogicalOperatorEnum,
FieldOperatorEnum,
FilterPartTypeEnum,
INotificationTemplateStep,
StepTypeEnum,
TriggerContextTypeEnum,
} from '@novu/shared';
import { expect } from 'chai';
describe('ContentService', () => {
describe('replaceVariables', () => {
it('should replace duplicates entries', () => {
const variables = {
firstName: 'Name',
lastName: 'Last Name',
};
const contentService = new ContentService();
const modified = contentService.replaceVariables(
'{{firstName}} is the first {{firstName}} of {{firstName}}',
variables
);
expect(modified).to.equal('Name is the first Name of Name');
});
it('should replace multiple variables', () => {
const variables = {
firstName: 'Name',
$last_name: 'Last Name',
};
const contentService = new ContentService();
const modified = contentService.replaceVariables(
'{{firstName}} is the first {{$last_name}} of {{firstName}}',
variables
);
expect(modified).to.equal('Name is the first Last Name of Name');
});
it('should not manipulate variables for text without them', () => {
const variables = {
firstName: 'Name',
lastName: 'Last Name',
};
const contentService = new ContentService();
const modified = contentService.replaceVariables('This is a text without variables', variables);
expect(modified).to.equal('This is a text without variables');
});
});
describe('extractVariables', () => {
it('should not find any variables', () => {
const contentService = new ContentService();
try {
contentService.extractVariables('This is a text without variables {{ invalid }} {{ not valid{ {var}}');
expect(true).to.equal(false);
} catch (e) {
expect(e.response.message).to.equal('Failed to extract variables');
}
});
it('should extract all valid variables', () => {
const contentService = new ContentService();
const extractVariables = contentService.extractVariables(
' {{name}} d {{lastName}} dd {{_validName}} {{not valid}} aa {{0notValid}}tr {{organization_name}}'
);
const variablesNames = extractVariables.map((variable) => variable.name);
expect(extractVariables.length).to.equal(4);
expect(variablesNames).to.include('_validName');
expect(variablesNames).to.include('lastName');
expect(variablesNames).to.include('name');
expect(variablesNames).to.include('organization_name');
});
it('should correctly extract variables related to registered handlebar helpers', () => {
const contentService = new ContentService();
const extractVariables = contentService.extractVariables(' {{titlecase word}}');
expect(extractVariables.length).to.equal(1);
expect(extractVariables[0].name).to.include('word');
});
it('should not show @data variables ', () => {
const contentService = new ContentService();
const extractVariables = contentService.extractVariables(
' {{#each array}} {{@index}} {{#if @first}} First {{/if}} {{name}} {{/each}}'
);
expect(extractVariables.length).to.equal(2);
expect(extractVariables[0].name).to.include('array');
expect(extractVariables[0].type).to.eq('Array');
expect(extractVariables[1].name).to.include('name');
});
});
describe('extractMessageVariables', () => {
it('should not extract variables', () => {
const contentService = new ContentService();
const { variables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.IN_APP,
subject: 'Test',
content: 'Text',
},
},
]);
expect(variables.length).to.equal(0);
});
it('should extract subject variables', () => {
const contentService = new ContentService();
const { variables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{firstName}}',
content: [],
},
},
]);
expect(variables.length).to.equal(1);
expect(variables[0].name).to.include('firstName');
});
it('should extract reserved variables', () => {
const contentService = new ContentService();
const { variables, reservedVariables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{firstName}} {{tenant.name}}',
content: [],
},
},
]);
expect(variables.length).to.equal(1);
expect(variables[0].name).to.include('firstName');
expect(reservedVariables.length).to.equal(1);
expect(reservedVariables[0].type).to.eq(TriggerContextTypeEnum.TENANT);
expect(reservedVariables[0].variables[0].name).to.include('identifier');
});
it('should add phone when SMS channel Exists', () => {
const contentService = new ContentService();
const variables = contentService.extractSubscriberMessageVariables([
{
template: {
type: StepTypeEnum.IN_APP,
subject: 'Test',
content: 'Text',
},
},
{
template: {
type: StepTypeEnum.SMS,
content: 'Text',
},
},
]);
expect(variables.length).to.equal(1);
expect(variables[0]).to.equal('phone');
});
it('should add email when EMAIL channel Exists', () => {
const contentService = new ContentService();
const variables = contentService.extractSubscriberMessageVariables([
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test',
content: 'Text',
},
},
{
template: {
type: StepTypeEnum.IN_APP,
content: 'Text',
},
},
]);
expect(variables.length).to.equal(1);
expect(variables[0]).to.equal('email');
});
it('should extract email content variables', () => {
const contentService = new ContentService();
const messages = [
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{firstName}}',
content: [
{
content: 'Test of {{lastName}}',
type: 'text',
},
{
content: 'Test of {{lastName}}',
type: 'text',
url: 'Test of {{url}}',
},
],
},
},
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{email}}',
content: [
{
content: 'Test of {{lastName}}',
type: 'text',
},
{
content: 'Test of {{lastName}}',
type: 'text',
url: 'Test of {{url}}',
},
],
},
},
] as INotificationTemplateStep[];
const { variables } = contentService.extractMessageVariables(messages);
const subscriberVariables = contentService.extractSubscriberMessageVariables(messages);
const variablesNames = variables.map((variable) => variable.name);
expect(variables.length).to.equal(4);
expect(subscriberVariables.length).to.equal(1);
expect(variablesNames).to.include('lastName');
expect(variablesNames).to.include('url');
expect(variablesNames).to.include('firstName');
expect(subscriberVariables).to.include('email');
});
it('should extract in-app content variables', () => {
const contentService = new ContentService();
const { variables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.IN_APP,
content: '{{customVariables}}',
},
},
]);
expect(variables.length).to.equal(1);
expect(variables[0].name).to.include('customVariables');
});
it('should extract i18n content variables', () => {
const contentService = new ContentService();
const { variables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.IN_APP,
content: '{{i18n "group.key" var=customVar.subVar var2=secVar}}',
},
},
]);
expect(variables.length).to.equal(2);
const variablesNames = variables.map((variable) => variable.name);
expect(variablesNames).to.include('customVar.subVar');
expect(variablesNames).to.include('secVar');
});
it('should extract action steps variables', () => {
const contentService = new ContentService();
const { variables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.DELAY,
content: '',
},
metadata: { type: DelayTypeEnum.SCHEDULED, delayPath: 'sendAt' },
},
{
template: {
type: StepTypeEnum.DIGEST,
content: '',
},
metadata: { type: DigestTypeEnum.REGULAR, digestKey: 'path', unit: DigestUnitEnum.SECONDS, amount: 1 },
},
]);
const variablesNames = variables.map((variable) => variable.name);
expect(variables.length).to.equal(2);
expect(variablesNames).to.include('sendAt');
expect(variablesNames).to.include('path');
});
it('should extract filter variables on payload', () => {
const contentService = new ContentService();
const { variables } = contentService.extractMessageVariables([
{
template: {
type: StepTypeEnum.EMAIL,
content: '{{name}}',
},
filters: [
{
isNegated: false,
type: 'GROUP',
value: FieldLogicalOperatorEnum.AND,
children: [
{
on: FilterPartTypeEnum.PAYLOAD,
field: 'counter',
value: 'test value',
operator: FieldOperatorEnum.EQUAL,
},
],
},
],
},
]);
const variablesNames = variables.map((variable) => variable.name);
expect(variables.length).to.equal(2);
expect(variablesNames).to.include('name');
expect(variablesNames).to.include('counter');
});
it('should not extract variables reserved for the system', () => {
const contentService = new ContentService();
const messages = [
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{subscriber.firstName}}',
content: [
{
content: 'Test of {{subscriber.firstName}} {{lastName}}',
type: 'text',
},
],
},
},
] as INotificationTemplateStep[];
const { variables: extractVariables } = contentService.extractMessageVariables(messages);
expect(extractVariables.length).to.equal(1);
expect(extractVariables[0].name).to.include('lastName');
});
});
describe('extractStepVariables', () => {
it('should not fail if no filters available', () => {
const contentService = new ContentService();
const messages = [
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{subscriber.firstName}}',
content: [
{
content: 'Test of {{subscriber.firstName}} {{lastName}}',
type: 'text',
},
],
},
},
] as INotificationTemplateStep[];
const variables = contentService.extractStepVariables(messages);
expect(variables.length).to.equal(0);
});
it('should not fail if filters are set as non array', () => {
const contentService = new ContentService();
const messages = [
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{subscriber.firstName}}',
content: [
{
content: 'Test of {{subscriber.firstName}} {{lastName}}',
type: 'text',
},
],
},
filters: {},
},
] as INotificationTemplateStep[];
const variables = contentService.extractStepVariables(messages);
expect(variables.length).to.equal(0);
});
it('should not fail if filters are an empty array', () => {
const contentService = new ContentService();
const messages = [
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{subscriber.firstName}}',
content: [
{
content: 'Test of {{subscriber.firstName}} {{lastName}}',
type: 'text',
},
],
},
filters: [],
},
] as INotificationTemplateStep[];
const variables = contentService.extractStepVariables(messages);
expect(variables.length).to.equal(0);
});
it('should not fail if filters have some wrong settings like missing children in filters', () => {
const contentService = new ContentService();
const messages = [
{
template: {
type: StepTypeEnum.EMAIL,
subject: 'Test {{subscriber.firstName}}',
content: [
{
content: 'Test of {{subscriber.firstName}} {{lastName}}',
type: 'text',
},
],
},
filters: [
{
isNegated: false,
type: 'GROUP',
value: FieldLogicalOperatorEnum.AND,
},
],
},
] as INotificationTemplateStep[];
const variables = contentService.extractStepVariables(messages);
expect(variables.length).to.equal(0);
});
});
});
================================================
FILE: apps/api/src/app/shared/helpers/e2e/sdk/e2e-sdk.helper.ts
================================================
import { Novu } from '@novu/api';
import { NovuCore } from '@novu/api/core';
import { SDKOptions } from '@novu/api/lib/config';
import { HTTPClient, HTTPClientOptions } from '@novu/api/lib/http';
import { ErrorDto, SDKValidationError, ValidationErrorDto } from '@novu/api/models/errors';
import { HttpRequestHeaderKeysEnum } from '@novu/application-generic';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
export function initNovuClassSdk(session: UserSession, shouldRetry: boolean = false): Novu {
const options: SDKOptions = {
security: { secretKey: session.apiKey },
serverURL: session.serverUrl,
debugLogger: process.env.LOG_LEVEL === 'debug' ? console : undefined,
};
if (!shouldRetry) {
options.retryConfig = { strategy: 'none' };
}
return new Novu(options);
}
export function initNovuClassSdkInternalAuth(session: UserSession, shouldRetry: boolean = false): Novu {
const options: SDKOptions = {
security: { bearerAuth: session.token },
serverURL: session.serverUrl,
httpClient: new CustomHeaderHTTPClient({
[HttpRequestHeaderKeysEnum.NOVU_ENVIRONMENT_ID]: session.environment._id,
}),
// debugLogger: console,
};
if (!shouldRetry) {
options.retryConfig = { strategy: 'none' };
}
return new Novu(options);
}
export function initNovuFunctionSdk(session: UserSession): NovuCore {
return new NovuCore({ security: { secretKey: session.apiKey }, serverURL: session.serverUrl });
}
function isErrorDto(error: unknown): error is ErrorDto {
return typeof error === 'object' && error !== null && 'name' in error && error.name === 'ErrorDto';
}
function isValidationErrorDto(error: unknown): error is ValidationErrorDto {
return typeof error === 'object' && error !== null && 'name' in error && error.name === 'ValidationErrorDto';
}
function isSDKValidationError(error: unknown): error is SDKValidationError {
return (
error instanceof SDKValidationError &&
error.name === 'SDKValidationError' &&
'rawValue' in error &&
'rawMessage' in error &&
'cause' in error
);
}
export function handleSdkError(error: unknown): ErrorDto {
if (!isErrorDto(error)) {
throw new Error(`Provided error is not an ErrorDto error found:\n ${JSON.stringify(error, null, 2)}`);
}
expect(error.name).to.equal('ErrorDto');
return error;
}
export function handleSdkZodFailure(error: unknown): SDKValidationError {
if (!isSDKValidationError(error)) {
throw new Error(`Provided error is not an ErrorDto error found:\n ${JSON.stringify(error, null, 2)}`);
}
expect(error.name).to.equal('SDKValidationError');
return error;
}
export function handleValidationErrorDto(error: unknown): ValidationErrorDto {
if (!isValidationErrorDto(error)) {
throw new Error(`Provided error is not an ValidationErrorDto error found:\n ${JSON.stringify(error, null, 2)}`);
}
expect(error.name).to.equal('ValidationErrorDto');
expect(error.ctx).to.be.ok;
return error;
}
type AsyncAction = () => Promise;
export async function expectSdkExceptionGeneric