diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts index 3cb76dac154..e489e15ec85 100644 --- a/apps/api/src/app/agents/agents.controller.ts +++ b/apps/api/src/app/agents/agents.controller.ts @@ -52,10 +52,10 @@ 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'; +import { UpdateAgentIntegrationCommand } from './usecases/update-agent-integration/update-agent-integration.command'; +import { UpdateAgentIntegration } from './usecases/update-agent-integration/update-agent-integration.usecase'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @@ -84,10 +84,7 @@ export class AgentsController { 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 { + createAgent(@UserSession() user: UserSessionData, @Body() body: CreateAgentRequestDto): Promise { return this.createAgentUsecase.execute( CreateAgentCommand.create({ userId: user._id, @@ -96,6 +93,7 @@ export class AgentsController { name: body.name, identifier: body.identifier, description: body.description, + active: body.active, }) ); } @@ -108,10 +106,7 @@ export class AgentsController { '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 { + listAgents(@UserSession() user: UserSessionData, @Query() query: ListAgentsQueryDto): Promise { return this.listAgentsUsecase.execute( ListAgentsCommand.create({ user, @@ -132,7 +127,8 @@ export class AgentsController { @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).', + 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.', @@ -287,6 +283,7 @@ export class AgentsController { identifier, name: body.name, description: body.description, + active: body.active, behavior: body.behavior, }) ); 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 index 7671f8b685a..e3aa4c0eae2 100644 --- a/apps/api/src/app/agents/dtos/agent-integration-response.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-integration-response.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; /** Picked integration fields embedded on an agent–integration link response. */ @@ -40,6 +40,9 @@ export class AgentIntegrationResponseDto { @ApiProperty() _organizationId: string; + @ApiPropertyOptional({ description: 'Set when the agent–integration link has been used (e.g. first credential resolution).' }) + connectedAt?: string | null; + @ApiProperty() createdAt: 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 index adef54ac260..6f9d9eb3665 100644 --- a/apps/api/src/app/agents/dtos/agent-response.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-response.dto.ts @@ -19,6 +19,9 @@ export class AgentResponseDto { @ApiPropertyOptional({ type: AgentBehaviorDto }) behavior?: AgentBehaviorDto; + @ApiProperty() + active: boolean; + @ApiProperty() _environmentId: 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 index f07f376b507..f65c14f60e2 100644 --- a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts +++ b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; -import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAgentRequestDto { @ApiProperty() @@ -20,4 +20,9 @@ export class CreateAgentRequestDto { @IsString() @IsOptional() description?: string; + + @ApiPropertyOptional({ default: true }) + @IsBoolean() + @IsOptional() + active?: boolean; } 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 index 57fc37d6967..591fb1a11ae 100644 --- a/apps/api/src/app/agents/dtos/update-agent-request.dto.ts +++ b/apps/api/src/app/agents/dtos/update-agent-request.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; import { AgentBehaviorDto } from './agent-behavior.dto'; @@ -15,6 +15,11 @@ export class UpdateAgentRequestDto { @IsOptional() description?: string; + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + active?: boolean; + @ApiPropertyOptional({ type: AgentBehaviorDto }) @ValidateNested() @Type(() => AgentBehaviorDto) diff --git a/apps/api/src/app/agents/mappers/agent-response.mapper.ts b/apps/api/src/app/agents/mappers/agent-response.mapper.ts index 1ab93bfff36..221339dc875 100644 --- a/apps/api/src/app/agents/mappers/agent-response.mapper.ts +++ b/apps/api/src/app/agents/mappers/agent-response.mapper.ts @@ -8,6 +8,7 @@ export function toAgentResponse(agent: AgentEntity): AgentResponseDto { name: agent.name, identifier: agent.identifier, description: agent.description, + active: agent.active, behavior: agent.behavior, _environmentId: agent._environmentId, _organizationId: agent._organizationId, @@ -33,7 +34,6 @@ export function toAgentIntegrationResponse( link: AgentIntegrationEntity, integration: Pick ): AgentIntegrationResponseDto { - return { _id: link._id, _agentId: link._agentId, @@ -47,6 +47,7 @@ export function toAgentIntegrationResponse( }, _environmentId: link._environmentId, _organizationId: link._organizationId, + connectedAt: link.connectedAt ?? null, createdAt: link.createdAt, updatedAt: link.updatedAt, }; diff --git a/apps/api/src/app/agents/services/agent-config-resolver.service.ts b/apps/api/src/app/agents/services/agent-config-resolver.service.ts index ed41b54eb88..9bd605ad9b4 100644 --- a/apps/api/src/app/agents/services/agent-config-resolver.service.ts +++ b/apps/api/src/app/agents/services/agent-config-resolver.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; -import { decryptCredentials, FeatureFlagsService } from '@novu/application-generic'; +import { decryptCredentials, FeatureFlagsService, PinoLogger } from '@novu/application-generic'; import { AgentIntegrationRepository, AgentRepository, @@ -46,7 +46,8 @@ export class AgentConfigResolver { private readonly agentRepository: AgentRepository, private readonly agentIntegrationRepository: AgentIntegrationRepository, private readonly integrationRepository: IntegrationRepository, - private readonly channelConnectionRepository: ChannelConnectionRepository + private readonly channelConnectionRepository: ChannelConnectionRepository, + private readonly logger: PinoLogger ) {} async resolve(agentId: string, integrationIdentifier: string): Promise { @@ -108,6 +109,17 @@ export class AgentConfigResolver { connectionAccessToken = connection.auth.accessToken; } + if (agentIntegration.connectedAt == null) { + await this.agentIntegrationRepository.updateOne( + { + _id: agentIntegration._id, + _environmentId: environmentId, + _organizationId: organizationId, + }, + { $set: { connectedAt: new Date() } } + ); + } + return { platform, credentials, diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts index 8868caa39d3..f23b72a21d2 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts @@ -1,12 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, ConversationRepository, SubscriberRepository } from '@novu/dal'; import type { Message, Thread } from 'chat'; import { AgentEventEnum } from '../dtos/agent-event.enum'; +import { HandleAgentReplyCommand } from '../usecases/handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-reply.usecase'; import { ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentConversationService } from './agent-conversation.service'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; -import { type BridgeAction, BridgeExecutorService } from './bridge-executor.service'; +import { type BridgeAction, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service'; + +const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu* + +Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`; @Injectable() export class AgentInboundHandler { @@ -16,7 +22,9 @@ export class AgentInboundHandler { private readonly conversationService: AgentConversationService, private readonly conversationRepository: ConversationRepository, private readonly bridgeExecutor: BridgeExecutorService, - private readonly subscriberRepository: SubscriberRepository + private readonly subscriberRepository: SubscriberRepository, + @Inject(forwardRef(() => HandleAgentReply)) + private readonly handleAgentReply: HandleAgentReply ) {} async handle( @@ -111,19 +119,39 @@ export class AgentInboundHandler { this.conversationService.getHistory(config.environmentId, conversation._id), ]); - await this.bridgeExecutor.execute({ - event, - config, - conversation, - subscriber, - history, - message, - platformContext: { - threadId: thread.id, - channelId: thread.channelId, - isDM: thread.isDM, - }, - }); + try { + await this.bridgeExecutor.execute({ + event, + config, + conversation, + subscriber, + history, + message, + platformContext: { + threadId: thread.id, + channelId: thread.channelId, + isDM: thread.isDM, + }, + }); + } catch (err) { + if (err instanceof NoBridgeUrlError) { + await this.handleAgentReply.execute( + HandleAgentReplyCommand.create({ + userId: 'system', + environmentId: config.environmentId, + organizationId: config.organizationId, + conversationId: conversation._id, + agentIdentifier: agentId, + integrationIdentifier: config.integrationIdentifier, + reply: { text: ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN }, + }) + ); + + return; + } + + throw err; + } } async handleAction( diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/services/bridge-executor.service.ts index 92408b5f2b2..f6adbd0545b 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -103,6 +103,13 @@ export interface AgentBridgeRequest { action: BridgeAction | null; } +export class NoBridgeUrlError extends Error { + constructor(agentIdentifier: string) { + super(`No bridge URL configured for agent ${agentIdentifier}`); + this.name = 'NoBridgeUrlError'; + } +} + @Injectable() export class BridgeExecutorService { constructor( @@ -119,7 +126,7 @@ export class BridgeExecutorService { const bridgeUrl = await this.resolveBridgeUrl(config.environmentId, config.organizationId, agentIdentifier, event); if (!bridgeUrl) { - return; + throw new NoBridgeUrlError(agentIdentifier); } const secretKey = await this.getDecryptedSecretKey.execute( @@ -133,6 +140,10 @@ export class BridgeExecutorService { this.logger.error(err, `[agent:${agentIdentifier}] Bridge delivery failed after ${MAX_RETRIES + 1} attempts`); }); } catch (err) { + if (err instanceof NoBridgeUrlError) { + throw err; + } + this.logger.error(err, `[agent:${agentIdentifier}] Bridge setup failed — skipping bridge call`); } } diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index 332a883dce9..96b1247dbeb 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable, OnModuleDestroy } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; import type { Chat, Message, Thread } from 'chat'; import { Request as ExpressRequest, Response as ExpressResponse } from 'express'; @@ -42,6 +42,7 @@ export class ChatSdkService implements OnModuleDestroy { constructor( private readonly logger: PinoLogger, private readonly agentConfigResolver: AgentConfigResolver, + @Inject(forwardRef(() => AgentInboundHandler)) private readonly inboundHandler: AgentInboundHandler ) { this.instances = new LRUCache({ 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 index 2f7eb88a93b..dd653cba7fe 100644 --- 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 @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; @@ -14,4 +14,8 @@ export class CreateAgentCommand extends EnvironmentWithUserCommand { @IsString() @IsOptional() description?: string; + + @IsBoolean() + @IsOptional() + active?: boolean; } 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 index aa58f553cdc..bd0e03908f1 100644 --- 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 @@ -27,6 +27,7 @@ export class CreateAgent { name: command.name, identifier: command.identifier, description: command.description, + active: command.active ?? true, _environmentId: command.environmentId, _organizationId: command.organizationId, }); diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts index a34351eff81..fe7c51d3547 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { PinoLogger, shortId } from '@novu/application-generic'; import { ConversationActivityRepository, @@ -23,6 +23,7 @@ export class HandleAgentReply { private readonly conversationRepository: ConversationRepository, private readonly activityRepository: ConversationActivityRepository, private readonly subscriberRepository: SubscriberRepository, + @Inject(forwardRef(() => ChatSdkService)) private readonly chatSdkService: ChatSdkService, private readonly bridgeExecutor: BridgeExecutorService, private readonly agentConfigResolver: AgentConfigResolver, 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 index 02bde97cd7d..255007dbc40 100644 --- 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 @@ -1,5 +1,5 @@ -import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { AgentBehaviorDto } from '../../dtos/agent-behavior.dto'; @@ -17,6 +17,10 @@ export class UpdateAgentCommand extends EnvironmentWithUserCommand { @IsOptional() description?: string; + @ValidateIf((_, value) => value !== undefined) + @IsBoolean() + active?: boolean; + @ValidateNested() @Type(() => AgentBehaviorDto) @IsOptional() 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 index f1dc84eb896..075675c52f5 100644 --- 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 @@ -1,8 +1,7 @@ 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 { toAgentResponse } from '../../mappers/agent-response.mapper'; import { UpdateAgentCommand } from './update-agent.command'; @Injectable() @@ -10,8 +9,15 @@ export class UpdateAgent { constructor(private readonly agentRepository: AgentRepository) {} async execute(command: UpdateAgentCommand): Promise { - if (command.name === undefined && command.description === undefined && command.behavior === undefined) { - throw new BadRequestException('At least one of name, description, or behavior must be provided.'); + if ( + command.name === undefined && + command.description === undefined && + command.behavior === undefined && + command.active === undefined + ) { + throw new BadRequestException( + 'At least one of name, description, behavior, or active must be provided.' + ); } const existing = await this.agentRepository.findOne( @@ -27,7 +33,7 @@ export class UpdateAgent { throw new NotFoundException(`Agent with identifier "${command.identifier}" was not found.`); } - const $set: Record = {}; + const $set: Record = {}; if (command.name !== undefined) { $set.name = command.name; @@ -37,6 +43,10 @@ export class UpdateAgent { $set.description = command.description; } + if (command.active !== undefined) { + $set.active = command.active; + } + if (command.behavior !== undefined) { if (command.behavior.thinkingIndicatorEnabled !== undefined) { $set['behavior.thinkingIndicatorEnabled'] = command.behavior.thinkingIndicatorEnabled; diff --git a/apps/dashboard/public/images/providers/light/square/google-chat.svg b/apps/dashboard/public/images/providers/light/square/google-chat.svg new file mode 100644 index 00000000000..119cfad1dc6 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/google-chat.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/dashboard/public/images/providers/light/square/imessages.svg b/apps/dashboard/public/images/providers/light/square/imessages.svg new file mode 100644 index 00000000000..b0969581135 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/imessages.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dashboard/public/images/providers/light/square/linear.svg b/apps/dashboard/public/images/providers/light/square/linear.svg new file mode 100644 index 00000000000..5028d7dd9d5 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/linear.svg @@ -0,0 +1 @@ + diff --git a/apps/dashboard/public/images/providers/light/square/telegram.svg b/apps/dashboard/public/images/providers/light/square/telegram.svg new file mode 100644 index 00000000000..5bbae032319 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/telegram.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/dashboard/public/images/providers/light/square/zoom.svg b/apps/dashboard/public/images/providers/light/square/zoom.svg new file mode 100644 index 00000000000..ea8e5d50ea1 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/zoom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/dashboard/src/api/agents.ts b/apps/dashboard/src/api/agents.ts index b04de3564ee..8c5ccc9a0c6 100644 --- a/apps/dashboard/src/api/agents.ts +++ b/apps/dashboard/src/api/agents.ts @@ -1,5 +1,5 @@ import type { ChannelTypeEnum, DirectionEnum, IEnvironment } from '@novu/shared'; -import { del, get, post } from '@/api/api.client'; +import { del, get, patch, post } from '@/api/api.client'; /** Root segment for TanStack Query keys; use with {@link getAgentsListQueryKey}. */ export const AGENTS_LIST_QUERY_KEY = 'fetchAgents' as const; @@ -39,6 +39,7 @@ export type AgentResponse = { name: string; identifier: string; description?: string; + active: boolean; _environmentId: string; _organizationId: string; createdAt: string; @@ -58,6 +59,13 @@ export type CreateAgentBody = { name: string; identifier: string; description?: string; + active?: boolean; +}; + +export type UpdateAgentBody = { + name?: string; + description?: string; + active?: boolean; }; export type ListAgentsParams = { @@ -133,6 +141,16 @@ export async function createAgent(environment: IEnvironment, body: CreateAgentBo return response.data; } +export async function updateAgent( + environment: IEnvironment, + identifier: string, + body: UpdateAgentBody +): Promise { + const response = await patch(`/agents/${encodeURIComponent(identifier)}`, { environment, body }); + + return response.data; +} + export function deleteAgent(environment: IEnvironment, identifier: string): Promise { return del(`/agents/${encodeURIComponent(identifier)}`, { environment }); } @@ -154,6 +172,7 @@ export type AgentIntegrationLink = { integration: AgentIntegrationEmbedded; _environmentId: string; _organizationId: string; + connectedAt?: string | null; createdAt: string; updatedAt: string; }; diff --git a/apps/dashboard/src/api/integrations.ts b/apps/dashboard/src/api/integrations.ts index 2b6f7bdc1d4..cc7676cb02c 100644 --- a/apps/dashboard/src/api/integrations.ts +++ b/apps/dashboard/src/api/integrations.ts @@ -7,7 +7,7 @@ export type CreateIntegrationData = { credentials: Record; configurations: Record; name: string; - identifier: string; + identifier?: string; active: boolean; primary?: boolean; _environmentId: string; diff --git a/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx b/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx index 22b58a56ed1..68504e2ce3f 100644 --- a/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx +++ b/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx @@ -32,10 +32,10 @@ function formatCreatedDate(isoDate: string): string { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } -function buildWebhookUrl(agentId: string): string { - const baseUrl = API_HOSTNAME ?? 'https://api.novu.co'; +function buildWebhookUrl(agentId: string, integrationIdentifier: string): string { + const baseUrl = (API_HOSTNAME ?? 'https://api.novu.co').replace(/\/$/, ''); - return `${baseUrl}/v1/agents/${agentId}`; + return `${baseUrl}/v1/agents/${agentId}/webhook/${integrationIdentifier}`; } export function AgentIntegrationGuideLayout({ @@ -54,7 +54,7 @@ export function AgentIntegrationGuideLayout({ const isActive = integrationLink?.integration.active ?? false; const integrationIdentifier = integrationLink?.integration.identifier; const createdAt = integrationLink?.createdAt; - const webhookUrl = buildWebhookUrl(agent._id); + const webhookUrl = buildWebhookUrl(agent._id, integrationIdentifier ?? 'YOUR_INTEGRATION_IDENTIFIER'); return (
diff --git a/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx b/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx index cfd7552b1fe..2612ff67792 100644 --- a/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx +++ b/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx @@ -1,31 +1,35 @@ import { ChatProviderIdEnum } from '@novu/shared'; import type { AgentIntegrationLink, AgentResponse } from '@/api/agents'; +import { SlackSetupGuide } from '@/components/agents/slack-setup-guide'; import { GenericAgentIntegrationGuide } from './generic-agent-integration-guide'; import { SlackAgentIntegrationGuide } from './slack-agent-integration-guide'; type ResolveAgentIntegrationGuideProps = { - providerId: string; + integrationLink: AgentIntegrationLink; onBack: () => void; embedded?: boolean; agent: AgentResponse; - integrationLink?: AgentIntegrationLink; canRemoveIntegration: boolean; onRequestRemoveIntegration?: () => void; isRemovingIntegration?: boolean; }; export function ResolveAgentIntegrationGuide({ - providerId, + integrationLink, onBack, embedded = false, agent, - integrationLink, canRemoveIntegration, onRequestRemoveIntegration, isRemovingIntegration, }: ResolveAgentIntegrationGuideProps) { - if (providerId === ChatProviderIdEnum.Slack) { + const providerId = integrationLink.integration.providerId; + if (providerId === ChatProviderIdEnum.Slack && !integrationLink.connectedAt) { + return ; + } + + if (providerId === ChatProviderIdEnum.Slack) { return ( + + + +
+ ); + + if (integrationIdentifier) { + if (isLoading) { + + return guideSkeleton; + } + + if (!selectedIntegration) { + + return ( + + ); + } return ( - - - - - ); + return guideSkeleton; } if (links.length > 0) { @@ -203,13 +214,12 @@ function IntegrationsMainPanel({ ); } -export function AgentIntegrationsTab({ agent, providerId }: AgentIntegrationsTabProps) { +export function AgentIntegrationsTab({ agent, integrationIdentifier }: AgentIntegrationsTabProps) { const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); const { currentEnvironment } = useEnvironment(); const has = useHasPermission(); - const [addSheetOpen, setAddSheetOpen] = useState(false); const canRemoveAgentIntegration = has({ permission: PermissionsEnum.AGENT_WRITE }); @@ -227,22 +237,22 @@ export function AgentIntegrationsTab({ agent, providerId }: AgentIntegrationsTab agentTab: 'activity', })}${location.search}`; - const navigateToGuide = (nextProviderId: string) => { + const navigateToGuide = (nextIntegrationIdentifier: string) => { if (!currentEnvironment?.slug) { return; } navigate( - `${buildRoute(ROUTES.AGENT_DETAILS_INTEGRATIONS_PROVIDER, { + `${buildRoute(ROUTES.AGENT_DETAILS_INTEGRATIONS_DETAIL, { environmentSlug: currentEnvironment.slug, agentIdentifier: encodeURIComponent(agent.identifier), - providerId: encodeURIComponent(nextProviderId), + integrationIdentifier: encodeURIComponent(nextIntegrationIdentifier), })}${location.search}` ); }; const handleBackFromGuide = () => { - navigate(integrationsHubPath); + navigate(integrationsHubPath, { state: { skipIntegrationsRedirect: true } }); }; const listQuery = useQuery({ @@ -256,30 +266,64 @@ export function AgentIntegrationsTab({ agent, providerId }: AgentIntegrationsTab enabled: Boolean(currentEnvironment && agent.identifier), }); - const linkedIntegrationIds = listQuery.data?.data.map((row) => row.integration._id) ?? []; + const linkedRows = listQuery.data?.data; - const addMutation = useMutation({ - mutationFn: (integrationIdentifier: string) => - addAgentIntegration(requireEnvironment(currentEnvironment, 'No environment selected'), agent.identifier, { - integrationIdentifier, - }), - onSuccess: async (data) => { - showSuccessToast('Integration linked', `${data.integration.name} was added to this agent.`); - await queryClient.invalidateQueries({ - queryKey: getAgentIntegrationsQueryKey(currentEnvironment?._id, agent.identifier), - }); - await queryClient.invalidateQueries({ - queryKey: getAgentDetailQueryKey(currentEnvironment?._id, agent.identifier), - }); - setAddSheetOpen(false); - navigateToGuide(data.integration.providerId); - }, - onError: (err: Error) => { - const message = err instanceof NovuApiError ? err.message : 'Could not link integration.'; + useEffect(() => { + if (integrationIdentifier != null) { + return; + } - showErrorToast(message, 'Link failed'); - }, - }); + if (!currentEnvironment?.slug) { + return; + } + + const skipRedirect = Boolean( + (location.state as { skipIntegrationsRedirect?: boolean } | null)?.skipIntegrationsRedirect + ); + + if (skipRedirect) { + return; + } + + if (!listQuery.isSuccess || !linkedRows?.length) { + return; + } + + const firstIntegrationIdentifier = getFirstLinkedIntegrationIdentifier(linkedRows); + + if (!firstIntegrationIdentifier) { + return; + } + + navigate( + `${buildRoute(ROUTES.AGENT_DETAILS_INTEGRATIONS_DETAIL, { + environmentSlug: currentEnvironment.slug, + agentIdentifier: encodeURIComponent(agent.identifier), + integrationIdentifier: encodeURIComponent(firstIntegrationIdentifier), + })}${location.search}`, + { replace: true } + ); + }, [ + agent.identifier, + currentEnvironment?.slug, + linkedRows, + listQuery.isSuccess, + location.search, + location.state, + navigate, + integrationIdentifier, + ]); + + const linkedIntegrationIdSet = useMemo( + () => new Set(linkedRows?.map((row) => row.integration._id) ?? []), + [linkedRows] + ); + + const handleProviderDropdownSelect = (_providerId: string, integration?: IIntegration) => { + if (integration?.identifier) { + navigateToGuide(integration.identifier); + } + }; const removeIntegrationMutation = useMutation({ mutationFn: (agentIntegrationId: string) => @@ -309,23 +353,17 @@ export function AgentIntegrationsTab({ agent, providerId }: AgentIntegrationsTab }, }); - const handlePickIntegration = (item: TableIntegration) => { - if (addMutation.isPending) { - return; - } - - addMutation.mutate(item.identifier); - }; - const handleLinkedRowClick = (link: AgentIntegrationLink) => { - navigateToGuide(link.integration.providerId); + navigateToGuide(link.integration.identifier); }; const isLoading = listQuery.isLoading; - const links = listQuery.data?.data ?? []; + const links = linkedRows ?? []; const grouped = groupLinksByChannel(links); const selectedIntegration = - providerId != null ? links.find((link) => link.integration.providerId === providerId) : undefined; + integrationIdentifier != null + ? links.find((link) => link.integration.identifier === integrationIdentifier) + : undefined; const selectedIntegrationUpdatedAtMs = selectedIntegration != null ? Date.parse(selectedIntegration.updatedAt) : undefined; const lastUpdatedParts = listQuery.isSuccess @@ -334,7 +372,9 @@ export function AgentIntegrationsTab({ agent, providerId }: AgentIntegrationsTab if (listQuery.isError) { return ( -
Could not load integrations for this agent. Try again later.
+
+

Could not load integrations for this agent. Try again later.

+
); } @@ -348,7 +388,7 @@ export function AgentIntegrationsTab({ agent, providerId }: AgentIntegrationsTab const mainPanel = ( - -
{mainPanel}
diff --git a/apps/dashboard/src/components/agents/agent-overview-tab.tsx b/apps/dashboard/src/components/agents/agent-overview-tab.tsx new file mode 100644 index 00000000000..626bf893105 --- /dev/null +++ b/apps/dashboard/src/components/agents/agent-overview-tab.tsx @@ -0,0 +1,16 @@ +import type { AgentResponse } from '@/api/agents'; +import { AgentSetupGuide } from '@/components/agents/agent-setup-guide'; +import { AgentSidebarWidget } from '@/components/agents/agent-sidebar-widget'; + +type AgentOverviewTabProps = { + agent: AgentResponse; +}; + +export function AgentOverviewTab({ agent }: AgentOverviewTabProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/agents/agent-setup-guide.tsx b/apps/dashboard/src/components/agents/agent-setup-guide.tsx new file mode 100644 index 00000000000..f4435c9b068 --- /dev/null +++ b/apps/dashboard/src/components/agents/agent-setup-guide.tsx @@ -0,0 +1,102 @@ +import { ChatProviderIdEnum } from '@novu/shared'; +import { useMemo, useState } from 'react'; +import { RiExpandUpDownLine } from 'react-icons/ri'; +import { type AgentResponse } from '@/api/agents'; +import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { cn } from '@/utils/ui'; +import { ProviderDropdown } from './provider-dropdown'; +import { SetupStep } from './setup-guide-primitives'; +import { deriveStepStatus } from './setup-guide-step-utils'; +import { SlackSetupGuide } from './slack-setup-guide'; + +type AgentSetupGuideProps = { + agent: AgentResponse; +}; + +function resolveProviderSetupGuide(providerId: string) { + switch (providerId) { + case ChatProviderIdEnum.Slack: + return SlackSetupGuide; + default: + return null; + } +} + +export function AgentSetupGuide({ agent }: AgentSetupGuideProps) { + const [isExpanded, setIsExpanded] = useState(true); + const [selectedIntegrationId, setSelectedIntegrationId] = useState(undefined); + const { integrations } = useFetchIntegrations(); + + const slackFromAgent = agent.integrations?.find((i) => i.providerId === ChatProviderIdEnum.Slack); + + const effectiveIntegrationId = selectedIntegrationId ?? slackFromAgent?.integrationId; + + const selectedProviderId = useMemo(() => { + if (selectedIntegrationId) { + return integrations?.find((i) => i._id === selectedIntegrationId)?.providerId; + } + + return slackFromAgent?.providerId; + }, [integrations, selectedIntegrationId, slackFromAgent?.providerId]); + + const hasProviderSelected = Boolean(effectiveIntegrationId); + + const linkedIntegrationIds = useMemo( + () => new Set(agent.integrations?.map((i) => i.integrationId) ?? []), + [agent.integrations] + ); + + const firstIncompleteStepForProviderRow = hasProviderSelected ? 2 : 1; + + const ProviderGuide = selectedProviderId ? resolveProviderSetupGuide(selectedProviderId) : null; + + return ( +
+ + + {isExpanded && ( +
+
+
+ + { + if (integration?._id) { + setSelectedIntegrationId(integration._id); + } + }} + /> + } + /> + + {ProviderGuide && effectiveIntegrationId ? ( + + ) : null} +
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx b/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx new file mode 100644 index 00000000000..be036c8eefb --- /dev/null +++ b/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx @@ -0,0 +1,340 @@ +import { MAX_DESCRIPTION_LENGTH, PermissionsEnum } from '@novu/shared'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; +import { AnimatePresence, motion } from 'motion/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { RiExpandUpDownLine } from 'react-icons/ri'; +import type { AgentResponse, UpdateAgentBody } from '@/api/agents'; +import { getAgentDetailQueryKey, updateAgent } from '@/api/agents'; +import { NovuApiError } from '@/api/api.client'; +import { AnimatedBadgeDot, Badge } from '@/components/primitives/badge'; +import { Input } from '@/components/primitives/input'; +import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { Switch } from '@/components/primitives/switch'; +import { Textarea } from '@/components/primitives/textarea'; +import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; +import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; +import { useHasPermission } from '@/hooks/use-has-permission'; +import { cn } from '@/utils/ui'; + +type AgentSidebarWidgetProps = { + agent: AgentResponse; +}; + +const AGENT_NAME_MAX_LENGTH = 64; + +const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', +}; + +function formatLongDate(dateStr: string): string { + const formatted = new Date(dateStr).toLocaleDateString('en-US', DATE_FORMAT_OPTIONS); + + return formatted; +} + +function SidebarRow({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) { + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + const has = useHasPermission(); + const canWrite = has({ permission: PermissionsEnum.AGENT_WRITE }); + + const [isEditingName, setIsEditingName] = useState(false); + const [name, setName] = useState(agent.name); + const nameBeforeEditRef = useRef(agent.name); + + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [description, setDescription] = useState(agent.description ?? ''); + const descriptionContainerRef = useRef(null); + + const { isPending: isUpdatePending, mutateAsync: updateAgentAsync } = useMutation({ + mutationFn: (body: UpdateAgentBody) => + updateAgent(requireEnvironment(currentEnvironment, 'No environment selected'), agent.identifier, body), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getAgentDetailQueryKey(currentEnvironment?._id, agent.identifier), + }); + + showSuccessToast('Your changes were saved.', 'Agent updated'); + }, + onError: (err: Error, variables: UpdateAgentBody) => { + const message = err instanceof NovuApiError ? err.message : 'Could not save changes.'; + + showErrorToast(message, 'Update failed'); + + if (variables.description !== undefined) { + setDescription(agent.description ?? ''); + } + + if (variables.name !== undefined) { + setName(agent.name); + } + }, + }); + + const persistDescription = useCallback(async () => { + const trimmed = description.trim(); + const server = (agent.description ?? '').trim(); + + if (trimmed === server) { + return; + } + + if (!canWrite) { + return; + } + + await updateAgentAsync({ description: trimmed }); + }, [agent.description, canWrite, description, updateAgentAsync]); + + const persistName = useCallback(async () => { + const trimmed = name.trim(); + const server = agent.name.trim(); + + if (trimmed === server) { + return; + } + + if (!canWrite) { + return; + } + + await updateAgentAsync({ name: trimmed }); + }, [agent.name, canWrite, name, updateAgentAsync]); + + useEffect(() => { + if (isDescriptionExpanded) { + return; + } + + setDescription(agent.description ?? ''); + }, [agent.description, isDescriptionExpanded]); + + useEffect(() => { + if (isEditingName) { + return; + } + + setName(agent.name); + }, [agent.name, isEditingName]); + + const toggleDescriptionExpanded = useCallback(() => { + if (isDescriptionExpanded) { + void persistDescription().finally(() => { + setIsDescriptionExpanded(false); + }); + + return; + } + + setDescription(agent.description ?? ''); + setIsDescriptionExpanded(true); + }, [agent.description, isDescriptionExpanded, persistDescription]); + + useEffect(() => { + if (!isDescriptionExpanded) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + if (descriptionContainerRef.current?.contains(target)) { + return; + } + + void persistDescription().finally(() => { + setIsDescriptionExpanded(false); + }); + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isDescriptionExpanded, persistDescription]); + + return ( +
+
+ + {agent.active ? ( + + + Active + + ) : ( + + + Inactive + + )} + { + void updateAgentAsync({ active: checked }); + }} + /> + + +
+ Name +
+ + {isEditingName && canWrite ? ( + + setName(e.target.value)} + maxLength={AGENT_NAME_MAX_LENGTH} + className="w-full text-right whitespace-nowrap overflow-x-hidden mask-none" + size="xs" + autoFocus + disabled={isUpdatePending} + onBlur={() => { + if (!name.trim()) { + setName(nameBeforeEditRef.current); + setIsEditingName(false); + + return; + } + + setIsEditingName(false); + void persistName(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (name.trim()) { + e.currentTarget.blur(); + } else { + setName(nameBeforeEditRef.current); + setIsEditingName(false); + } + } + + if (e.key === 'Escape') { + setName(agent.name); + setIsEditingName(false); + } + }} + /> + + ) : ( + { + if (!canWrite) { + return; + } + + const current = name.trim(); + + nameBeforeEditRef.current = current ? name : agent.name; + setIsEditingName(true); + }} + disabled={!canWrite} + initial={{ opacity: 0, scale: 0.98 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.98 }} + transition={{ duration: 0.15, ease: 'easeOut' }} + whileHover={canWrite ? { x: 2 } : {}} + whileTap={canWrite ? { scale: 0.98 } : {}} + className={cn( + 'text-text-sub flex h-8 min-w-0 w-full items-center justify-end text-right text-label-xs font-medium transition-colors', + canWrite && 'hover:text-text-strong cursor-pointer', + !canWrite && 'cursor-default' + )} + > + {name || 'Untitled agent'} + + )} + +
+
+ + + {agent.identifier} + + + + + + {formatLongDate(agent.createdAt)} + + + + +
+ + {isDescriptionExpanded ? ( + +