Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isClerkEnabled } from '@novu/shared';
import { SentryModule } from '@sentry/nestjs/setup';
import packageJson from '../package.json';
import { ActivityModule } from './app/activity/activity.module';
import { AgentsModule } from './app/agents/agents.module';
import { AnalyticsModule } from './app/analytics/analytics.module';
import { AuthModule } from './app/auth/auth.module';
import { BlueprintModule } from './app/blueprint/blueprint.module';
Expand Down Expand Up @@ -116,6 +117,7 @@ const baseModules: Array<Type | DynamicModule | Promise<DynamicModule> | Forward
ContentTemplatesModule,
OrganizationModule,
ActivityModule,
AgentsModule,
UserModule,
IntegrationModule,
InternalModule,
Expand Down
287 changes: 287 additions & 0 deletions apps/api/src/app/agents/agents.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import {
Body,
ClassSerializerInterceptor,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ThrottlerCategory } from '../rate-limiting/guards';
import {
ApiCommonResponses,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiResponse,
} from '../shared/framework/response.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import {
AddAgentIntegrationRequestDto,
AgentIntegrationResponseDto,
AgentResponseDto,
CreateAgentRequestDto,
UpdateAgentIntegrationRequestDto,
UpdateAgentRequestDto,
} from './dtos';
import { AddAgentIntegrationCommand } from './usecases/add-agent-integration/add-agent-integration.command';
import { AddAgentIntegration } from './usecases/add-agent-integration/add-agent-integration.usecase';
import { CreateAgentCommand } from './usecases/create-agent/create-agent.command';
import { CreateAgent } from './usecases/create-agent/create-agent.usecase';
import { DeleteAgentCommand } from './usecases/delete-agent/delete-agent.command';
import { DeleteAgent } from './usecases/delete-agent/delete-agent.usecase';
import { GetAgentCommand } from './usecases/get-agent/get-agent.command';
import { GetAgent } from './usecases/get-agent/get-agent.usecase';
import { ListAgentIntegrationsCommand } from './usecases/list-agent-integrations/list-agent-integrations.command';
import { ListAgentIntegrations } from './usecases/list-agent-integrations/list-agent-integrations.usecase';
import { ListAgentsCommand } from './usecases/list-agents/list-agents.command';
import { ListAgents } from './usecases/list-agents/list-agents.usecase';
import { RemoveAgentIntegrationCommand } from './usecases/remove-agent-integration/remove-agent-integration.command';
import { RemoveAgentIntegration } from './usecases/remove-agent-integration/remove-agent-integration.usecase';
import { UpdateAgentIntegrationCommand } from './usecases/update-agent-integration/update-agent-integration.command';
import { UpdateAgentIntegration } from './usecases/update-agent-integration/update-agent-integration.usecase';
import { UpdateAgentCommand } from './usecases/update-agent/update-agent.command';
import { UpdateAgent } from './usecases/update-agent/update-agent.usecase';

@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
@ApiCommonResponses()
@Controller('/agents')
@UseInterceptors(ClassSerializerInterceptor)
@ApiTags('Agents')
@RequireAuthentication()
export class AgentsController {
constructor(
private readonly createAgentUsecase: CreateAgent,
private readonly listAgentsUsecase: ListAgents,
private readonly getAgentUsecase: GetAgent,
private readonly updateAgentUsecase: UpdateAgent,
private readonly deleteAgentUsecase: DeleteAgent,
private readonly addAgentIntegrationUsecase: AddAgentIntegration,
private readonly listAgentIntegrationsUsecase: ListAgentIntegrations,
private readonly updateAgentIntegrationUsecase: UpdateAgentIntegration,
private readonly removeAgentIntegrationUsecase: RemoveAgentIntegration
) {}

@Post('/')
@ApiResponse(AgentResponseDto, 201)
@ApiOperation({
summary: 'Create agent',
description: 'Creates an agent scoped to the current environment. The identifier must be unique per environment.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
createAgent(
@UserSession() user: UserSessionData,
@Body() body: CreateAgentRequestDto
): Promise<AgentResponseDto> {
return this.createAgentUsecase.execute(
CreateAgentCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
name: body.name,
identifier: body.identifier,
description: body.description,
})
);
}

@Get('/')
@ApiResponse(AgentResponseDto, 200, true)
@ApiOperation({
summary: 'List agents',
description: 'Returns all agents for the current environment.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
listAgents(@UserSession() user: UserSessionData): Promise<AgentResponseDto[]> {
return this.listAgentsUsecase.execute(
ListAgentsCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
})
);
}
Comment on lines +100 to +126
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other list endpoints in the codebase implement pagination
rg -n "@SdkUsePagination" --type=ts -A5 apps/api/src/app

Repository: novuhq/novu

Length of output: 1187


🏁 Script executed:

cat -n apps/api/src/app/agents/agents.controller.ts

Repository: novuhq/novu

Length of output: 12929


Add @SdkUsePagination decorator and implement pagination for list endpoint.

Per coding guidelines, list endpoints must support pagination and use the @SdkUsePagination decorator. The listAgents endpoint currently returns an unbounded array instead of a paginated response. Other controllers in the codebase (e.g., subscribersV1.controller.ts, tenant.controller.ts) implement this pattern by adding the decorator and returning PaginatedResponseDto<AgentResponseDto> with pagination query parameters (e.g., limit, offset).

Update the endpoint to match the established pattern and prevent unbounded result sets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/agents.controller.ts` around lines 97 - 112, Update
the listAgents endpoint to use pagination: add the `@SdkUsePagination` decorator
to the listAgents method, change its return type from
Promise<AgentResponseDto[]> to Promise<PaginatedResponseDto<AgentResponseDto>>,
and accept pagination query params (limit, offset) from the request; pass those
into ListAgentsCommand.create (e.g., include limit and offset along with
environmentId and organizationId) so the ListAgentsUsecase returns a paginated
result consistent with other controllers like subscribersV1.controller.ts and
tenant.controller.ts.


@Post('/:identifier/integrations')
@ApiResponse(AgentIntegrationResponseDto, 201)
@ApiOperation({
summary: 'Link integration to agent',
description: 'Creates a link between an agent (by identifier) and an integration (by internal id).',
})
@ApiNotFoundResponse({
description: 'The agent or integration was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
addAgentIntegration(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string,
@Body() body: AddAgentIntegrationRequestDto
): Promise<AgentIntegrationResponseDto> {
return this.addAgentIntegrationUsecase.execute(
AddAgentIntegrationCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
agentIdentifier: identifier,
integrationId: body.integrationId,
})
);
}

@Get('/:identifier/integrations')
@ApiResponse(AgentIntegrationResponseDto, 200, true)
@ApiOperation({
summary: 'List agent integrations',
description: 'Lists integration links for an agent identified by its external identifier.',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
listAgentIntegrations(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string
): Promise<AgentIntegrationResponseDto[]> {
return this.listAgentIntegrationsUsecase.execute(
ListAgentIntegrationsCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
agentIdentifier: identifier,
})
);
}
Comment thread
scopsy marked this conversation as resolved.

@Patch('/:identifier/integrations/:agentIntegrationId')
@ApiResponse(AgentIntegrationResponseDto)
@ApiOperation({
summary: 'Update agent-integration link',
description: 'Updates which integration a link points to (by integration document id).',
})
@ApiNotFoundResponse({
description: 'The agent, integration, or link was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
updateAgentIntegration(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string,
@Param('agentIntegrationId') agentIntegrationId: string,
@Body() body: UpdateAgentIntegrationRequestDto
): Promise<AgentIntegrationResponseDto> {
return this.updateAgentIntegrationUsecase.execute(
UpdateAgentIntegrationCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
agentIdentifier: identifier,
agentIntegrationId,
integrationId: body.integrationId,
})
);
}

@Delete('/:identifier/integrations/:agentIntegrationId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Remove agent-integration link',
description: 'Deletes a specific agent-integration link by its document id.',
})
@ApiNoContentResponse({
description: 'The link was removed.',
})
@ApiNotFoundResponse({
description: 'The agent or agent-integration link was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
removeAgentIntegration(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string,
@Param('agentIntegrationId') agentIntegrationId: string
): Promise<void> {
return this.removeAgentIntegrationUsecase.execute(
RemoveAgentIntegrationCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
agentIdentifier: identifier,
agentIntegrationId,
})
);
}

@Get('/:identifier')
@ApiResponse(AgentResponseDto)
@ApiOperation({
summary: 'Get agent',
description: 'Retrieves an agent by its external identifier (not the internal MongoDB id).',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_READ)
getAgent(@UserSession() user: UserSessionData, @Param('identifier') identifier: string): Promise<AgentResponseDto> {
return this.getAgentUsecase.execute(
GetAgentCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
identifier,
})
);
}

@Patch('/:identifier')
@ApiResponse(AgentResponseDto)
@ApiOperation({
summary: 'Update agent',
description: 'Updates an agent by its external identifier.',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
updateAgent(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string,
@Body() body: UpdateAgentRequestDto
): Promise<AgentResponseDto> {
return this.updateAgentUsecase.execute(
UpdateAgentCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
identifier,
name: body.name,
description: body.description,
})
);
}

@Delete('/:identifier')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete agent',
description: 'Deletes an agent by identifier and removes all agent-integration links.',
})
@ApiNoContentResponse({
description: 'The agent was deleted.',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@RequirePermissions(PermissionsEnum.WORKFLOW_WRITE)
deleteAgent(@UserSession() user: UserSessionData, @Param('identifier') identifier: string): Promise<void> {
Comment thread
scopsy marked this conversation as resolved.
return this.deleteAgentUsecase.execute(
DeleteAgentCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
identifier,
})
);
}
}
14 changes: 14 additions & 0 deletions apps/api/src/app/agents/agents.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';

import { AuthModule } from '../auth/auth.module';
import { SharedModule } from '../shared/shared.module';
import { AgentsController } from './agents.controller';
import { USE_CASES } from './usecases';

@Module({
imports: [SharedModule, AuthModule],
controllers: [AgentsController],
providers: [...USE_CASES],
exports: [...USE_CASES],
})
export class AgentsModule {}
11 changes: 11 additions & 0 deletions apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class AddAgentIntegrationRequestDto {
@ApiProperty({
description: 'The Novu integration document _id to link to this agent.',
})
@IsString()
@IsNotEmpty()
integrationId: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten integrationId validation to Mongo _id format.

IsString + IsNotEmpty still accepts malformed IDs; this should be rejected at request validation time.

Suggested fix
-import { IsNotEmpty, IsString } from 'class-validator';
+import { IsMongoId, IsNotEmpty, IsString } from 'class-validator';
@@
   `@IsString`()
   `@IsNotEmpty`()
+  `@IsMongoId`()
   integrationId: string;

As per coding guidelines for apps/api/**: check for proper error handling and input validation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { IsNotEmpty, IsString } from 'class-validator';
export class AddAgentIntegrationRequestDto {
@ApiProperty({
description: 'The Novu integration document _id to link to this agent.',
})
@IsString()
@IsNotEmpty()
integrationId: string;
import { IsMongoId, IsNotEmpty, IsString } from 'class-validator';
export class AddAgentIntegrationRequestDto {
`@ApiProperty`({
description: 'The Novu integration document _id to link to this agent.',
})
`@IsString`()
`@IsNotEmpty`()
`@IsMongoId`()
integrationId: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts` around
lines 2 - 10, Update the AddAgentIntegrationRequestDto so integrationId is
validated as a MongoDB ObjectId: replace the generic `@IsString`()/@IsNotEmpty()
usage on the integrationId property with the class-validator `@IsMongoId`()
decorator (and keep `@IsNotEmpty`() if you want to enforce presence) and add the
corresponding import for IsMongoId; ensure the property name integrationId in
class AddAgentIntegrationRequestDto is the same and that any tests or callers
supply a 24-hex-character ObjectId format.

}
24 changes: 24 additions & 0 deletions apps/api/src/app/agents/dtos/agent-integration-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';

export class AgentIntegrationResponseDto {
@ApiProperty()
_id: string;

@ApiProperty()
_agentId: string;

@ApiProperty()
_integrationId: string;

@ApiProperty()
_environmentId: string;

@ApiProperty()
_organizationId: string;

@ApiProperty()
createdAt: string;

@ApiProperty()
updatedAt: string;
}
27 changes: 27 additions & 0 deletions apps/api/src/app/agents/dtos/agent-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class AgentResponseDto {
@ApiProperty()
_id: string;

@ApiProperty()
name: string;

@ApiProperty()
identifier: string;

@ApiPropertyOptional()
description?: string;

@ApiProperty()
_environmentId: string;

@ApiProperty()
_organizationId: string;

@ApiProperty()
createdAt: string;

@ApiProperty()
updatedAt: string;
}
Loading
Loading