diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts index b8e2e767fc1..462069b5773 100644 --- a/apps/api/src/app/agents/agents.controller.ts +++ b/apps/api/src/app/agents/agents.controller.ts @@ -15,8 +15,8 @@ import { UseInterceptors, } from '@nestjs/common'; import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; -import { RequirePermissions } from '@novu/application-generic'; -import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; +import { ProductFeature, RequirePermissions } from '@novu/application-generic'; +import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, ProductFeatureKeyEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards'; @@ -262,6 +262,7 @@ export class AgentsController { @Post('/:identifier/test-email') @HttpCode(HttpStatus.OK) + @ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION) @ApiOperation({ summary: 'Send a test email to the agent inbound address', description: 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 index a67356ebfc1..9b984da91b1 100644 --- 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 @@ -1,8 +1,8 @@ import { randomBytes } from 'node:crypto'; -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; import { encryptSecret } from '@novu/application-generic'; -import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; -import { EmailProviderIdEnum } from '@novu/shared'; +import { AgentIntegrationRepository, AgentRepository, CommunityOrganizationRepository, IntegrationRepository } from '@novu/dal'; +import { ApiServiceLevelEnum, EmailProviderIdEnum, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared'; import type { AgentIntegrationResponseDto } from '../../dtos'; import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; @@ -13,7 +13,8 @@ export class AddAgentIntegration { constructor( private readonly agentRepository: AgentRepository, private readonly integrationRepository: IntegrationRepository, - private readonly agentIntegrationRepository: AgentIntegrationRepository + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly organizationRepository: CommunityOrganizationRepository ) {} async execute(command: AddAgentIntegrationCommand): Promise { @@ -58,6 +59,7 @@ export class AddAgentIntegration { } if (integration.providerId === EmailProviderIdEnum.NovuAgent) { + await this.enforceEmailTier(command.organizationId); await this.prepareNovuEmailIntegration(agent._id, integration._id, command); } @@ -71,6 +73,16 @@ export class AddAgentIntegration { return toAgentIntegrationResponse(link, integration); } + private async enforceEmailTier(organizationId: string): Promise { + const organization = await this.organizationRepository.findById(organizationId); + const tier = organization?.apiServiceLevel ?? ApiServiceLevelEnum.FREE; + const allowed = getFeatureForTierAsBoolean(FeatureNameEnum.AGENT_EMAIL_INTEGRATION, tier); + + if (!allowed) { + throw new HttpException('Payment Required', HttpStatus.PAYMENT_REQUIRED); + } + } + /** * Enforces the singleton constraint (one NovuAgent email integration per * agent) and seeds the `secretKey` credential the email adapter needs for diff --git a/apps/dashboard/src/components/agents/provider-dropdown.tsx b/apps/dashboard/src/components/agents/provider-dropdown.tsx index 827dbfac09f..1af5b76bac7 100644 --- a/apps/dashboard/src/components/agents/provider-dropdown.tsx +++ b/apps/dashboard/src/components/agents/provider-dropdown.tsx @@ -8,7 +8,8 @@ import { } from '@novu/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; -import { RiAddLine, RiExpandUpDownLine, RiLoader4Line, RiSearchLine } from 'react-icons/ri'; +import { RiAddLine, RiExpandUpDownLine, RiExternalLinkLine, RiLoader4Line, RiLockStarLine, RiSearchLine } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; import { addAgentIntegration, getAgentDetailQueryKey, getAgentIntegrationsQueryKey } from '@/api/agents'; import { NovuApiError } from '@/api/api.client'; import { createIntegration } from '@/api/integrations'; @@ -23,15 +24,21 @@ import { } from '@/components/primitives/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; +import { IS_SELF_HOSTED, SELF_HOSTED_UPGRADE_REDIRECT_URL } from '@/config'; import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; import { useFetchIntegrations } from '@/hooks/use-fetch-integrations'; +import { useIsAgentEmailAvailable } from '@/hooks/use-is-agent-email-available'; import { QueryKeys } from '@/utils/query-keys'; +import { ROUTES } from '@/utils/routes'; +import { openInNewTab } from '@/utils/url'; import { cn } from '@/utils/ui'; type DropdownItem = { providerId: string; displayName: string; comingSoon: boolean; + requiresBusinessTier: boolean; integration?: IIntegration; }; @@ -72,6 +79,7 @@ function buildDropdownItems( providerId: cp.providerId, displayName: cp.displayName, comingSoon: true, + requiresBusinessTier: false, }); continue; } @@ -84,6 +92,7 @@ function buildDropdownItems( providerId: cp.providerId, displayName: integration.name || providerConfig?.displayName || cp.displayName, comingSoon: false, + requiresBusinessTier: cp.requiresBusinessTier ?? false, integration, }); } @@ -95,6 +104,7 @@ function buildDropdownItems( providerId: cp.providerId, displayName: providerConfig?.displayName || cp.displayName, comingSoon: false, + requiresBusinessTier: cp.requiresBusinessTier ?? false, }); } } @@ -132,6 +142,8 @@ export function ProviderDropdown({ const { integrations } = useFetchIntegrations(); const { currentEnvironment } = useEnvironment(); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const isAgentEmailAvailable = useIsAgentEmailAvailable(); const { supported: allSupported, comingSoon } = useMemo( () => buildDropdownItems(CONVERSATIONAL_PROVIDERS, integrations), @@ -225,6 +237,10 @@ export function ProviderDropdown({ return; } + if (item.requiresBusinessTier && !isAgentEmailAvailable) { + return; + } + const environment = currentEnvironment; if (!environment?._id) { @@ -353,41 +369,113 @@ export function ProviderDropdown({ {supported.map((item, index) => { const itemKey = getSupportedItemKey(item, index); const isRowPending = pendingItemKey === itemKey; + const isLocked = item.requiresBusinessTier && !isAgentEmailAvailable; + + const rowContent = ( +
+ + {item.displayName} + + {isRowPending && ( + + )} + {!isRowPending && isLocked && ( +
+ + + Team+ + +
+ )} + {!isRowPending && !isLocked && item.integration && item.providerId !== EmailProviderIdEnum.NovuAgent && ( + + {item.integration.identifier} + + )} + {!isRowPending && !isLocked && !item.integration && ( + + )} +
+ ); return ( { void handleSelect(item, index); }} className={cn( 'flex items-center gap-2 rounded-md p-1', - item.integration?._id === selectedIntegrationId && 'bg-bg-muted' + item.integration?._id === selectedIntegrationId && 'bg-bg-muted', + isLocked && '!pointer-events-auto opacity-60' )} > -
- - - {item.displayName} - -
- - {isRowPending && ( - - )} - {!isRowPending && item.integration && item.providerId !== EmailProviderIdEnum.NovuAgent && ( - - {item.integration.identifier} - - )} - {!isRowPending && !item.integration && ( - + {isLocked ? ( + + + {rowContent} + + +
+ + + Team feature + +
+
+

+ Agent email requires the Team plan. Upgrade to connect an inbound email address. +

+ +
+
+
+ ) : ( + rowContent )}
); diff --git a/apps/dashboard/src/hooks/use-is-agent-email-available.ts b/apps/dashboard/src/hooks/use-is-agent-email-available.ts new file mode 100644 index 00000000000..45cedf6d1eb --- /dev/null +++ b/apps/dashboard/src/hooks/use-is-agent-email-available.ts @@ -0,0 +1,11 @@ +import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared'; +import { useFetchSubscription } from '@/hooks/use-fetch-subscription'; + +export function useIsAgentEmailAvailable(): boolean { + const { subscription } = useFetchSubscription(); + + return getFeatureForTierAsBoolean( + FeatureNameEnum.AGENT_EMAIL_INTEGRATION, + subscription?.apiServiceLevel ?? ApiServiceLevelEnum.FREE + ); +} diff --git a/packages/shared/src/consts/feature-tiers-constants.ts b/packages/shared/src/consts/feature-tiers-constants.ts index 091130dd27b..e51ae078341 100644 --- a/packages/shared/src/consts/feature-tiers-constants.ts +++ b/packages/shared/src/consts/feature-tiers-constants.ts @@ -69,6 +69,9 @@ export enum FeatureNameEnum { // Domains Features DOMAINS_BOOLEAN = 'domainsBoolean', + + // Agent Features + AGENT_EMAIL_INTEGRATION = 'agentEmailIntegration', } export type FeatureValue = string | number | null | boolean | DetailedPriceListItem; @@ -491,6 +494,13 @@ const novuServiceTiers: Record { @@ -12,6 +13,7 @@ function createProductFeatureMap(): Record