Skip to content

Commit ddcef59

Browse files
authored
feat(dashboard): Agent management page list view (#10690)
1 parent f62462b commit ddcef59

File tree

22 files changed

+1192
-112
lines changed

22 files changed

+1192
-112
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { ChannelTypeEnum } from '@novu/shared';
3+
4+
export class AgentIntegrationSummaryDto {
5+
@ApiProperty({ description: 'Integration document id.' })
6+
integrationId: string;
7+
8+
@ApiProperty()
9+
providerId: string;
10+
11+
@ApiProperty()
12+
name: string;
13+
14+
@ApiProperty()
15+
identifier: string;
16+
17+
@ApiProperty({ enum: ChannelTypeEnum, enumName: 'ChannelTypeEnum' })
18+
channel: ChannelTypeEnum;
19+
20+
@ApiProperty()
21+
active: boolean;
22+
}

apps/api/src/app/agents/dtos/agent-response.dto.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
22

3+
import { AgentIntegrationSummaryDto } from './agent-integration-summary.dto';
4+
35
export class AgentResponseDto {
46
@ApiProperty()
57
_id: string;
@@ -24,4 +26,7 @@ export class AgentResponseDto {
2426

2527
@ApiProperty()
2628
updatedAt: string;
29+
30+
@ApiPropertyOptional({ type: [AgentIntegrationSummaryDto] })
31+
integrations?: AgentIntegrationSummaryDto[];
2732
}

apps/api/src/app/agents/dtos/create-agent-request.dto.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
2+
import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared';
3+
import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
34

45
export class CreateAgentRequestDto {
56
@ApiProperty()
@@ -10,6 +11,9 @@ export class CreateAgentRequestDto {
1011
@ApiProperty()
1112
@IsString()
1213
@IsNotEmpty()
14+
@Matches(SLUG_IDENTIFIER_REGEX, {
15+
message: slugIdentifierFormatMessage('identifier'),
16+
})
1317
identifier: string;
1418

1519
@ApiPropertyOptional()

apps/api/src/app/agents/dtos/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './add-agent-integration-request.dto';
2+
export * from './agent-integration-summary.dto';
23
export * from './agent-integration-response.dto';
34
export * from './agent-response.dto';
45
export * from './create-agent-request.dto';

apps/api/src/app/agents/e2e/agents.e2e.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ describe('Agents API - /agents #novu-v2', () => {
6161
expect(afterDelete.status).to.equal(404);
6262
});
6363

64+
it('should return 422 when identifier is not a valid slug', async () => {
65+
const res = await session.testAgent.post('/v1/agents').send({
66+
name: 'Invalid Slug Agent',
67+
identifier: 'bad id with spaces',
68+
});
69+
70+
expect(res.status).to.equal(422);
71+
const messages = res.body?.errors?.general?.messages;
72+
const text = Array.isArray(messages) ? messages.join(' ') : String(messages ?? '');
73+
74+
expect(text.toLowerCase()).to.contain('identifier');
75+
expect(text.toLowerCase()).to.match(/slug|valid/);
76+
});
77+
6478
it('should return 404 when agent identifier does not exist', async () => {
6579
const res = await session.testAgent.get('/v1/agents/nonexistent-agent-id-xyz');
6680

@@ -153,9 +167,7 @@ describe('Agents API - /agents #novu-v2', () => {
153167

154168
expect(removeRes.status).to.equal(204);
155169

156-
const listAfterRemove = await session.testAgent.get(
157-
`/v1/agents/${encodeURIComponent(identifier)}/integrations`
158-
);
170+
const listAfterRemove = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}/integrations`);
159171

160172
expect(listAfterRemove.body.data.length).to.equal(0);
161173

apps/api/src/app/agents/mappers/agent-response.mapper.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { AgentEntity, AgentIntegrationEntity } from '@novu/dal';
1+
import type { AgentEntity, AgentIntegrationEntity, IntegrationEntity } from '@novu/dal';
22

3-
import type { AgentIntegrationResponseDto, AgentResponseDto } from '../dtos';
3+
import type { AgentIntegrationResponseDto, AgentIntegrationSummaryDto, AgentResponseDto } from '../dtos';
44

55
export function toAgentResponse(agent: AgentEntity): AgentResponseDto {
66
return {
@@ -15,6 +15,19 @@ export function toAgentResponse(agent: AgentEntity): AgentResponseDto {
1515
};
1616
}
1717

18+
export function toAgentIntegrationSummary(
19+
integration: Pick<IntegrationEntity, '_id' | 'identifier' | 'name' | 'providerId' | 'channel' | 'active'>
20+
): AgentIntegrationSummaryDto {
21+
return {
22+
integrationId: integration._id,
23+
providerId: integration.providerId,
24+
name: integration.name,
25+
identifier: integration.identifier,
26+
channel: integration.channel,
27+
active: integration.active,
28+
};
29+
}
30+
1831
export function toAgentIntegrationResponse(
1932
link: AgentIntegrationEntity,
2033
integrationIdentifier: string

apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { BadRequestException, Injectable } from '@nestjs/common';
22
import { InstrumentUsecase } from '@novu/application-generic';
3-
import { AgentRepository } from '@novu/dal';
3+
import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal';
44
import { DirectionEnum } from '@novu/shared';
5+
import type { AgentIntegrationSummaryDto } from '../../dtos/agent-integration-summary.dto';
56
import { ListAgentsResponseDto } from '../../dtos/list-agents-response.dto';
6-
import { toAgentResponse } from '../../mappers/agent-response.mapper';
7+
import { toAgentIntegrationSummary, toAgentResponse } from '../../mappers/agent-response.mapper';
78
import { ListAgentsCommand } from './list-agents.command';
89

910
@Injectable()
1011
export class ListAgents {
11-
constructor(private readonly agentRepository: AgentRepository) {}
12+
constructor(
13+
private readonly agentRepository: AgentRepository,
14+
private readonly agentIntegrationRepository: AgentIntegrationRepository,
15+
private readonly integrationRepository: IntegrationRepository
16+
) {}
1217

1318
@InstrumentUsecase()
1419
async execute(command: ListAgentsCommand): Promise<ListAgentsResponseDto> {
@@ -28,12 +33,101 @@ export class ListAgents {
2833
identifier: command.identifier,
2934
});
3035

36+
const integrationsByAgentId = await this.loadIntegrationsForAgents(
37+
command.environmentId,
38+
command.organizationId,
39+
pagination.agents
40+
);
41+
3142
return {
32-
data: pagination.agents.map((agent) => toAgentResponse(agent)),
43+
data: pagination.agents.map((agent) => ({
44+
...toAgentResponse(agent),
45+
integrations: integrationsByAgentId.get(agent._id) ?? [],
46+
})),
3347
next: pagination.next,
3448
previous: pagination.previous,
3549
totalCount: pagination.totalCount,
3650
totalCountCapped: pagination.totalCountCapped,
3751
};
3852
}
53+
54+
private async loadIntegrationsForAgents(
55+
environmentId: string,
56+
organizationId: string,
57+
agents: { _id: string }[]
58+
): Promise<Map<string, AgentIntegrationSummaryDto[]>> {
59+
const result = new Map<string, AgentIntegrationSummaryDto[]>();
60+
61+
if (agents.length === 0) {
62+
return result;
63+
}
64+
65+
const agentIds = agents.map((a) => a._id);
66+
const links = await this.agentIntegrationRepository.findLinksForAgents({
67+
environmentId,
68+
organizationId,
69+
agentIds,
70+
});
71+
72+
const integrationIds = [...new Set(links.map((l) => l._integrationId))];
73+
74+
if (integrationIds.length === 0) {
75+
for (const id of agentIds) {
76+
result.set(id, []);
77+
}
78+
79+
return result;
80+
}
81+
82+
const integrations = await this.integrationRepository.find(
83+
{
84+
_id: { $in: integrationIds },
85+
_environmentId: environmentId,
86+
_organizationId: organizationId,
87+
},
88+
'_id identifier name providerId channel active'
89+
);
90+
91+
const summaryByIntegrationId = new Map(integrations.map((i) => [i._id, toAgentIntegrationSummary(i)] as const));
92+
93+
const seen = new Map<string, Set<string>>();
94+
95+
for (const link of links) {
96+
const summary = summaryByIntegrationId.get(link._integrationId);
97+
98+
if (!summary) {
99+
continue;
100+
}
101+
102+
let dedupe = seen.get(link._agentId);
103+
104+
if (!dedupe) {
105+
dedupe = new Set<string>();
106+
seen.set(link._agentId, dedupe);
107+
}
108+
109+
if (dedupe.has(summary.integrationId)) {
110+
continue;
111+
}
112+
113+
dedupe.add(summary.integrationId);
114+
const list = result.get(link._agentId) ?? [];
115+
list.push(summary);
116+
117+
result.set(link._agentId, list);
118+
}
119+
120+
for (const id of agentIds) {
121+
if (!result.has(id)) {
122+
result.set(id, []);
123+
} else {
124+
const list = result.get(id) ?? [];
125+
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
126+
127+
result.set(id, sorted);
128+
}
129+
}
130+
131+
return result;
132+
}
39133
}

apps/api/src/app/workflows-v2/dtos/create-step.dto.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
SmsControlDto,
1212
ThrottleControlDto,
1313
} from '@novu/application-generic';
14-
import { StepTypeEnum } from '@novu/shared';
14+
import { SLUG_IDENTIFIER_REGEX, StepTypeEnum, slugIdentifierFormatMessage } from '@novu/shared';
1515
import { IsEnum, IsObject, IsOptional, IsString, Matches } from 'class-validator';
1616

1717
// Base DTO for common properties
@@ -27,8 +27,8 @@ export class BaseStepConfigDto {
2727

2828
@ApiPropertyOptional({ description: 'Unique identifier for the step' })
2929
@IsString()
30-
@Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, {
31-
message: 'stepId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)',
30+
@Matches(SLUG_IDENTIFIER_REGEX, {
31+
message: slugIdentifierFormatMessage('stepId'),
3232
})
3333
@IsOptional()
3434
stepId?: string;

apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {
1212
ThrottleControlDto,
1313
WorkflowCommonsFields,
1414
} from '@novu/application-generic';
15-
import { SeverityLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared';
15+
import {
16+
SeverityLevelEnum,
17+
SLUG_IDENTIFIER_REGEX,
18+
StepTypeEnum,
19+
slugIdentifierFormatMessage,
20+
WorkflowCreationSourceEnum,
21+
} from '@novu/shared';
1622
import { Type } from 'class-transformer';
1723
import { IsArray, IsEnum, IsOptional, IsString, Matches, ValidateNested } from 'class-validator';
1824
import {
@@ -67,8 +73,8 @@ export type StepCreateDto =
6773
export class CreateWorkflowDto extends WorkflowCommonsFields {
6874
@ApiProperty({ description: 'Unique identifier for the workflow' })
6975
@IsString()
70-
@Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, {
71-
message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)',
76+
@Matches(SLUG_IDENTIFIER_REGEX, {
77+
message: slugIdentifierFormatMessage('workflowId'),
7278
})
7379
workflowId: string;
7480

apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared';
23
import { IsArray, IsBoolean, IsOptional, IsString, Matches } from 'class-validator';
34

45
export class DuplicateWorkflowDto {
@@ -16,8 +17,8 @@ export class DuplicateWorkflowDto {
1617
})
1718
@IsOptional()
1819
@IsString()
19-
@Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, {
20-
message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)',
20+
@Matches(SLUG_IDENTIFIER_REGEX, {
21+
message: slugIdentifierFormatMessage('workflowId'),
2122
})
2223
workflowId?: string;
2324

0 commit comments

Comments
 (0)