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
314 changes: 314 additions & 0 deletions apps/api/src/app/agents/agents.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import {
Body,
ClassSerializerInterceptor,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } 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';
import {
ApiCommonResponses,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiResponse,
} from '../shared/framework/response.decorator';
import { UserSession } from '../shared/framework/user.decorator';
import {
AddAgentIntegrationRequestDto,
AgentIntegrationResponseDto,
AgentResponseDto,
CreateAgentRequestDto,
ListAgentIntegrationsQueryDto,
ListAgentIntegrationsResponseDto,
ListAgentsQueryDto,
ListAgentsResponseDto,
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.AGENT_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(ListAgentsResponseDto)
@ApiOperation({
summary: 'List agents',
description:
'Returns a cursor-paginated list of agents for the current environment. Use **after**, **before**, **limit**, **orderBy**, and **orderDirection** query parameters.',
})
@RequirePermissions(PermissionsEnum.AGENT_READ)
listAgents(
@UserSession() user: UserSessionData,
@Query() query: ListAgentsQueryDto
): Promise<ListAgentsResponseDto> {
return this.listAgentsUsecase.execute(
ListAgentsCommand.create({
user,
environmentId: user.environmentId,
organizationId: user.organizationId,
limit: Number(query.limit || '10'),
after: query.after,
before: query.before,
orderDirection: query.orderDirection || DirectionEnum.DESC,
orderBy: query.orderBy || '_id',
includeCursor: query.includeCursor,
identifier: query.identifier,
})
);
}
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 integration **identifier**, not the internal _id).',
})
@ApiNotFoundResponse({
description: 'The agent or integration was not found.',
})
@RequirePermissions(PermissionsEnum.AGENT_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,
integrationIdentifier: body.integrationIdentifier,
})
);
}

@Get('/:identifier/integrations')
@ApiResponse(ListAgentIntegrationsResponseDto)
@ApiOperation({
summary: 'List agent integrations',
description:
'Lists integration links for an agent identified by its external identifier. Supports cursor pagination via **after**, **before**, **limit**, **orderBy**, and **orderDirection**.',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@RequirePermissions(PermissionsEnum.AGENT_READ)
listAgentIntegrations(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string,
@Query() query: ListAgentIntegrationsQueryDto
): Promise<ListAgentIntegrationsResponseDto> {
return this.listAgentIntegrationsUsecase.execute(
ListAgentIntegrationsCommand.create({
user,
environmentId: user.environmentId,
organizationId: user.organizationId,
agentIdentifier: identifier,
limit: Number(query.limit || '10'),
after: query.after,
before: query.before,
orderDirection: query.orderDirection || DirectionEnum.DESC,
orderBy: query.orderBy || '_id',
includeCursor: query.includeCursor,
integrationIdentifier: query.integrationIdentifier,
})
);
}
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 **identifier**, not the internal _id).',
})
@ApiNotFoundResponse({
description: 'The agent, integration, or link was not found.',
})
@RequirePermissions(PermissionsEnum.AGENT_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,
integrationIdentifier: body.integrationIdentifier,
})
);
}

@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.AGENT_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.AGENT_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.AGENT_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.AGENT_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 integration identifier (same as in the integration store), not the internal document _id.',
})
@IsString()
@IsNotEmpty()
integrationIdentifier: string;
}
26 changes: 26 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,26 @@
import { ApiProperty } from '@nestjs/swagger';

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

@ApiProperty()
_agentId: string;

@ApiProperty({
description: 'The integration identifier (matches the integration store), not the internal MongoDB _id.',
})
integrationIdentifier: string;

@ApiProperty()
_environmentId: string;

@ApiProperty()
_organizationId: string;

@ApiProperty()
createdAt: string;

@ApiProperty()
updatedAt: string;
}
Loading
Loading