diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 002e3a50686..49166047871 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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'; @@ -116,6 +117,7 @@ const baseModules: Array | Forward ContentTemplatesModule, OrganizationModule, ActivityModule, + AgentsModule, UserModule, IntegrationModule, InternalModule, diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts new file mode 100644 index 00000000000..1a9399b38c4 --- /dev/null +++ b/apps/api/src/app/agents/agents.controller.ts @@ -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 { + 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 { + 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, + }) + ); + } + + @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 { + 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 { + 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, + }) + ); + } + + @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 { + 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 { + 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 { + 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 { + 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 { + return this.deleteAgentUsecase.execute( + DeleteAgentCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + }) + ); + } +} diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts new file mode 100644 index 00000000000..8cc16903465 --- /dev/null +++ b/apps/api/src/app/agents/agents.module.ts @@ -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 {} diff --git a/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts b/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts new file mode 100644 index 00000000000..e9455d66a78 --- /dev/null +++ b/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts @@ -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; +} diff --git a/apps/api/src/app/agents/dtos/agent-integration-response.dto.ts b/apps/api/src/app/agents/dtos/agent-integration-response.dto.ts new file mode 100644 index 00000000000..92876484282 --- /dev/null +++ b/apps/api/src/app/agents/dtos/agent-integration-response.dto.ts @@ -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; +} diff --git a/apps/api/src/app/agents/dtos/agent-response.dto.ts b/apps/api/src/app/agents/dtos/agent-response.dto.ts new file mode 100644 index 00000000000..d5a4b0310d2 --- /dev/null +++ b/apps/api/src/app/agents/dtos/agent-response.dto.ts @@ -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; +} diff --git a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts new file mode 100644 index 00000000000..4da94f1ad64 --- /dev/null +++ b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateAgentRequestDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + identifier: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/app/agents/dtos/cursor-pagination-query.dto.ts b/apps/api/src/app/agents/dtos/cursor-pagination-query.dto.ts new file mode 100644 index 00000000000..65ae4ffc353 --- /dev/null +++ b/apps/api/src/app/agents/dtos/cursor-pagination-query.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DirectionEnum } from '@novu/shared'; +import { Transform, Type } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; + +export class CursorPaginationQueryDto { + @ApiProperty({ + description: 'Cursor for pagination indicating the starting point after which to fetch results.', + type: String, + required: false, + }) + @IsString() + @IsOptional() + after?: string; + + @ApiProperty({ + description: 'Cursor for pagination indicating the ending point before which to fetch results.', + type: String, + required: false, + }) + @IsString() + @IsOptional() + before?: string; + + @ApiPropertyOptional({ + description: 'Limit the number of items to return', + type: Number, + example: 10, + }) + @IsOptional() + @Type(() => Number) + limit?: number; + + @ApiPropertyOptional({ + description: 'Direction of sorting', + enum: DirectionEnum, + }) + @IsOptional() + orderDirection?: DirectionEnum; + + @ApiPropertyOptional({ + description: 'Field to order by', + type: String, + }) + @IsString() + @IsOptional() + orderBy?: K; + + @ApiPropertyOptional({ + description: 'Include cursor item in response', + type: Boolean, + }) + @Transform(({ value }) => value === 'true') + @IsOptional() + includeCursor?: boolean; +} diff --git a/apps/api/src/app/agents/dtos/index.ts b/apps/api/src/app/agents/dtos/index.ts new file mode 100644 index 00000000000..b63d9531dac --- /dev/null +++ b/apps/api/src/app/agents/dtos/index.ts @@ -0,0 +1,10 @@ +export * from './add-agent-integration-request.dto'; +export * from './agent-integration-response.dto'; +export * from './agent-response.dto'; +export * from './create-agent-request.dto'; +export * from './list-agent-integrations-query.dto'; +export * from './list-agent-integrations-response.dto'; +export * from './list-agents-query.dto'; +export * from './list-agents-response.dto'; +export * from './update-agent-integration-request.dto'; +export * from './update-agent-request.dto'; diff --git a/apps/api/src/app/agents/dtos/list-agent-integrations-query.dto.ts b/apps/api/src/app/agents/dtos/list-agent-integrations-query.dto.ts new file mode 100644 index 00000000000..a7abe9e74e3 --- /dev/null +++ b/apps/api/src/app/agents/dtos/list-agent-integrations-query.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +import { AgentIntegrationResponseDto } from './agent-integration-response.dto'; +import { CursorPaginationQueryDto } from './cursor-pagination-query.dto'; + +export class ListAgentIntegrationsQueryDto extends CursorPaginationQueryDto< + AgentIntegrationResponseDto, + 'createdAt' | 'updatedAt' | '_id' +> { + @ApiPropertyOptional({ + description: 'Return only links for this integration identifier (not the internal document _id).', + type: String, + }) + @IsOptional() + @IsString() + integrationIdentifier?: string; +} diff --git a/apps/api/src/app/agents/dtos/list-agent-integrations-response.dto.ts b/apps/api/src/app/agents/dtos/list-agent-integrations-response.dto.ts new file mode 100644 index 00000000000..75d651eb4b9 --- /dev/null +++ b/apps/api/src/app/agents/dtos/list-agent-integrations-response.dto.ts @@ -0,0 +1,6 @@ +import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; +import { AgentIntegrationResponseDto } from './agent-integration-response.dto'; + +export class ListAgentIntegrationsResponseDto extends withCursorPagination(AgentIntegrationResponseDto, { + description: 'List of agent–integration links', +}) {} diff --git a/apps/api/src/app/agents/dtos/list-agents-query.dto.ts b/apps/api/src/app/agents/dtos/list-agents-query.dto.ts new file mode 100644 index 00000000000..46bd3325530 --- /dev/null +++ b/apps/api/src/app/agents/dtos/list-agents-query.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +import { AgentResponseDto } from './agent-response.dto'; +import { CursorPaginationQueryDto } from './cursor-pagination-query.dto'; + +export class ListAgentsQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: 'Filter agents by partial, case-insensitive match on identifier.', + type: String, + }) + @IsOptional() + @IsString() + identifier?: string; +} diff --git a/apps/api/src/app/agents/dtos/list-agents-response.dto.ts b/apps/api/src/app/agents/dtos/list-agents-response.dto.ts new file mode 100644 index 00000000000..ac130e7a153 --- /dev/null +++ b/apps/api/src/app/agents/dtos/list-agents-response.dto.ts @@ -0,0 +1,6 @@ +import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; +import { AgentResponseDto } from './agent-response.dto'; + +export class ListAgentsResponseDto extends withCursorPagination(AgentResponseDto, { + description: 'List of returned agents', +}) {} diff --git a/apps/api/src/app/agents/dtos/update-agent-integration-request.dto.ts b/apps/api/src/app/agents/dtos/update-agent-integration-request.dto.ts new file mode 100644 index 00000000000..92fa72070cd --- /dev/null +++ b/apps/api/src/app/agents/dtos/update-agent-integration-request.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpdateAgentIntegrationRequestDto { + @ApiProperty({ + description: 'The integration identifier this link should point to (not the internal document _id).', + }) + @IsString() + @IsNotEmpty() + integrationIdentifier: string; +} diff --git a/apps/api/src/app/agents/dtos/update-agent-request.dto.ts b/apps/api/src/app/agents/dtos/update-agent-request.dto.ts new file mode 100644 index 00000000000..b7115bf6d01 --- /dev/null +++ b/apps/api/src/app/agents/dtos/update-agent-request.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateAgentRequestDto { + @ApiPropertyOptional() + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/app/agents/e2e/agents.e2e.ts b/apps/api/src/app/agents/e2e/agents.e2e.ts new file mode 100644 index 00000000000..a0dad59613b --- /dev/null +++ b/apps/api/src/app/agents/e2e/agents.e2e.ts @@ -0,0 +1,213 @@ +import { AgentIntegrationRepository, AgentRepository } from '@novu/dal'; +import { ChannelTypeEnum, EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/shared'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; + +describe('Agents API - /agents #novu-v2', () => { + let session: UserSession; + const agentRepository = new AgentRepository(); + const agentIntegrationRepository = new AgentIntegrationRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should create, list, get, patch, and delete an agent', async () => { + const identifier = `e2e-agent-${Date.now()}`; + + const createRes = await session.testAgent.post('/v1/agents').send({ + name: 'E2E Agent', + identifier, + description: 'e2e description', + }); + + expect(createRes.status).to.equal(201); + expect(createRes.body.data.name).to.equal('E2E Agent'); + expect(createRes.body.data.identifier).to.equal(identifier); + expect(createRes.body.data.description).to.equal('e2e description'); + expect(createRes.body.data._id).to.be.a('string'); + + const listRes = await session.testAgent.get('/v1/agents'); + + expect(listRes.status).to.equal(200); + expect(listRes.body.data).to.be.an('array'); + expect(listRes.body).to.have.property('next'); + expect(listRes.body).to.have.property('previous'); + expect(listRes.body).to.have.property('totalCount'); + expect(listRes.body).to.have.property('totalCountCapped'); + expect(listRes.body.data.some((a: { identifier: string }) => a.identifier === identifier)).to.be.true; + + const getRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`); + + expect(getRes.status).to.equal(200); + expect(getRes.body.data.identifier).to.equal(identifier); + + const patchRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + name: 'E2E Agent Updated', + description: 'updated', + }); + + expect(patchRes.status).to.equal(200); + expect(patchRes.body.data.name).to.equal('E2E Agent Updated'); + expect(patchRes.body.data.description).to.equal('updated'); + + const deleteRes = await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); + + expect(deleteRes.status).to.equal(204); + + const afterDelete = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`); + + expect(afterDelete.status).to.equal(404); + }); + + it('should return 404 when agent identifier does not exist', async () => { + const res = await session.testAgent.get('/v1/agents/nonexistent-agent-id-xyz'); + + expect(res.status).to.equal(404); + }); + + it('should return 400 when both before and after cursors are provided on list agents', async () => { + const response = await session.testAgent + .get('/v1/agents') + .query({ before: '000000000000000000000001', after: '000000000000000000000002' }); + + expect(response.status).to.equal(400); + expect(response.body.message).to.contain('Cannot specify both "before" and "after" cursors'); + }); + + it('should return 409 when creating a duplicate agent identifier in the same environment', async () => { + const identifier = `e2e-dup-${Date.now()}`; + + await session.testAgent.post('/v1/agents').send({ + name: 'First', + identifier, + }); + + const second = await session.testAgent.post('/v1/agents').send({ + name: 'Second', + identifier, + }); + + expect(second.status).to.equal(409); + }); + + it('should add, list, update, and remove agent-integration links', async () => { + const identifier = `e2e-agent-int-${Date.now()}`; + + await session.testAgent.post('/v1/agents').send({ + name: 'Agent With Integrations', + identifier, + }); + + const integrations = (await session.testAgent.get('/v1/integrations')).body.data as Array<{ + _id: string; + identifier: string; + channel: string; + providerId: string; + }>; + + const emailIntegration = integrations.find( + (i) => i.channel === ChannelTypeEnum.EMAIL && i.providerId === EmailProviderIdEnum.SendGrid + ); + const smsIntegration = integrations.find( + (i) => i.channel === ChannelTypeEnum.SMS && i.providerId === SmsProviderIdEnum.Twilio + ); + + expect(emailIntegration, 'seeded SendGrid integration').to.exist; + expect(smsIntegration, 'seeded Twilio integration').to.exist; + + if (!emailIntegration || !smsIntegration) { + throw new Error('Seeded email/SMS integrations not found'); + } + + const emailIntegrationIdentifier = emailIntegration.identifier; + const smsIntegrationIdentifier = smsIntegration.identifier; + + const addRes = await session.testAgent + .post(`/v1/agents/${encodeURIComponent(identifier)}/integrations`) + .send({ integrationIdentifier: emailIntegrationIdentifier }); + + expect(addRes.status).to.equal(201); + expect(addRes.body.data.integrationIdentifier).to.equal(emailIntegrationIdentifier); + const linkId = addRes.body.data._id as string; + + const listRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}/integrations`); + + expect(listRes.status).to.equal(200); + expect(listRes.body.data).to.be.an('array'); + expect(listRes.body).to.have.property('next'); + expect(listRes.body.data.length).to.equal(1); + expect(listRes.body.data[0]._id).to.equal(linkId); + + const patchLinkRes = await session.testAgent + .patch(`/v1/agents/${encodeURIComponent(identifier)}/integrations/${linkId}`) + .send({ integrationIdentifier: smsIntegrationIdentifier }); + + expect(patchLinkRes.status).to.equal(200); + expect(patchLinkRes.body.data.integrationIdentifier).to.equal(smsIntegrationIdentifier); + + const removeRes = await session.testAgent.delete( + `/v1/agents/${encodeURIComponent(identifier)}/integrations/${linkId}` + ); + + expect(removeRes.status).to.equal(204); + + const listAfterRemove = await session.testAgent.get( + `/v1/agents/${encodeURIComponent(identifier)}/integrations` + ); + + expect(listAfterRemove.body.data.length).to.equal(0); + + await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); + }); + + it('should delete agent and cascade remove agent-integration links', async () => { + const identifier = `e2e-cascade-${Date.now()}`; + + const createAgentRes = await session.testAgent.post('/v1/agents').send({ + name: 'Cascade Agent', + identifier, + }); + + const agentId = createAgentRes.body.data._id as string; + + const integrations = (await session.testAgent.get('/v1/integrations')).body.data as Array<{ + identifier: string; + }>; + const integrationIdentifier = integrations[0].identifier; + + await session.testAgent.post(`/v1/agents/${encodeURIComponent(identifier)}/integrations`).send({ + integrationIdentifier, + }); + + const countBefore = await agentIntegrationRepository.count({ + _agentId: agentId, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }); + + expect(countBefore).to.equal(1); + + await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); + + const countAfter = await agentIntegrationRepository.count({ + _agentId: agentId, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }); + + expect(countAfter).to.equal(0); + + const agentAfter = await agentRepository.findOne( + { + _id: agentId, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }, + ['_id'] + ); + + expect(agentAfter).to.equal(null); + }); +}); diff --git a/apps/api/src/app/agents/mappers/agent-response.mapper.ts b/apps/api/src/app/agents/mappers/agent-response.mapper.ts new file mode 100644 index 00000000000..9dac3fd0055 --- /dev/null +++ b/apps/api/src/app/agents/mappers/agent-response.mapper.ts @@ -0,0 +1,31 @@ +import type { AgentEntity, AgentIntegrationEntity } from '@novu/dal'; + +import type { AgentIntegrationResponseDto, AgentResponseDto } from '../dtos'; + +export function toAgentResponse(agent: AgentEntity): AgentResponseDto { + return { + _id: agent._id, + name: agent.name, + identifier: agent.identifier, + description: agent.description, + _environmentId: agent._environmentId, + _organizationId: agent._organizationId, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + }; +} + +export function toAgentIntegrationResponse( + link: AgentIntegrationEntity, + integrationIdentifier: string +): AgentIntegrationResponseDto { + return { + _id: link._id, + _agentId: link._agentId, + integrationIdentifier, + _environmentId: link._environmentId, + _organizationId: link._organizationId, + createdAt: link.createdAt, + updatedAt: link.updatedAt, + }; +} diff --git a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts new file mode 100644 index 00000000000..a9517d35e05 --- /dev/null +++ b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class AddAgentIntegrationCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + agentIdentifier: string; + + @IsString() + @IsNotEmpty() + integrationIdentifier: string; +} diff --git a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts new file mode 100644 index 00000000000..33444f773a7 --- /dev/null +++ b/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts @@ -0,0 +1,68 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; + +import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import type { AgentIntegrationResponseDto } from '../../dtos'; +import { AddAgentIntegrationCommand } from './add-agent-integration.command'; + +@Injectable() +export class AddAgentIntegration { + constructor( + private readonly agentRepository: AgentRepository, + private readonly integrationRepository: IntegrationRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository + ) {} + + async execute(command: AddAgentIntegrationCommand): Promise { + const agent = await this.agentRepository.findOne( + { + identifier: command.agentIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (!agent) { + throw new NotFoundException(`Agent with identifier "${command.agentIdentifier}" was not found.`); + } + + const integration = await this.integrationRepository.findOne( + { + identifier: command.integrationIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id', 'identifier'] + ); + + if (!integration) { + throw new NotFoundException( + `Integration with identifier "${command.integrationIdentifier}" was not found.` + ); + } + + const existingLink = await this.agentIntegrationRepository.findOne( + { + _agentId: agent._id, + _integrationId: integration._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (existingLink) { + throw new ConflictException('This integration is already linked to the agent.'); + } + + const link = await this.agentIntegrationRepository.create({ + _agentId: agent._id, + _integrationId: integration._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }); + + return toAgentIntegrationResponse(link, integration.identifier); + } +} diff --git a/apps/api/src/app/agents/usecases/create-agent/create-agent.command.ts b/apps/api/src/app/agents/usecases/create-agent/create-agent.command.ts new file mode 100644 index 00000000000..2f7eb88a93b --- /dev/null +++ b/apps/api/src/app/agents/usecases/create-agent/create-agent.command.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class CreateAgentCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + identifier: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/app/agents/usecases/create-agent/create-agent.usecase.ts b/apps/api/src/app/agents/usecases/create-agent/create-agent.usecase.ts new file mode 100644 index 00000000000..aa58f553cdc --- /dev/null +++ b/apps/api/src/app/agents/usecases/create-agent/create-agent.usecase.ts @@ -0,0 +1,36 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { AgentRepository } from '@novu/dal'; + +import { toAgentResponse } from '../../mappers/agent-response.mapper'; +import type { AgentResponseDto } from '../../dtos'; +import { CreateAgentCommand } from './create-agent.command'; + +@Injectable() +export class CreateAgent { + constructor(private readonly agentRepository: AgentRepository) {} + + async execute(command: CreateAgentCommand): Promise { + const existing = await this.agentRepository.findOne( + { + identifier: command.identifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (existing) { + throw new ConflictException(`An agent with identifier "${command.identifier}" already exists in this environment.`); + } + + const agent = await this.agentRepository.create({ + name: command.name, + identifier: command.identifier, + description: command.description, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }); + + return toAgentResponse(agent); + } +} diff --git a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.command.ts b/apps/api/src/app/agents/usecases/delete-agent/delete-agent.command.ts new file mode 100644 index 00000000000..2780d320225 --- /dev/null +++ b/apps/api/src/app/agents/usecases/delete-agent/delete-agent.command.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class DeleteAgentCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + identifier: string; +} diff --git a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts b/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts new file mode 100644 index 00000000000..5913787d1f2 --- /dev/null +++ b/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts @@ -0,0 +1,39 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AgentIntegrationRepository, AgentRepository } from '@novu/dal'; + +import { DeleteAgentCommand } from './delete-agent.command'; + +@Injectable() +export class DeleteAgent { + constructor( + private readonly agentRepository: AgentRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository + ) {} + + async execute(command: DeleteAgentCommand): Promise { + const agent = await this.agentRepository.findOne( + { + identifier: command.identifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (!agent) { + throw new NotFoundException(`Agent with identifier "${command.identifier}" was not found.`); + } + + await this.agentIntegrationRepository.delete({ + _agentId: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }); + + await this.agentRepository.delete({ + _id: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }); + } +} diff --git a/apps/api/src/app/agents/usecases/get-agent/get-agent.command.ts b/apps/api/src/app/agents/usecases/get-agent/get-agent.command.ts new file mode 100644 index 00000000000..8dc95b097c8 --- /dev/null +++ b/apps/api/src/app/agents/usecases/get-agent/get-agent.command.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class GetAgentCommand extends EnvironmentCommand { + @IsString() + @IsNotEmpty() + identifier: string; +} diff --git a/apps/api/src/app/agents/usecases/get-agent/get-agent.usecase.ts b/apps/api/src/app/agents/usecases/get-agent/get-agent.usecase.ts new file mode 100644 index 00000000000..9610b1947b3 --- /dev/null +++ b/apps/api/src/app/agents/usecases/get-agent/get-agent.usecase.ts @@ -0,0 +1,28 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AgentRepository } from '@novu/dal'; + +import { toAgentResponse } from '../../mappers/agent-response.mapper'; +import type { AgentResponseDto } from '../../dtos'; +import { GetAgentCommand } from './get-agent.command'; + +@Injectable() +export class GetAgent { + constructor(private readonly agentRepository: AgentRepository) {} + + async execute(command: GetAgentCommand): Promise { + const agent = await this.agentRepository.findOne( + { + identifier: command.identifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + '*' + ); + + if (!agent) { + throw new NotFoundException(`Agent with identifier "${command.identifier}" was not found.`); + } + + return toAgentResponse(agent); + } +} diff --git a/apps/api/src/app/agents/usecases/index.ts b/apps/api/src/app/agents/usecases/index.ts new file mode 100644 index 00000000000..ce4cef7cde6 --- /dev/null +++ b/apps/api/src/app/agents/usecases/index.ts @@ -0,0 +1,21 @@ +import { AddAgentIntegration } from './add-agent-integration/add-agent-integration.usecase'; +import { CreateAgent } from './create-agent/create-agent.usecase'; +import { DeleteAgent } from './delete-agent/delete-agent.usecase'; +import { GetAgent } from './get-agent/get-agent.usecase'; +import { ListAgentIntegrations } from './list-agent-integrations/list-agent-integrations.usecase'; +import { ListAgents } from './list-agents/list-agents.usecase'; +import { RemoveAgentIntegration } from './remove-agent-integration/remove-agent-integration.usecase'; +import { UpdateAgentIntegration } from './update-agent-integration/update-agent-integration.usecase'; +import { UpdateAgent } from './update-agent/update-agent.usecase'; + +export const USE_CASES = [ + CreateAgent, + GetAgent, + ListAgents, + UpdateAgent, + DeleteAgent, + AddAgentIntegration, + ListAgentIntegrations, + UpdateAgentIntegration, + RemoveAgentIntegration, +]; diff --git a/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.command.ts b/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.command.ts new file mode 100644 index 00000000000..e056f2ed459 --- /dev/null +++ b/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.command.ts @@ -0,0 +1,26 @@ +import { CursorBasedPaginatedCommand } from '@novu/application-generic'; +import { AgentIntegrationEntity } from '@novu/dal'; +import { IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ListAgentIntegrationsCommand extends CursorBasedPaginatedCommand< + AgentIntegrationEntity, + 'createdAt' | 'updatedAt' | '_id' +> { + @IsString() + @IsNotEmpty() + @IsMongoId() + environmentId: string; + + @IsString() + @IsMongoId() + @IsNotEmpty() + organizationId: string; + + @IsString() + @IsNotEmpty() + agentIdentifier: string; + + @IsString() + @IsOptional() + integrationIdentifier?: string; +} diff --git a/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.usecase.ts b/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.usecase.ts new file mode 100644 index 00000000000..5f3277154eb --- /dev/null +++ b/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.usecase.ts @@ -0,0 +1,101 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; +import { DirectionEnum } from '@novu/shared'; + +import { ListAgentIntegrationsResponseDto } from '../../dtos/list-agent-integrations-response.dto'; +import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import { ListAgentIntegrationsCommand } from './list-agent-integrations.command'; + +@Injectable() +export class ListAgentIntegrations { + constructor( + private readonly agentRepository: AgentRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly integrationRepository: IntegrationRepository + ) {} + + @InstrumentUsecase() + async execute(command: ListAgentIntegrationsCommand): Promise { + if (command.before && command.after) { + throw new BadRequestException('Cannot specify both "before" and "after" cursors at the same time.'); + } + + const agent = await this.agentRepository.findOne( + { + identifier: command.agentIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (!agent) { + throw new NotFoundException(`Agent with identifier "${command.agentIdentifier}" was not found.`); + } + + let filterIntegrationId: string | undefined; + + if (command.integrationIdentifier) { + const filterIntegration = await this.integrationRepository.findOne( + { + identifier: command.integrationIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (!filterIntegration) { + return { + data: [], + next: null, + previous: null, + totalCount: 0, + totalCountCapped: false, + }; + } + + filterIntegrationId = filterIntegration._id; + } + + const pagination = await this.agentIntegrationRepository.listAgentIntegrationsForAgent({ + after: command.after, + before: command.before, + limit: command.limit, + sortDirection: command.orderDirection === DirectionEnum.ASC ? 1 : -1, + sortBy: command.orderBy, + environmentId: command.environmentId, + organizationId: command.organizationId, + agentId: agent._id, + includeCursor: command.includeCursor, + integrationId: filterIntegrationId, + }); + + const integrationIds = [...new Set(pagination.links.map((link) => link._integrationId))]; + let idToIdentifier = new Map(); + + if (integrationIds.length > 0) { + const integrations = await this.integrationRepository.find( + { + _id: { $in: integrationIds }, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + '_id identifier' + ); + + idToIdentifier = new Map(integrations.map((i) => [i._id, i.identifier])); + } + + return { + data: pagination.links.map((link) => + toAgentIntegrationResponse(link, idToIdentifier.get(link._integrationId) ?? '') + ), + next: pagination.next, + previous: pagination.previous, + totalCount: pagination.totalCount, + totalCountCapped: pagination.totalCountCapped, + }; + } +} diff --git a/apps/api/src/app/agents/usecases/list-agents/list-agents.command.ts b/apps/api/src/app/agents/usecases/list-agents/list-agents.command.ts new file mode 100644 index 00000000000..06a4959547b --- /dev/null +++ b/apps/api/src/app/agents/usecases/list-agents/list-agents.command.ts @@ -0,0 +1,19 @@ +import { CursorBasedPaginatedCommand } from '@novu/application-generic'; +import { AgentEntity } from '@novu/dal'; +import { IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ListAgentsCommand extends CursorBasedPaginatedCommand { + @IsString() + @IsNotEmpty() + @IsMongoId() + environmentId: string; + + @IsString() + @IsMongoId() + @IsNotEmpty() + organizationId: string; + + @IsString() + @IsOptional() + identifier?: string; +} diff --git a/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts b/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts new file mode 100644 index 00000000000..94365257a61 --- /dev/null +++ b/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts @@ -0,0 +1,39 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { AgentRepository } from '@novu/dal'; +import { DirectionEnum } from '@novu/shared'; +import { ListAgentsResponseDto } from '../../dtos/list-agents-response.dto'; +import { toAgentResponse } from '../../mappers/agent-response.mapper'; +import { ListAgentsCommand } from './list-agents.command'; + +@Injectable() +export class ListAgents { + constructor(private readonly agentRepository: AgentRepository) {} + + @InstrumentUsecase() + async execute(command: ListAgentsCommand): Promise { + if (command.before && command.after) { + throw new BadRequestException('Cannot specify both "before" and "after" cursors at the same time.'); + } + + const pagination = await this.agentRepository.listAgents({ + after: command.after, + before: command.before, + limit: command.limit, + sortDirection: command.orderDirection === DirectionEnum.ASC ? 1 : -1, + sortBy: command.orderBy, + environmentId: command.environmentId, + organizationId: command.organizationId, + includeCursor: command.includeCursor, + identifier: command.identifier, + }); + + return { + data: pagination.agents.map((agent) => toAgentResponse(agent)), + next: pagination.next, + previous: pagination.previous, + totalCount: pagination.totalCount, + totalCountCapped: pagination.totalCountCapped, + }; + } +} diff --git a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.command.ts b/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.command.ts new file mode 100644 index 00000000000..b250d390cb6 --- /dev/null +++ b/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.command.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class RemoveAgentIntegrationCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + agentIdentifier: string; + + @IsString() + @IsNotEmpty() + agentIntegrationId: string; +} diff --git a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts b/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts new file mode 100644 index 00000000000..7a4852cbe32 --- /dev/null +++ b/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AgentIntegrationRepository, AgentRepository } from '@novu/dal'; + +import { RemoveAgentIntegrationCommand } from './remove-agent-integration.command'; + +@Injectable() +export class RemoveAgentIntegration { + constructor( + private readonly agentRepository: AgentRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository + ) {} + + async execute(command: RemoveAgentIntegrationCommand): Promise { + const agent = await this.agentRepository.findOne( + { + identifier: command.agentIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (!agent) { + throw new NotFoundException(`Agent with identifier "${command.agentIdentifier}" was not found.`); + } + + const deleted = await this.agentIntegrationRepository.findOneAndDelete({ + _id: command.agentIntegrationId, + _agentId: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }); + + if (!deleted) { + throw new NotFoundException( + `Agent-integration link "${command.agentIntegrationId}" was not found for this agent.` + ); + } + } +} diff --git a/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.command.ts b/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.command.ts new file mode 100644 index 00000000000..1b662882cb1 --- /dev/null +++ b/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.command.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class UpdateAgentIntegrationCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + agentIdentifier: string; + + @IsString() + @IsNotEmpty() + agentIntegrationId: string; + + @IsString() + @IsNotEmpty() + integrationIdentifier: string; +} diff --git a/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.usecase.ts b/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.usecase.ts new file mode 100644 index 00000000000..75330fd59ac --- /dev/null +++ b/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.usecase.ts @@ -0,0 +1,106 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; + +import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import type { AgentIntegrationResponseDto } from '../../dtos'; +import { UpdateAgentIntegrationCommand } from './update-agent-integration.command'; + +@Injectable() +export class UpdateAgentIntegration { + constructor( + private readonly agentRepository: AgentRepository, + private readonly integrationRepository: IntegrationRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository + ) {} + + async execute(command: UpdateAgentIntegrationCommand): Promise { + const agent = await this.agentRepository.findOne( + { + identifier: command.agentIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (!agent) { + throw new NotFoundException(`Agent with identifier "${command.agentIdentifier}" was not found.`); + } + + const existingLink = await this.agentIntegrationRepository.findOne( + { + _id: command.agentIntegrationId, + _agentId: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + '*' + ); + + if (!existingLink) { + throw new NotFoundException( + `Agent-integration link "${command.agentIntegrationId}" was not found for this agent.` + ); + } + + const targetIntegration = await this.integrationRepository.findOne( + { + identifier: command.integrationIdentifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id', 'identifier'] + ); + + if (!targetIntegration) { + throw new NotFoundException( + `Integration with identifier "${command.integrationIdentifier}" was not found.` + ); + } + + if (existingLink._integrationId === targetIntegration._id) { + return toAgentIntegrationResponse(existingLink, targetIntegration.identifier); + } + + const duplicate = await this.agentIntegrationRepository.findOne( + { + _agentId: agent._id, + _integrationId: targetIntegration._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + ['_id'] + ); + + if (duplicate && duplicate._id !== command.agentIntegrationId) { + throw new ConflictException('This integration is already linked to the agent.'); + } + + await this.agentIntegrationRepository.updateOne( + { + _id: command.agentIntegrationId, + _agentId: agent._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + { $set: { _integrationId: targetIntegration._id } } + ); + + const updated = await this.agentIntegrationRepository.findById( + { + _id: command.agentIntegrationId, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + '*' + ); + + if (!updated) { + throw new NotFoundException( + `Agent-integration link "${command.agentIntegrationId}" was not found after update.` + ); + } + + return toAgentIntegrationResponse(updated, targetIntegration.identifier); + } +} diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts b/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts new file mode 100644 index 00000000000..f7f54f64632 --- /dev/null +++ b/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class UpdateAgentCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + identifier: string; + + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts new file mode 100644 index 00000000000..e972b2e1ffb --- /dev/null +++ b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts @@ -0,0 +1,64 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AgentRepository } from '@novu/dal'; + +import { toAgentResponse } from '../../mappers/agent-response.mapper'; +import type { AgentResponseDto } from '../../dtos'; +import { UpdateAgentCommand } from './update-agent.command'; + +@Injectable() +export class UpdateAgent { + constructor(private readonly agentRepository: AgentRepository) {} + + async execute(command: UpdateAgentCommand): Promise { + if (command.name === undefined && command.description === undefined) { + throw new BadRequestException('At least one of name or description must be provided.'); + } + + const existing = await this.agentRepository.findOne( + { + identifier: command.identifier, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + '*' + ); + + if (!existing) { + throw new NotFoundException(`Agent with identifier "${command.identifier}" was not found.`); + } + + const $set: Record = {}; + + if (command.name !== undefined) { + $set.name = command.name; + } + + if (command.description !== undefined) { + $set.description = command.description; + } + + await this.agentRepository.updateOne( + { + _id: existing._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + { $set } + ); + + const updated = await this.agentRepository.findById( + { + _id: existing._id, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }, + '*' + ); + + if (!updated) { + throw new NotFoundException(`Agent with identifier "${command.identifier}" was not found.`); + } + + return toAgentResponse(updated); + } +} diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts index 418b2ac57b0..f8c528e1a88 100644 --- a/apps/api/src/app/shared/shared.module.ts +++ b/apps/api/src/app/shared/shared.module.ts @@ -30,6 +30,8 @@ import { WorkflowRunRepository, } from '@novu/application-generic'; import { + AgentIntegrationRepository, + AgentRepository, ChangeRepository, CommunityMemberRepository, CommunityOrganizationRepository, @@ -111,6 +113,8 @@ const DAL_MODELS = [ ControlValuesRepository, PreferencesRepository, EnvironmentVariableRepository, + AgentRepository, + AgentIntegrationRepository, ]; const dalService = { diff --git a/libs/dal/src/index.ts b/libs/dal/src/index.ts index 74f0ca73f48..c7426e9515b 100644 --- a/libs/dal/src/index.ts +++ b/libs/dal/src/index.ts @@ -1,4 +1,6 @@ export * from './dal.service'; +export * from './repositories/agent'; +export * from './repositories/agent-integration'; export * from './repositories/ai-chat'; export * from './repositories/base-repository'; export * from './repositories/base-repository-v2'; diff --git a/libs/dal/src/repositories/agent-integration/agent-integration.entity.ts b/libs/dal/src/repositories/agent-integration/agent-integration.entity.ts new file mode 100644 index 00000000000..5859a7a9aec --- /dev/null +++ b/libs/dal/src/repositories/agent-integration/agent-integration.entity.ts @@ -0,0 +1,24 @@ +import type { ChangePropsValueType } from '../../types/helpers'; +import type { EnvironmentId } from '../environment'; +import type { OrganizationId } from '../organization'; + +export class AgentIntegrationEntity { + _id: string; + + _agentId: string; + + _integrationId: string; + + _environmentId: EnvironmentId; + + _organizationId: OrganizationId; + + createdAt: string; + + updatedAt: string; +} + +export type AgentIntegrationDBModel = ChangePropsValueType< + AgentIntegrationEntity, + '_agentId' | '_integrationId' | '_environmentId' | '_organizationId' +>; diff --git a/libs/dal/src/repositories/agent-integration/agent-integration.repository.ts b/libs/dal/src/repositories/agent-integration/agent-integration.repository.ts new file mode 100644 index 00000000000..3dd1783a811 --- /dev/null +++ b/libs/dal/src/repositories/agent-integration/agent-integration.repository.ts @@ -0,0 +1,110 @@ +import { DirectionEnum } from '@novu/shared'; +import { FilterQuery } from 'mongoose'; + +import type { EnforceEnvOrOrgIds } from '../../types'; +import { SortOrder } from '../../types/sort-order'; +import { BaseRepositoryV2 } from '../base-repository-v2'; +import { AgentIntegrationDBModel, AgentIntegrationEntity } from './agent-integration.entity'; +import { AgentIntegration } from './agent-integration.schema'; + +export class AgentIntegrationRepository extends BaseRepositoryV2< + AgentIntegrationDBModel, + AgentIntegrationEntity, + EnforceEnvOrOrgIds +> { + constructor() { + super(AgentIntegration, AgentIntegrationEntity); + } + + async listAgentIntegrationsForAgent({ + organizationId, + environmentId, + agentId, + limit = 10, + after, + before, + sortBy = '_id', + sortDirection = 1, + includeCursor = false, + integrationId, + }: { + organizationId: string; + environmentId: string; + agentId: string; + limit?: number; + after?: string; + before?: string; + sortBy?: string; + sortDirection?: SortOrder; + includeCursor?: boolean; + integrationId?: string; + }): Promise<{ + links: AgentIntegrationEntity[]; + next: string | null; + previous: string | null; + totalCount: number; + totalCountCapped: boolean; + }> { + if (before && after) { + throw new Error('Cannot specify both "before" and "after" cursors at the same time.'); + } + + let link: AgentIntegrationEntity | null = null; + const id = before || after; + + if (id) { + link = await this.findOne( + { + _environmentId: environmentId, + _organizationId: organizationId, + _agentId: agentId, + _id: id, + }, + '*' + ); + + if (!link) { + return { + links: [], + next: null, + previous: null, + totalCount: 0, + totalCountCapped: false, + }; + } + } + + const afterCursor = after && link ? { sortBy: link[sortBy], paginateField: link._id } : undefined; + const beforeCursor = before && link ? { sortBy: link[sortBy], paginateField: link._id } : undefined; + + const query: FilterQuery & EnforceEnvOrOrgIds = { + _environmentId: environmentId, + _organizationId: organizationId, + _agentId: agentId, + }; + + if (integrationId) { + query._integrationId = integrationId; + } + + const pagination = await this.findWithCursorBasedPagination({ + after: afterCursor, + before: beforeCursor, + paginateField: '_id', + limit, + sortDirection: sortDirection === 1 ? DirectionEnum.ASC : DirectionEnum.DESC, + sortBy, + includeCursor, + query, + select: '*', + }); + + return { + links: pagination.data, + next: pagination.next, + previous: pagination.previous, + totalCount: pagination.totalCount, + totalCountCapped: pagination.totalCountCapped, + }; + } +} diff --git a/libs/dal/src/repositories/agent-integration/agent-integration.schema.ts b/libs/dal/src/repositories/agent-integration/agent-integration.schema.ts new file mode 100644 index 00000000000..d3688ccb350 --- /dev/null +++ b/libs/dal/src/repositories/agent-integration/agent-integration.schema.ts @@ -0,0 +1,42 @@ +import mongoose, { Schema } from 'mongoose'; + +import { schemaOptions } from '../schema-default.options'; +import { AgentIntegrationDBModel } from './agent-integration.entity'; + +const agentIntegrationSchema = new Schema( + { + _agentId: { + type: Schema.Types.ObjectId, + ref: 'Agent', + }, + _integrationId: { + type: Schema.Types.ObjectId, + ref: 'Integration', + }, + _organizationId: { + type: Schema.Types.ObjectId, + ref: 'Organization', + }, + _environmentId: { + type: Schema.Types.ObjectId, + ref: 'Environment', + }, + }, + schemaOptions +); + +agentIntegrationSchema.index( + { + _agentId: 1, + _integrationId: 1, + _environmentId: 1, + }, + { unique: true } +); + +agentIntegrationSchema.index({ _agentId: 1 }); +agentIntegrationSchema.index({ _environmentId: 1 }); + +export const AgentIntegration = + (mongoose.models.AgentIntegration as mongoose.Model) || + mongoose.model('AgentIntegration', agentIntegrationSchema); diff --git a/libs/dal/src/repositories/agent-integration/index.ts b/libs/dal/src/repositories/agent-integration/index.ts new file mode 100644 index 00000000000..45d15e85224 --- /dev/null +++ b/libs/dal/src/repositories/agent-integration/index.ts @@ -0,0 +1,3 @@ +export * from './agent-integration.entity'; +export * from './agent-integration.repository'; +export * from './agent-integration.schema'; diff --git a/libs/dal/src/repositories/agent/agent.entity.ts b/libs/dal/src/repositories/agent/agent.entity.ts new file mode 100644 index 00000000000..c7e83c45f03 --- /dev/null +++ b/libs/dal/src/repositories/agent/agent.entity.ts @@ -0,0 +1,23 @@ +import type { ChangePropsValueType } from '../../types/helpers'; +import type { EnvironmentId } from '../environment'; +import type { OrganizationId } from '../organization'; + +export class AgentEntity { + _id: string; + + name: string; + + identifier: string; + + description?: string; + + _environmentId: EnvironmentId; + + _organizationId: OrganizationId; + + createdAt: string; + + updatedAt: string; +} + +export type AgentDBModel = ChangePropsValueType; diff --git a/libs/dal/src/repositories/agent/agent.repository.ts b/libs/dal/src/repositories/agent/agent.repository.ts new file mode 100644 index 00000000000..59bbf2c7839 --- /dev/null +++ b/libs/dal/src/repositories/agent/agent.repository.ts @@ -0,0 +1,102 @@ +import { DirectionEnum } from '@novu/shared'; +import { FilterQuery } from 'mongoose'; + +import type { EnforceEnvOrOrgIds } from '../../types'; +import { SortOrder } from '../../types/sort-order'; +import { BaseRepositoryV2 } from '../base-repository-v2'; +import { AgentDBModel, AgentEntity } from './agent.entity'; +import { Agent } from './agent.schema'; + +export class AgentRepository extends BaseRepositoryV2 { + constructor() { + super(Agent, AgentEntity); + } + + async listAgents({ + organizationId, + environmentId, + limit = 10, + after, + before, + sortBy = '_id', + sortDirection = 1, + includeCursor = false, + identifier, + }: { + organizationId: string; + environmentId: string; + limit?: number; + after?: string; + before?: string; + sortBy?: string; + sortDirection?: SortOrder; + includeCursor?: boolean; + identifier?: string; + }): Promise<{ + agents: AgentEntity[]; + next: string | null; + previous: string | null; + totalCount: number; + totalCountCapped: boolean; + }> { + if (before && after) { + throw new Error('Cannot specify both "before" and "after" cursors at the same time.'); + } + + let agent: AgentEntity | null = null; + const id = before || after; + + if (id) { + agent = await this.findOne( + { + _environmentId: environmentId, + _organizationId: organizationId, + _id: id, + }, + '*' + ); + + if (!agent) { + return { + agents: [], + next: null, + previous: null, + totalCount: 0, + totalCountCapped: false, + }; + } + } + + const afterCursor = after && agent ? { sortBy: agent[sortBy], paginateField: agent._id } : undefined; + const beforeCursor = before && agent ? { sortBy: agent[sortBy], paginateField: agent._id } : undefined; + + const query: FilterQuery & EnforceEnvOrOrgIds = { + _environmentId: environmentId, + _organizationId: organizationId, + }; + + if (identifier) { + query.identifier = { $regex: this.regExpEscape(identifier), $options: 'i' }; + } + + const pagination = await this.findWithCursorBasedPagination({ + after: afterCursor, + before: beforeCursor, + paginateField: '_id', + limit, + sortDirection: sortDirection === 1 ? DirectionEnum.ASC : DirectionEnum.DESC, + sortBy, + includeCursor, + query, + select: '*', + }); + + return { + agents: pagination.data, + next: pagination.next, + previous: pagination.previous, + totalCount: pagination.totalCount, + totalCountCapped: pagination.totalCountCapped, + }; + } +} diff --git a/libs/dal/src/repositories/agent/agent.schema.ts b/libs/dal/src/repositories/agent/agent.schema.ts new file mode 100644 index 00000000000..482ce9b7121 --- /dev/null +++ b/libs/dal/src/repositories/agent/agent.schema.ts @@ -0,0 +1,33 @@ +import mongoose, { Schema } from 'mongoose'; + +import { schemaOptions } from '../schema-default.options'; +import { AgentDBModel } from './agent.entity'; + +const agentSchema = new Schema( + { + name: { + type: Schema.Types.String, + required: true, + }, + identifier: { + type: Schema.Types.String, + required: true, + }, + description: Schema.Types.String, + _organizationId: { + type: Schema.Types.ObjectId, + ref: 'Organization', + }, + _environmentId: { + type: Schema.Types.ObjectId, + ref: 'Environment', + }, + }, + schemaOptions +); + +agentSchema.index({ _environmentId: 1 }); +agentSchema.index({ identifier: 1, _environmentId: 1 }, { unique: true }); + +export const Agent = + (mongoose.models.Agent as mongoose.Model) || mongoose.model('Agent', agentSchema); diff --git a/libs/dal/src/repositories/agent/index.ts b/libs/dal/src/repositories/agent/index.ts new file mode 100644 index 00000000000..70ec338ee1b --- /dev/null +++ b/libs/dal/src/repositories/agent/index.ts @@ -0,0 +1,3 @@ +export * from './agent.entity'; +export * from './agent.repository'; +export * from './agent.schema'; diff --git a/libs/dal/src/repositories/base-repository-v2.ts b/libs/dal/src/repositories/base-repository-v2.ts index 735f8a30b6f..94fa416a9d5 100644 --- a/libs/dal/src/repositories/base-repository-v2.ts +++ b/libs/dal/src/repositories/base-repository-v2.ts @@ -410,7 +410,11 @@ export class BaseRepositoryV2 { ): Promise<{ acknowledged: boolean; deletedCount: number }> { const { session } = options; - return this.MongooseModel.deleteMany(query, session ? { session } : {}); + if (session) { + return this.MongooseModel.deleteMany(query, { session }); + } + + return this.MongooseModel.deleteMany(query); } async create( diff --git a/packages/shared/src/types/auth.ts b/packages/shared/src/types/auth.ts index f437e65dfc8..58a55a12184 100644 --- a/packages/shared/src/types/auth.ts +++ b/packages/shared/src/types/auth.ts @@ -65,6 +65,8 @@ export enum PermissionsEnum { BRIDGE_WRITE = 'org:bridge:write', ORG_SETTINGS_WRITE = 'org:settings:write', ORG_SETTINGS_READ = 'org:settings:read', + AGENT_READ = 'org:agent:read', + AGENT_WRITE = 'org:agent:write', } export const ALL_PERMISSIONS = Object.values(PermissionsEnum); @@ -73,6 +75,8 @@ export const ROLE_PERMISSIONS: Record = { [MemberRoleEnum.OWNER]: [ PermissionsEnum.WORKFLOW_READ, PermissionsEnum.WORKFLOW_WRITE, + PermissionsEnum.AGENT_READ, + PermissionsEnum.AGENT_WRITE, PermissionsEnum.WEBHOOK_READ, PermissionsEnum.WEBHOOK_WRITE, PermissionsEnum.ENVIRONMENT_WRITE, @@ -99,6 +103,8 @@ export const ROLE_PERMISSIONS: Record = { [MemberRoleEnum.ADMIN]: [ PermissionsEnum.WORKFLOW_READ, PermissionsEnum.WORKFLOW_WRITE, + PermissionsEnum.AGENT_READ, + PermissionsEnum.AGENT_WRITE, PermissionsEnum.WEBHOOK_READ, PermissionsEnum.WEBHOOK_WRITE, PermissionsEnum.ENVIRONMENT_WRITE, @@ -124,6 +130,8 @@ export const ROLE_PERMISSIONS: Record = { [MemberRoleEnum.AUTHOR]: [ PermissionsEnum.WORKFLOW_READ, PermissionsEnum.WORKFLOW_WRITE, + PermissionsEnum.AGENT_READ, + PermissionsEnum.AGENT_WRITE, PermissionsEnum.EVENT_WRITE, PermissionsEnum.INTEGRATION_READ, PermissionsEnum.INTEGRATION_WRITE, @@ -137,6 +145,7 @@ export const ROLE_PERMISSIONS: Record = { ], [MemberRoleEnum.VIEWER]: [ PermissionsEnum.WORKFLOW_READ, + PermissionsEnum.AGENT_READ, PermissionsEnum.INTEGRATION_READ, PermissionsEnum.MESSAGE_READ, PermissionsEnum.SUBSCRIBER_READ, diff --git a/packages/stateless/src/lib/provider/provider.interface.ts b/packages/stateless/src/lib/provider/provider.interface.ts index 064bacd4283..5f9f1e32c7d 100644 --- a/packages/stateless/src/lib/provider/provider.interface.ts +++ b/packages/stateless/src/lib/provider/provider.interface.ts @@ -51,6 +51,7 @@ export interface IPushOptions { title: string; content: string; payload: object; + /** Novu message id; used by some providers (e.g. APNS) for collapse-id when not set in overrides. */ messageId?: string; overrides?: { type?: 'notification' | 'data';