/
githubmirror
/
novu
Обзор
Документация
Войти
/
githubmirror
/
novu
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
next
apps/api/src/app/integrations/integrations.controller.ts
1 203 строки
49 KB
Paweł Tymczuk
fix(api-service): support TelegramConnectButton without agent link fixes NV-8452 (#12144)
04 авг 2026, 19:01
Не верифицирован
04 авг 2026, 19:01
c64f6fc
Код
Авторство
О чём код?
import { BadRequestException, Body, ClassSerializerInterceptor, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, Post, Put, Query, Res, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CalculateLimitNovuIntegration, CalculateLimitNovuIntegrationCommand, FeatureFlagsService, GetActiveIntegrations, GetActiveIntegrationsCommand, GetDecryptedIntegrations, IntegrationResponseDto, OtelSpan, PinoLogger, RequirePermissions, } from '@novu/application-generic'; import { CommunityOrganizationRepository, IntegrationRepository } from '@novu/dal'; import { ApiAuthSchemeEnum, ApiServiceLevelEnum, ChannelTypeEnum, ChatProviderIdEnum, FeatureFlagsKeysEnum, FeatureNameEnum, getFeatureForTierAsBoolean, PermissionsEnum, UserSessionData, } from '@novu/shared'; import { Response } from 'express'; import { ConfigureTelegramWebhookResponseDto } from '../agents/shared/dtos/configure-telegram-webhook-response.dto'; import { IssueTelegramMobileLinkResponseDto } from '../agents/shared/dtos/issue-telegram-mobile-link-response.dto'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible, OAuthAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiNotFoundResponse, ApiOkResponse, ApiResponse, } from '../shared/framework/response.decorator'; import { KeylessAccessible } from '../shared/framework/swagger/keyless.security'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { CONNECTION_RESULT_CSP } from '../shared/html/connection-result-page'; import { assertEnvironmentScopedAccess, isEnvironmentScopedAuthScheme } from '../shared/utils/auth.utils'; import { ConfigureTelegramWebhookCommand } from '../telegram-linking/configure-telegram-webhook/configure-telegram-webhook.command'; import { ConfigureTelegramWebhook } from '../telegram-linking/configure-telegram-webhook/configure-telegram-webhook.usecase'; import { IssueTelegramMobileLinkCommand } from '../telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.command'; import { IssueTelegramMobileLink } from '../telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase'; import { IssueTelegramSubscriberLinkCommand } from '../telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command'; import { IssueTelegramSubscriberLink } from '../telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase'; import { AutoConfigureIntegrationResponseDto } from './dtos/auto-configure-integration-response.dto'; import { CreateIntegrationRequestDto } from './dtos/create-integration-request.dto'; import { GenerateChatOauthUrlRequestDto } from './dtos/generate-chat-oauth-url.dto'; import { GenerateChatOAuthUrlResponseDto } from './dtos/generate-chat-oauth-url-response.dto'; import { GenerateConnectOauthUrlRequestDto } from './dtos/generate-connect-oauth-url-request.dto'; import { GenerateLinkUserOauthUrlRequestDto } from './dtos/generate-link-user-oauth-url-request.dto'; import { ChannelTypeLimitDto } from './dtos/get-channel-type-limit.sto'; import { IssueIntegrationMobileLinkRequestDto } from './dtos/issue-integration-mobile-link-request.dto'; import { IssueIntegrationStoreTelegramMobileLinkResponseDto } from './dtos/issue-integration-store-telegram-mobile-link-response.dto'; import { LinkChannelEndpointRequestDto } from './dtos/link-channel-endpoint-request.dto'; import { LinkChannelEndpointResponseDto } from './dtos/link-channel-endpoint-response.dto'; import { SlackQuickSetupRequestDto, SlackQuickSetupResponseDto } from './dtos/slack-quick-setup.dto'; import { UpdateIntegrationRequestDto } from './dtos/update-integration.dto'; import { WhatsAppEmbeddedSignupRequestDto, WhatsAppEmbeddedSignupResponseDto, } from './dtos/whatsapp-embedded-signup.dto'; import { WhatsAppEmbeddedSignupAvailabilityResponseDto } from './dtos/whatsapp-embedded-signup-availability.dto'; import { IssueWhatsAppSignupLinkRequestDto, IssueWhatsAppSignupLinkResponseDto } from './dtos/whatsapp-signup-link.dto'; import { WhatsAppValidateTokenRequestDto, WhatsAppValidateTokenResponseDto } from './dtos/whatsapp-validate-token.dto'; import { AutoConfigureIntegrationCommand } from './usecases/auto-configure-integration/auto-configure-integration.command'; import { AutoConfigureIntegration } from './usecases/auto-configure-integration/auto-configure-integration.usecase'; import { AzureSetupOauthCallbackCommand } from './usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command'; import { AzureSetupOauthCallback } from './usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.usecase'; import { ChatOauthCallbackCommand } from './usecases/chat-oauth-callback/chat-oauth-callback.command'; import { ResponseTypeEnum } from './usecases/chat-oauth-callback/chat-oauth-callback.response'; import { ChatOauthCallback } from './usecases/chat-oauth-callback/chat-oauth-callback.usecase'; import { CreateIntegrationCommand } from './usecases/create-integration/create-integration.command'; import { CreateIntegration } from './usecases/create-integration/create-integration.usecase'; import { GenerateAzureSetupOauthUrlCommand } from './usecases/generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.command'; import { GenerateAzureSetupOauthUrl } from './usecases/generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.usecase'; import { GenerateChatOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-chat-oauth-url.command'; import { GenerateChatOauthUrl } from './usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase'; import { GenerateConnectOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-connect-oauth-url.command'; import { GenerateConnectOauthUrl } from './usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase'; import { GenerateLinkUserOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-link-user-oauth-url.command'; import { GenerateLinkUserOauthUrl } from './usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase'; import { GenerateMsTeamsArmTemplateCommand } from './usecases/generate-msteams-arm-template/generate-msteams-arm-template.command'; import { GenerateMsTeamsArmTemplate } from './usecases/generate-msteams-arm-template/generate-msteams-arm-template.usecase'; import { GetMsTeamsArmTemplate } from './usecases/generate-msteams-arm-template/get-msteams-arm-template.usecase'; import { GetInAppActivatedCommand } from './usecases/get-in-app-activated/get-in-app-activated.command'; import { GetInAppActivated } from './usecases/get-in-app-activated/get-in-app-activated.usecase'; import { GetIntegrationsCommand } from './usecases/get-integrations/get-integrations.command'; import { GetIntegrations } from './usecases/get-integrations/get-integrations.usecase'; import { GetWebhookSupportStatusCommand } from './usecases/get-webhook-support-status/get-webhook-support-status.command'; import { GetWebhookSupportStatus } from './usecases/get-webhook-support-status/get-webhook-support-status.usecase'; import { IssueIntegrationStoreTelegramMobileLinkCommand } from './usecases/issue-integration-store-telegram-mobile-link/issue-integration-store-telegram-mobile-link.command'; import { IssueIntegrationStoreTelegramMobileLink } from './usecases/issue-integration-store-telegram-mobile-link/issue-integration-store-telegram-mobile-link.usecase'; import { MsTeamsHealthCheckCommand } from './usecases/msteams-health-check/msteams-health-check.command'; import { MsTeamsHealthCheck, MsTeamsHealthCheckResult, } from './usecases/msteams-health-check/msteams-health-check.usecase'; import { RemoveIntegrationCommand } from './usecases/remove-integration/remove-integration.command'; import { RemoveIntegration } from './usecases/remove-integration/remove-integration.usecase'; import { SetIntegrationAsPrimaryCommand } from './usecases/set-integration-as-primary/set-integration-as-primary.command'; import { SetIntegrationAsPrimary } from './usecases/set-integration-as-primary/set-integration-as-primary.usecase'; import { SlackQuickSetupCommand } from './usecases/slack-quick-setup/slack-quick-setup.command'; import { SlackQuickSetup } from './usecases/slack-quick-setup/slack-quick-setup.usecase'; import { UpdateIntegrationCommand } from './usecases/update-integration/update-integration.command'; import { UpdateIntegration } from './usecases/update-integration/update-integration.usecase'; import { IssueWhatsAppSignupLinkCommand } from './usecases/whatsapp/issue-whatsapp-signup-link.command'; import { IssueWhatsAppSignupLink } from './usecases/whatsapp/issue-whatsapp-signup-link.usecase'; import { WhatsAppEmbeddedSignupCommand } from './usecases/whatsapp/whatsapp-embedded-signup.command'; import { WhatsAppEmbeddedSignup } from './usecases/whatsapp/whatsapp-embedded-signup.usecase'; import { WhatsAppEmbeddedSignupAvailabilityCommand } from './usecases/whatsapp/whatsapp-embedded-signup-availability.command'; import { WhatsAppEmbeddedSignupAvailability } from './usecases/whatsapp/whatsapp-embedded-signup-availability.usecase'; import { WhatsAppValidateTokenCommand } from './usecases/whatsapp/whatsapp-validate-token.command'; import { WhatsAppValidateToken } from './usecases/whatsapp/whatsapp-validate-token.usecase'; @ApiCommonResponses() @Controller('/integrations') @UseInterceptors(ClassSerializerInterceptor) @ApiTags('Integrations') export class IntegrationsController { constructor( private getInAppActivatedUsecase: GetInAppActivated, private getIntegrationsUsecase: GetIntegrations, private getActiveIntegrationsUsecase: GetActiveIntegrations, private getWebhookSupportStatusUsecase: GetWebhookSupportStatus, private createIntegrationUsecase: CreateIntegration, private updateIntegrationUsecase: UpdateIntegration, private autoConfigureIntegrationUsecase: AutoConfigureIntegration, private setIntegrationAsPrimaryUsecase: SetIntegrationAsPrimary, private removeIntegrationUsecase: RemoveIntegration, private calculateLimitNovuIntegration: CalculateLimitNovuIntegration, private organizationRepository: CommunityOrganizationRepository, private generateChatOauthUrlUsecase: GenerateChatOauthUrl, private generateConnectOauthUrlUsecase: GenerateConnectOauthUrl, private generateLinkUserOauthUrlUsecase: GenerateLinkUserOauthUrl, private chatOauthCallbackUsecase: ChatOauthCallback, private slackQuickSetupUsecase: SlackQuickSetup, private featureFlagsService: FeatureFlagsService, private generateMsTeamsArmTemplateUsecase: GenerateMsTeamsArmTemplate, private getMsTeamsArmTemplateUsecase: GetMsTeamsArmTemplate, private generateAzureSetupOauthUrlUsecase: GenerateAzureSetupOauthUrl, private azureSetupOauthCallbackUsecase: AzureSetupOauthCallback, private msTeamsHealthCheckUsecase: MsTeamsHealthCheck, private whatsAppValidateTokenUsecase: WhatsAppValidateToken, private whatsAppEmbeddedSignupUsecase: WhatsAppEmbeddedSignup, private whatsAppEmbeddedSignupAvailabilityUsecase: WhatsAppEmbeddedSignupAvailability, private issueWhatsAppSignupLinkUsecase: IssueWhatsAppSignupLink, private issueIntegrationStoreTelegramMobileLinkUsecase: IssueIntegrationStoreTelegramMobileLink, private issueTelegramSubscriberLinkUsecase: IssueTelegramSubscriberLink, private issueTelegramMobileLinkUsecase: IssueTelegramMobileLink, private configureTelegramWebhookUsecase: ConfigureTelegramWebhook, private integrationRepository: IntegrationRepository, private logger: PinoLogger ) { this.logger.setContext(IntegrationsController.name); } @Get('/') @OAuthAccessible() @ApiOkResponse({ type: [IntegrationResponseDto], description: 'The list of integrations belonging to the organization that are successfully returned.', }) @ApiOperation({ summary: 'List all integrations', description: 'List all the channels integrations created in the organization. Only integration metadata is returned, credentials field is returned as an empty object.', }) @ExternalApiAccessible() @KeylessAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async listIntegrations(@UserSession() user: UserSessionData): Promise<IntegrationResponseDto[]> { const canAccessCredentials = await this.canUserAccessCredentials(user); return await this.getIntegrationsUsecase.execute( GetIntegrationsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, returnCredentials: canAccessCredentials, scopeToEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) ); } @Get('/active') @OAuthAccessible() @ApiOkResponse({ type: [IntegrationResponseDto], description: 'The list of active integrations belonging to the organization that are successfully returned.', }) @ApiOperation({ summary: 'List active integrations', description: 'List all the active integrations created in the organization. Only integration metadata is returned, credentials field is returned as an empty object.', }) @ExternalApiAccessible() @SdkMethodName('listActive') @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async getActiveIntegrations(@UserSession() user: UserSessionData): Promise<IntegrationResponseDto[]> { const canAccessCredentials = await this.canUserAccessCredentials(user); return await this.getActiveIntegrationsUsecase.execute( GetActiveIntegrationsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, returnCredentials: canAccessCredentials, scopeToEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) ); } @Get('/webhook/provider/:providerOrIntegrationId/status') @ApiOkResponse({ type: Boolean, description: 'The status of the webhook for the provider requested', }) @ApiExcludeEndpoint() @ApiOperation({ summary: 'Retrieve webhook status', description: `Retrieve the status of the webhook for integration specified in query param **providerOrIntegrationId**. This API returns a boolean value.`, }) @SdkGroupName('Integrations.Webhooks') @ExternalApiAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async getWebhookSupportStatus( @UserSession() user: UserSessionData, @Param('providerOrIntegrationId') providerOrIntegrationId: string ): Promise<boolean> { return await this.getWebhookSupportStatusUsecase.execute( GetWebhookSupportStatusCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, providerOrIntegrationId, userId: user._id, }) ); } @Post('/') @ApiResponse(IntegrationResponseDto, 201) @ApiOperation({ summary: 'Create an integration', description: `Create an integration for the current environment the user is based on the API key provided. Each provider supports different credentials, check the provider documentation for more details. Only integration metadata is returned, credentials field is returned as an empty object.`, }) @ExternalApiAccessible() @KeylessAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async createIntegration( @UserSession() user: UserSessionData, @Body() body: CreateIntegrationRequestDto ): Promise<IntegrationResponseDto> { try { assertEnvironmentScopedAccess(user.scheme, user.environmentId, body._environmentId); const canAccessCredentials = await this.canUserAccessCredentials(user); const integration = await this.createIntegrationUsecase.execute( CreateIntegrationCommand.create({ userId: user._id, name: body.name, identifier: body.identifier, environmentId: body._environmentId ?? user.environmentId, organizationId: user.organizationId, providerId: body.providerId, channel: body.channel, kind: body.kind, credentials: body.credentials, active: body.active ?? false, check: body.check ?? false, conditions: body.conditions, configurations: body.configurations, }) ); if (canAccessCredentials) { return GetDecryptedIntegrations.getDecryptedCredentials(integration); } const { credentials: _credentials, ...integrationWithoutCredentials } = integration; return integrationWithoutCredentials as unknown as IntegrationResponseDto; } catch (e) { if (e.message.includes('Integration validation failed') || e.message.includes('Cast to embedded')) { throw new BadRequestException(e.message); } throw e; } } @Put('/:integrationId') @ApiResponse(IntegrationResponseDto) @ApiNotFoundResponse({ description: 'The integration with the integrationId provided does not exist in the database.', }) @ApiOperation({ summary: 'Update an integration', description: `Update an integration by its unique key identifier **integrationId**. Each provider supports different credentials, check the provider documentation for more details. Only integration metadata is returned, credentials field is returned as an empty object.`, }) @ExternalApiAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async updateIntegrationById( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string, @Body() body: UpdateIntegrationRequestDto ): Promise<IntegrationResponseDto> { try { assertEnvironmentScopedAccess(user.scheme, user.environmentId, body._environmentId); const canAccessCredentials = await this.canUserAccessCredentials(user); const integration = await this.updateIntegrationUsecase.execute( UpdateIntegrationCommand.create({ userId: user._id, name: body.name, identifier: body.identifier, environmentId: body._environmentId, userEnvironmentId: user.environmentId || undefined, organizationId: user.organizationId, integrationId, credentials: body.credentials, active: body.active, check: body.check ?? false, conditions: body.conditions, configurations: body.configurations, restrictToUserEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) ); if (canAccessCredentials) { return GetDecryptedIntegrations.getDecryptedCredentials(integration); } const { credentials: _credentials, ...integrationWithoutCredentials } = integration; return integrationWithoutCredentials as unknown as IntegrationResponseDto; } catch (e) { if (e.message.includes('Integration validation failed') || e.message.includes('Cast to embedded')) { throw new BadRequestException(e.message); } throw e; } } @Post('/:integrationId/auto-configure') @ApiResponse(AutoConfigureIntegrationResponseDto, 200) @ApiNotFoundResponse({ description: 'The integration with the integrationId provided does not exist in the database.', }) @ApiOperation({ summary: 'Auto-configure an integration for inbound webhooks', description: `Auto-configure an integration by its unique key identifier **integrationId** for inbound webhook support. This will automatically generate required webhook signing keys and configure webhook endpoints. Only integration metadata is returned, credentials field is returned as an empty object.`, }) @ExternalApiAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async autoConfigureIntegration( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string ): Promise<AutoConfigureIntegrationResponseDto> { const result = await this.autoConfigureIntegrationUsecase.execute( AutoConfigureIntegrationCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, integrationId, restrictToUserEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) ); return result; } @Post('/:integrationId/set-primary') @OAuthAccessible() @ApiResponse(IntegrationResponseDto) @ApiNotFoundResponse({ description: 'The integration with the integrationId provided does not exist in the database.', }) @ApiOperation({ summary: 'Update integration as primary', description: `Update an integration as **primary** by its unique key identifier **integrationId**. This API will set the integration as primary for that channel in the current environment. Primary integration is used to deliver notification for sms and email channels in the workflow. Only integration metadata is returned, credentials field is returned as an empty object.`, }) @ExternalApiAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @SdkMethodName('setAsPrimary') async setIntegrationAsPrimary( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string ): Promise<IntegrationResponseDto> { const canAccessCredentials = await this.canUserAccessCredentials(user); const integration = await this.setIntegrationAsPrimaryUsecase.execute( SetIntegrationAsPrimaryCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, integrationId, restrictToUserEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) ); if (canAccessCredentials) { return GetDecryptedIntegrations.getDecryptedCredentials(integration); } const { credentials: _credentials, ...integrationWithoutCredentials } = integration; return integrationWithoutCredentials as unknown as IntegrationResponseDto; } @Delete('/:integrationId') @OAuthAccessible() @ApiResponse(IntegrationResponseDto, 200, true) @ApiOperation({ summary: 'Delete an integration', description: `Delete an integration by its unique key identifier **integrationId**. This action is irreversible. Only integration metadata is returned, credentials field is returned as empty object.`, }) @ExternalApiAccessible() @KeylessAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async removeIntegration( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string ): Promise<IntegrationResponseDto[]> { return await this.removeIntegrationUsecase.execute( RemoveIntegrationCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, integrationId, restrictToUserEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) ); } @Get('/:channelType/limit') @ApiExcludeEndpoint() @OtelSpan() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async getProviderLimit( @UserSession() user: UserSessionData, @Param('channelType') channelType: ChannelTypeEnum ): Promise<ChannelTypeLimitDto> { const result = await this.calculateLimitNovuIntegration.execute( CalculateLimitNovuIntegrationCommand.create({ channelType, organizationId: user.organizationId, environmentId: user.environmentId, }) ); if (!result) { return { limit: 0, count: 0 }; } return result; } @Get('/in-app/status') @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async getInAppActivated(@UserSession() user: UserSessionData) { return await this.getInAppActivatedUsecase.execute( GetInAppActivatedCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, }) ); } @Get('/:integrationId/msteams-arm-template/deploy-url') @ApiOkResponse({ description: 'Signed Azure Portal "Deploy to Azure" URL for the MS Teams ARM template.', }) @ApiOperation({ summary: 'Get MS Teams ARM template deploy URL', description: 'Returns a short-lived signed URL that opens the Azure Portal with a pre-filled ARM template to create the Azure Bot resource and enable the MS Teams channel.', }) @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async getMsTeamsArmTemplateDeployUrl( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string ): Promise<{ deployUrl: string }> { return this.generateMsTeamsArmTemplateUsecase.execute( GenerateMsTeamsArmTemplateCommand.create({ userId: user._id, organizationId: user.organizationId, integrationId, }) ); } /** * Public endpoint fetched by Azure Portal when the user clicks "Deploy to Azure". * Protected by an HMAC-signed, time-expiring `sig` + `exp` query parameter pair — * no session cookie is available because Azure's servers make this request, not the browser. */ @Get('/:integrationId/msteams-arm-template') @ApiExcludeEndpoint() @ApiOperation({ summary: 'Serve MS Teams ARM template JSON (signed)' }) async getMsTeamsArmTemplateJson( @Res() res: Response, @Param('integrationId') integrationId: string, @Query('sig') sig: string, @Query('exp') exp: string ): Promise<void> { if (!sig || !exp) { throw new BadRequestException('Missing required parameters: sig, exp'); } const { template } = await this.getMsTeamsArmTemplateUsecase.execute(integrationId, sig, exp); res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store'); res.send(JSON.stringify(template, null, 2)); } /** * Quick Setup: generate an Azure AD OAuth URL so Novu can create the App Registration * on the user's behalf via Microsoft Graph. */ @Get('/:integrationId/msteams-azure-setup/oauth-url') @ApiOkResponse({ description: 'Azure AD OAuth URL for the Quick Setup flow (Novu creates the app registration).', }) @ApiOperation({ summary: 'Get Azure Quick Setup OAuth URL', description: 'Returns an Azure AD OAuth URL that authorizes Novu to create an App Registration and client secret on your behalf via Microsoft Graph.', }) @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async getAzureSetupOauthUrl( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string ): Promise<{ url: string }> { const url = await this.generateAzureSetupOauthUrlUsecase.execute( GenerateAzureSetupOauthUrlCommand.create({ userId: user._id, organizationId: user.organizationId, environmentId: user.environmentId, integrationId, }) ); return { url }; } /** * Health-check endpoint polled by the dashboard to determine if the saved MS Teams * credentials, app catalog entry, and Graph permissions are ready after the Quick * Setup OAuth flow. */ @Get('/:integrationId/msteams-health') @ApiOkResponse({ description: 'Per-checkpoint health status for an MS Teams integration after Quick Setup.', }) @ApiOperation({ summary: 'Get MS Teams integration health status', description: 'Returns the readiness status of the stored MS Teams credentials, app catalog entry, and Graph permissions. Poll this endpoint after the OAuth setup completes to determine when it is safe to proceed to admin consent.', }) @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async getMsTeamsHealth( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string, @Query('checks') checksParam?: string ): Promise<MsTeamsHealthCheckResult> { const checks = checksParam ? checksParam .split(',') .map((s) => s.trim()) .filter(Boolean) : undefined; return this.msTeamsHealthCheckUsecase.execute( MsTeamsHealthCheckCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, integrationId, checks, }) ); } /** * Quick Setup callback: Azure AD redirects here after the user authorizes Novu. * Creates the App Registration, secret, and service principal via Graph, saves * credentials to the integration, then attempts to upload the Teams app to the catalog. * Returns a self-closing script that posts a message to the opener tab and closes itself. */ @Get('/chat/oauth/azure-setup/callback') @ApiExcludeEndpoint() @ApiOperation({ summary: 'Azure Quick Setup OAuth callback' }) async handleAzureSetupOauthCallback( @Res() res: Response, @Query('code') code?: string, @Query('state') state?: string, @Query('error') error?: string, @Query('error_description') errorDescription?: string ): Promise<void> { res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'"); if (!state) { res .status(400) .type('html') .send( AzureSetupOauthCallback.buildPopupHtml({ success: false, errorMessage: 'Missing required OAuth parameter: state', }) ); return; } try { const result = await this.azureSetupOauthCallbackUsecase.execute( AzureSetupOauthCallbackCommand.create({ state, code, error, errorDescription, }) ); res.type('html').send(result.html); } catch (err: unknown) { this.logger.error({ err }, 'Azure OAuth callback failed'); res .status(200) .type('html') .send( AzureSetupOauthCallback.buildPopupHtml({ success: false, errorMessage: 'An unexpected error occurred while completing Azure setup.', }) ); } } /** * @deprecated Use POST /integrations/channel-connections/oauth or POST /integrations/channel-endpoints/oauth instead. */ @Post('/chat/oauth') @ApiResponse(GenerateChatOAuthUrlResponseDto, 201) @ApiOperation({ summary: 'Generate chat OAuth URL', description: `**Deprecated** — use \`POST /integrations/channel-connections/oauth\` (connect) or \`POST /integrations/channel-endpoints/oauth\` (link_user) instead. Generate an OAuth URL for chat integrations like Slack, MS Teams, and Webex. This URL allows subscribers to authorize the integration, enabling the system to send messages through their chat workspace. The generated URL expires after 5 minutes.`, deprecated: true, }) @SdkMethodName('generateChatOAuthUrl') @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @ExternalApiAccessible() @RequireAuthentication() async getChatOAuthUrl( @UserSession() user: UserSessionData, @Body() body: GenerateChatOauthUrlRequestDto ): Promise<GenerateChatOAuthUrlResponseDto> { const url = await this.generateChatOauthUrlUsecase.execute( GenerateChatOauthUrlCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId: body.subscriberId, integrationIdentifier: body.integrationIdentifier, connectionIdentifier: body.connectionIdentifier, context: body.context, scope: body.scope, userScope: body.userScope, mode: body.mode, connectionMode: body.connectionMode, autoLinkUser: body.autoLinkUser, }) ); return { url }; } @Post('/channel-connections/oauth') @ApiResponse(GenerateChatOAuthUrlResponseDto, 201) @ApiOperation({ summary: 'Generate OAuth URL for a workspace/tenant connection', description: `Generate an OAuth URL that creates a workspace or tenant-level channel connection (Slack workspace install, MS Teams admin consent, or Webex integration authorization). The generated URL expires after 5 minutes.`, }) @SdkMethodName('generateConnectOAuthUrl') @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @ExternalApiAccessible() @KeylessAccessible() @RequireAuthentication() async generateConnectOAuthUrl( @UserSession() user: UserSessionData, @Body() body: GenerateConnectOauthUrlRequestDto ): Promise<GenerateChatOAuthUrlResponseDto> { const url = await this.generateConnectOauthUrlUsecase.execute( GenerateConnectOauthUrlCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId: body.subscriberId, integrationIdentifier: body.integrationIdentifier, connectionIdentifier: body.connectionIdentifier, context: body.context, contextHash: body.contextHash, scope: body.scope, connectionMode: body.connectionMode, autoLinkUser: body.autoLinkUser, }) ); return { url }; } @Post('/channel-endpoints/oauth') @ApiResponse(GenerateChatOAuthUrlResponseDto, 201) @ApiOperation({ summary: 'Generate OAuth URL to link a subscriber user identity', description: `Generate an OAuth URL that links a specific subscriber to their chat identity (Slack user ID, MS Teams user OID, or Webex person). The generated URL expires after 5 minutes.`, }) @SdkMethodName('generateLinkUserOAuthUrl') @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @ExternalApiAccessible() @RequireAuthentication() async generateLinkUserOAuthUrl( @UserSession() user: UserSessionData, @Body() body: GenerateLinkUserOauthUrlRequestDto ): Promise<GenerateChatOAuthUrlResponseDto> { const url = await this.generateLinkUserOauthUrlUsecase.execute( GenerateLinkUserOauthUrlCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId: body.subscriberId, integrationIdentifier: body.integrationIdentifier, connectionIdentifier: body.connectionIdentifier, context: body.context, contextHash: body.contextHash, userScope: body.userScope, }) ); return { url }; } @Get('/chat/oauth/callback') @ApiOperation({ summary: 'Handle chat OAuth callback', description: `Generic OAuth callback handler for all chat integrations (Slack, Teams, Webex, etc.). This endpoint processes the authorization code and stores the connection for any supported chat provider.`, }) @ApiExcludeEndpoint() async handleChatOAuthCallback( @Res() res: Response, @Query('code') providerCode?: string, @Query('tenant') tenant?: string, @Query('admin_consent') adminConsent?: string, @Query('state') state?: string, @Query('error') error?: string, @Query('error_description') errorDescription?: string ): Promise<void> { if (error) { throw new BadRequestException(`OAuth error: ${error}${errorDescription ? ` - ${errorDescription}` : ''}`); } if (!state) { throw new BadRequestException('Missing required OAuth parameter: state'); } if (!providerCode && !tenant) { throw new BadRequestException('Missing required OAuth parameters: code or tenant'); } const result = await this.chatOauthCallbackUsecase.execute( ChatOauthCallbackCommand.create({ providerCode, tenant, adminConsent, state, }) ); if (result.type === ResponseTypeEnum.HTML) { res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Security-Policy', CONNECTION_RESULT_CSP); res.send(result.result); return; } res.redirect(result.result); } @Post('/whatsapp/validate-token') @ApiResponse(WhatsAppValidateTokenResponseDto, 200) @ApiOperation({ summary: 'Validate WhatsApp Business credentials inline', description: 'Calls the Meta Graph API to validate a WhatsApp Cloud API access token (and optional phone number ID) before the user saves the integration. Returns the available scopes and resolves the WhatsApp Business Account ID, used by the dashboard onboarding flow to surface friendly inline errors.', }) @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async validateWhatsAppToken( @UserSession() user: UserSessionData, @Body() body: WhatsAppValidateTokenRequestDto ): Promise<WhatsAppValidateTokenResponseDto> { return this.whatsAppValidateTokenUsecase.execute( WhatsAppValidateTokenCommand.create({ userId: user._id, organizationId: user.organizationId, accessToken: body.accessToken, phoneNumberIdentification: body.phoneNumberIdentification, businessAccountId: body.businessAccountId, }) ); } @Post('/whatsapp/embedded-signup') @ApiResponse(WhatsAppEmbeddedSignupResponseDto, 200) @ApiOperation({ summary: 'Complete WhatsApp Embedded Signup', description: 'Exchanges a Meta Embedded Signup authorization code for a business integration token, saves WhatsApp credentials on the integration, registers the phone number when possible, and configures the agent webhook with Meta.', }) @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @HttpCode(HttpStatus.OK) async completeWhatsAppEmbeddedSignup( @UserSession() user: UserSessionData, @Body() body: WhatsAppEmbeddedSignupRequestDto ): Promise<WhatsAppEmbeddedSignupResponseDto> { return this.whatsAppEmbeddedSignupUsecase.execute( WhatsAppEmbeddedSignupCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, code: body.code, wabaId: body.wabaId, phoneNumberId: body.phoneNumberId, integrationIdentifier: body.integrationIdentifier, agentIdentifier: body.agentIdentifier, }) ); } @Get('/whatsapp/embedded-signup/availability') @ApiResponse(WhatsAppEmbeddedSignupAvailabilityResponseDto, 200) @ApiOperation({ summary: 'Check WhatsApp Embedded Signup availability', description: 'Reports whether Meta Embedded Signup can be completed on this deployment for the caller organization (feature flag plus Tech Provider credentials). Used by the connect CLI to route between the embedded-signup flow and the classic dashboard handoff.', }) @ApiExcludeEndpoint() @ExternalApiAccessible() @KeylessAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_READ) async getWhatsAppEmbeddedSignupAvailability( @UserSession() user: UserSessionData ): Promise<WhatsAppEmbeddedSignupAvailabilityResponseDto> { return this.whatsAppEmbeddedSignupAvailabilityUsecase.execute( WhatsAppEmbeddedSignupAvailabilityCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, }) ); } @Post('/whatsapp/signup-link') @ApiResponse(IssueWhatsAppSignupLinkResponseDto, 200) @ApiOperation({ summary: 'Issue a public WhatsApp Embedded Signup link', description: 'Mints an opaque, single-use token bound to the agent + WhatsApp integration and returns the public signup page URL. ' + 'Used by the connect CLI (keyless or authenticated) to hand the browser off to Meta Embedded Signup.', }) @ApiExcludeEndpoint() @ExternalApiAccessible() @KeylessAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @HttpCode(HttpStatus.OK) async createWhatsAppSignupLink( @UserSession() user: UserSessionData, @Body() body: IssueWhatsAppSignupLinkRequestDto ): Promise<IssueWhatsAppSignupLinkResponseDto> { return this.issueWhatsAppSignupLinkUsecase.execute( IssueWhatsAppSignupLinkCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, agentIdentifier: body.agentIdentifier, integrationIdentifier: body.integrationIdentifier, }) ); } @Post('/channel-endpoints/link') @ExternalApiAccessible() @KeylessAccessible() @HttpCode(HttpStatus.OK) @ApiResponse(LinkChannelEndpointResponseDto, 200) @ApiOperation({ summary: 'Issue a URL to link a subscriber chat identity', description: 'Returns a provider-specific URL the subscriber opens to link their chat identity. ' + 'The integration provider is resolved from integrationIdentifier; Telegram returns a deep link.', }) @SdkGroupName('Integrations') @SdkMethodName('linkChannelEndpoint') @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @RequireAuthentication() async linkChannelEndpoint( @UserSession() user: UserSessionData, @Body() body: LinkChannelEndpointRequestDto ): Promise<LinkChannelEndpointResponseDto> { const integration = await this.integrationRepository.findOne( { identifier: body.integrationIdentifier, _environmentId: user.environmentId, _organizationId: user.organizationId, }, '_id providerId' ); if (!integration) { throw new NotFoundException(`Integration ${body.integrationIdentifier} not found`); } const providerId = integration.providerId as ChatProviderIdEnum; switch (providerId) { case ChatProviderIdEnum.Telegram: { const result = await this.issueTelegramSubscriberLinkUsecase.execute( IssueTelegramSubscriberLinkCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, integrationIdentifier: body.integrationIdentifier, subscriberId: body.subscriberId, context: body.context, contextHash: body.contextHash, }) ); return { url: result.deepLinkUrl, providerMetadata: { botUsername: result.botUsername, expiresAt: result.expiresAt, }, }; } default: throw new BadRequestException( `Provider "${providerId}" does not support subscriber chat linking via this endpoint.` ); } } @Post('/:integrationIdentifier/webhook/configure') @ExternalApiAccessible() @KeylessAccessible() @HttpCode(HttpStatus.OK) @ApiResponse(ConfigureTelegramWebhookResponseDto, 200) @ApiOperation({ summary: 'Configure a chat integration webhook', description: 'Registers the Novu webhook URL with the chat provider for the specified integration. ' + 'Telegram is the only supported provider initially.', }) @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @RequireAuthentication() async configureIntegrationWebhook( @UserSession() user: UserSessionData, @Param('integrationIdentifier') integrationIdentifier: string ): Promise<ConfigureTelegramWebhookResponseDto> { const integration = await this.integrationRepository.findOne( { identifier: integrationIdentifier, _environmentId: user.environmentId, _organizationId: user.organizationId, }, '_id providerId' ); if (!integration) { throw new NotFoundException(`Integration ${integrationIdentifier} not found`); } const providerId = integration.providerId as ChatProviderIdEnum; switch (providerId) { case ChatProviderIdEnum.Telegram: return this.configureTelegramWebhookUsecase.execute( ConfigureTelegramWebhookCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, integrationIdentifier, }) ); default: throw new BadRequestException( `Provider "${providerId}" does not support webhook configuration via this endpoint.` ); } } @Post('/:integrationIdentifier/mobile-link') @ExternalApiAccessible() @KeylessAccessible() @HttpCode(HttpStatus.OK) @ApiResponse(IssueTelegramMobileLinkResponseDto, 200) @SdkMethodName('createMobileLink') @ApiOperation({ summary: 'Issue a short-lived mobile setup link for an existing integration', description: 'Returns an opaque, single-use setup token plus a mobile URL for configuring an existing chat integration. ' + 'Telegram is the only supported provider initially.', }) @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @RequireAuthentication() async createIntegrationMobileLink( @UserSession() user: UserSessionData, @Param('integrationIdentifier') integrationIdentifier: string, @Body() body?: IssueIntegrationMobileLinkRequestDto ): Promise<IssueTelegramMobileLinkResponseDto> { const integration = await this.integrationRepository.findOne( { identifier: integrationIdentifier, _environmentId: user.environmentId, _organizationId: user.organizationId, }, '_id providerId' ); if (!integration) { throw new NotFoundException(`Integration ${integrationIdentifier} not found`); } const providerId = integration.providerId as ChatProviderIdEnum; switch (providerId) { case ChatProviderIdEnum.Telegram: return this.issueTelegramMobileLinkUsecase.execute( IssueTelegramMobileLinkCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, integrationIdentifier, subscriberId: body?.subscriberId, }) ); default: throw new BadRequestException( `Provider "${providerId}" does not support mobile setup links via this endpoint.` ); } } @Post('/telegram/mobile-link') @ApiResponse(IssueIntegrationStoreTelegramMobileLinkResponseDto, 200) @ApiOperation({ summary: 'Issue a short-lived Telegram mobile setup link for the integration store', description: 'Returns an opaque, single-use, short-lived setup token plus a mobile URL. The visitor pastes the BotFather token on the linked landing page and the consume endpoint creates a brand-new Telegram integration in the current environment.', }) @ApiExcludeEndpoint() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) @HttpCode(HttpStatus.OK) async createTelegramMobileLink( @UserSession() user: UserSessionData ): Promise<IssueIntegrationStoreTelegramMobileLinkResponseDto> { return this.issueIntegrationStoreTelegramMobileLinkUsecase.execute( IssueIntegrationStoreTelegramMobileLinkCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, }) ); } @Post('/:integrationId/slack-quick-setup') @ExternalApiAccessible() @ApiResponse(SlackQuickSetupResponseDto, 201) @ApiOperation({ summary: 'Quick-setup a Slack integration', description: `Creates a Slack app from a manifest using the provided App Configuration Token and saves the resulting credentials (client ID, client secret, signing secret) directly on the integration. The configuration token is used ephemerally and is never stored.`, }) @ApiExcludeEndpoint() @KeylessAccessible() @RequireAuthentication() @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) async slackQuickSetup( @UserSession() user: UserSessionData, @Param('integrationId') integrationId: string, @Body() body: SlackQuickSetupRequestDto ): Promise<SlackQuickSetupResponseDto> { return this.slackQuickSetupUsecase.execute( SlackQuickSetupCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, integrationId, agentId: body.agentId, configToken: body.configToken, subscriberId: body.subscriberId, connectionIdentifier: body.connectionIdentifier, }) ); } private async canUserAccessCredentials(user: UserSessionData): Promise<boolean> { /* * API-key, keyless, and OAuth auth must never receive decrypted provider credentials, * regardless of RBAC state. API keys grant ALL_PERMISSIONS in `community.auth.service.ts`, * which would otherwise allow the RBAC path below to succeed and leak every stored * provider secret to any caller holding an environment API key or OAuth token. */ if ( user.scheme === ApiAuthSchemeEnum.API_KEY || user.scheme === ApiAuthSchemeEnum.KEYLESS || user.scheme === ApiAuthSchemeEnum.OAUTH2 ) { return false; } const organization = await this.organizationRepository.findOne({ _id: user.organizationId, }); const [isRbacFlagEnabled, isRbacFeatureEnabled] = await Promise.all([ this.featureFlagsService.getFlag({ organization: { _id: user.organizationId }, user: { _id: user._id }, key: FeatureFlagsKeysEnum.IS_RBAC_ENABLED, defaultValue: false, }), getFeatureForTierAsBoolean( FeatureNameEnum.ACCOUNT_ROLE_BASED_ACCESS_CONTROL_BOOLEAN, organization?.apiServiceLevel || ApiServiceLevelEnum.FREE ), ]); const isRbacEnabled = isRbacFlagEnabled && isRbacFeatureEnabled; if (!isRbacEnabled) { return true; } return user.permissions.includes(PermissionsEnum.INTEGRATION_WRITE); } }