diff --git a/apps/api/src/app/channel-connections/channel-connections.controller.ts b/apps/api/src/app/channel-connections/channel-connections.controller.ts index 5bad4473a87..401cd35afb6 100644 --- a/apps/api/src/app/channel-connections/channel-connections.controller.ts +++ b/apps/api/src/app/channel-connections/channel-connections.controller.ts @@ -130,6 +130,7 @@ export class ChannelConnectionsController { integrationIdentifier: body.integrationIdentifier, subscriberId: body.subscriberId, context: body.context, + connectionMode: body.connectionMode, workspace: body.workspace, auth: body.auth, }) diff --git a/apps/api/src/app/channel-connections/dtos/create-channel-connection-request.dto.ts b/apps/api/src/app/channel-connections/dtos/create-channel-connection-request.dto.ts index 0017d65a734..7d00e6db487 100644 --- a/apps/api/src/app/channel-connections/dtos/create-channel-connection-request.dto.ts +++ b/apps/api/src/app/channel-connections/dtos/create-channel-connection-request.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic'; -import { ContextPayload } from '@novu/shared'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; import { Type } from 'class-transformer'; -import { IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsDefined, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'; import { AuthDto, WorkspaceDto } from './shared.dto'; export class CreateChannelConnectionRequestDto { @@ -30,6 +30,20 @@ export class CreateChannelConnectionRequestDto { @IsValidContextPayload({ maxCount: 5 }) context?: ContextPayload; + @ApiPropertyOptional({ + description: + 'Connection mode that determines how the channel connection is scoped. ' + + 'Use "subscriber" (default) to associate the connection with a specific subscriber. ' + + 'Use "shared" to associate the connection with a context instead of a subscriber — ' + + 'subscriberId will not be stored on the connection.', + enum: ['subscriber', 'shared'], + example: 'shared', + }) + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + connectionMode?: ConnectionMode; + @ApiProperty({ description: 'The identifier of the integration to use for this channel connection.', type: String, diff --git a/apps/api/src/app/channel-connections/usecases/channel-connection.utils.ts b/apps/api/src/app/channel-connections/usecases/channel-connection.utils.ts new file mode 100644 index 00000000000..87f9a27e93b --- /dev/null +++ b/apps/api/src/app/channel-connections/usecases/channel-connection.utils.ts @@ -0,0 +1,44 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; + +/** + * Validates that the subscriber/context combination is consistent with the + * requested connectionMode. Throws a BadRequestException when the caller + * violates the scoping rules for the chosen mode. + * + * Called from both CreateChannelConnection and GenerateSlackOauthUrl so the + * rules are enforced in one place. + */ +export function validateConnectionMode({ + connectionMode, + subscriberId, + context, +}: { + connectionMode?: ConnectionMode; + subscriberId?: string; + context?: ContextPayload; +}): void { + if (connectionMode === 'shared') { + if (!context) { + throw new BadRequestException('context is required when connectionMode is "shared"'); + } + + if (subscriberId) { + throw new BadRequestException('subscriberId must not be provided when connectionMode is "shared"'); + } + + return; + } + + if (connectionMode === 'subscriber') { + if (!subscriberId) { + throw new BadRequestException('subscriberId is required when connectionMode is "subscriber"'); + } + + return; + } + + if (!subscriberId && !context) { + throw new BadRequestException('Either subscriberId or context must be provided'); + } +} diff --git a/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.command.ts b/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.command.ts index 45c45dc0642..97e39896bb1 100644 --- a/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.command.ts +++ b/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.command.ts @@ -1,7 +1,7 @@ import { IsValidContextPayload } from '@novu/application-generic'; -import { ContextPayload } from '@novu/shared'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; import { Type } from 'class-transformer'; -import { IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsDefined, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { AuthDto, WorkspaceDto } from '../../dtos/shared.dto'; @@ -22,6 +22,11 @@ export class CreateChannelConnectionCommand extends EnvironmentCommand { @IsValidContextPayload({ maxCount: 5 }) context?: ContextPayload; + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + connectionMode?: ConnectionMode; + @IsDefined() @ValidateNested() @Type(() => WorkspaceDto) diff --git a/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.usecase.ts b/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.usecase.ts index b3e498fc6ed..08fc9e55665 100644 --- a/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.usecase.ts +++ b/apps/api/src/app/channel-connections/usecases/create-channel-connection/create-channel-connection.usecase.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase, shortId } from '@novu/application-generic'; import { ChannelConnectionEntity, @@ -8,6 +8,7 @@ import { IntegrationRepository, SubscriberRepository, } from '@novu/dal'; +import { validateConnectionMode } from '../channel-connection.utils'; import { CreateChannelConnectionCommand } from './create-channel-connection.command'; @Injectable() @@ -50,11 +51,11 @@ export class CreateChannelConnection { } private validateResourceOrContext(command: CreateChannelConnectionCommand) { - const { subscriberId, context } = command; - - if (!subscriberId && !context) { - throw new BadRequestException('Either subscriberId or context must be provided'); - } + validateConnectionMode({ + connectionMode: command.connectionMode, + subscriberId: command.subscriberId, + context: command.context, + }); } private async resolveContexts(command: CreateChannelConnectionCommand): Promise { diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index d9f0f25e98c..73d2dd13387 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -6,6 +6,7 @@ import { Headers, HttpCode, HttpStatus, + NotFoundException, Param, Patch, Post, @@ -16,17 +17,49 @@ import { } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiExcludeController } from '@nestjs/swagger'; +import { FeatureFlagsService } from '@novu/application-generic'; import { AddressingTypeEnum, + FeatureFlagsKeysEnum, MessageActionStatusEnum, PreferenceLevelEnum, TriggerRequestCategoryEnum, UserSessionData, } from '@novu/shared'; +import { CreateChannelConnectionRequestDto } from '../channel-connections/dtos/create-channel-connection-request.dto'; +import { mapChannelConnectionEntityToDto } from '../channel-connections/dtos/dto.mapper'; +import { GetChannelConnectionResponseDto } from '../channel-connections/dtos/get-channel-connection-response.dto'; +import { ListChannelConnectionsQueryDto } from '../channel-connections/dtos/list-channel-connections-query.dto'; +import { ListChannelConnectionsResponseDto } from '../channel-connections/dtos/list-channel-connections-response.dto'; +import { CreateChannelConnectionCommand } from '../channel-connections/usecases/create-channel-connection/create-channel-connection.command'; +import { CreateChannelConnection } from '../channel-connections/usecases/create-channel-connection/create-channel-connection.usecase'; +import { DeleteChannelConnectionCommand } from '../channel-connections/usecases/delete-channel-connection/delete-channel-connection.command'; +import { DeleteChannelConnection } from '../channel-connections/usecases/delete-channel-connection/delete-channel-connection.usecase'; +import { GetChannelConnectionCommand } from '../channel-connections/usecases/get-channel-connection/get-channel-connection.command'; +import { GetChannelConnection } from '../channel-connections/usecases/get-channel-connection/get-channel-connection.usecase'; +import { ListChannelConnectionsCommand } from '../channel-connections/usecases/list-channel-connections/list-channel-connections.command'; +import { ListChannelConnections } from '../channel-connections/usecases/list-channel-connections/list-channel-connections.usecase'; +import { CreateChannelEndpointRequest } from '../channel-endpoints/dtos/create-channel-endpoint-request.dto'; +import { mapChannelEndpointEntityToDto } from '../channel-endpoints/dtos/dto.mapper'; +import { GetChannelEndpointResponseDto } from '../channel-endpoints/dtos/get-channel-endpoint-response.dto'; +import { ListChannelEndpointsQueryDto } from '../channel-endpoints/dtos/list-channel-endpoints-query.dto'; +import { ListChannelEndpointsResponseDto } from '../channel-endpoints/dtos/list-channel-endpoints-response.dto'; +import { CreateChannelEndpointCommand } from '../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.command'; +import { CreateChannelEndpoint } from '../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; +import { DeleteChannelEndpointCommand } from '../channel-endpoints/usecases/delete-channel-endpoint/delete-channel-endpoint.command'; +import { DeleteChannelEndpoint } from '../channel-endpoints/usecases/delete-channel-endpoint/delete-channel-endpoint.usecase'; +import { GetChannelEndpointCommand } from '../channel-endpoints/usecases/get-channel-endpoint/get-channel-endpoint.command'; +import { GetChannelEndpoint } from '../channel-endpoints/usecases/get-channel-endpoint/get-channel-endpoint.usecase'; +import { ListChannelEndpointsCommand } from '../channel-endpoints/usecases/list-channel-endpoints/list-channel-endpoints.command'; +import { ListChannelEndpoints } from '../channel-endpoints/usecases/list-channel-endpoints/list-channel-endpoints.usecase'; import { TriggerEventRequestDto } from '../events/dtos'; import { TriggerEventResponseDto } from '../events/dtos/trigger-event-response.dto'; import { ParseEventRequestMulticastCommand } from '../events/usecases/parse-event-request'; import { ParseEventRequest } from '../events/usecases/parse-event-request/parse-event-request.usecase'; +import { GenerateChatOauthUrlRequestDto } from '../integrations/dtos/generate-chat-oauth-url.dto'; +import { GenerateChatOAuthUrlResponseDto } from '../integrations/dtos/generate-chat-oauth-url-response.dto'; +import { GenerateChatOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command'; +import { GenerateChatOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase'; import { ExcludeFromIdempotency } from '../shared/framework/exclude-from-idempotency'; import { ApiCommonResponses } from '../shared/framework/response.decorator'; import { KeylessAccessible } from '../shared/framework/swagger/keyless.security'; @@ -103,7 +136,17 @@ export class InboxController { private parseEventRequest: ParseEventRequest, private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, private deleteNotificationUsecase: DeleteNotification, - private deleteAllNotificationsUsecase: DeleteAllNotifications + private deleteAllNotificationsUsecase: DeleteAllNotifications, + private listChannelConnectionsUsecase: ListChannelConnections, + private getChannelConnectionUsecase: GetChannelConnection, + private createChannelConnectionUsecase: CreateChannelConnection, + private deleteChannelConnectionUsecase: DeleteChannelConnection, + private listChannelEndpointsUsecase: ListChannelEndpoints, + private getChannelEndpointUsecase: GetChannelEndpoint, + private createChannelEndpointUsecase: CreateChannelEndpoint, + private deleteChannelEndpointUsecase: DeleteChannelEndpoint, + private generateChatOauthUrlUsecase: GenerateChatOauthUrl, + private featureFlagsService: FeatureFlagsService ) {} @KeylessAccessible() @@ -625,4 +668,275 @@ export class InboxController { return result as unknown as TriggerEventResponseDto; } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/channel-connections') + async listChannelConnections( + @SubscriberSession() subscriberSession: SubscriberSession, + @Query() query: ListChannelConnectionsQueryDto + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const result = await this.listChannelConnectionsUsecase.execute( + ListChannelConnectionsCommand.create({ + user: { + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + } as UserSessionData, + subscriberId: subscriberSession.subscriberId, + limit: query.limit || 10, + after: query.after, + before: query.before, + orderDirection: query.orderDirection, + orderBy: query.orderBy || 'createdAt', + includeCursor: query.includeCursor, + contextKeys: query.contextKeys, + channel: query.channel, + providerId: query.providerId, + integrationIdentifier: query.integrationIdentifier, + }) + ); + + return { + data: result.data.map(mapChannelConnectionEntityToDto), + next: result.next, + previous: result.previous, + totalCount: result.totalCount!, + totalCountCapped: result.totalCountCapped!, + }; + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/channel-connections/:identifier') + async getChannelConnection( + @SubscriberSession() subscriberSession: SubscriberSession, + @Param('identifier') identifier: string + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const channelConnection = await this.getChannelConnectionUsecase.execute( + GetChannelConnectionCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier, + }) + ); + + if (channelConnection.subscriberId && channelConnection.subscriberId !== subscriberSession.subscriberId) { + throw new NotFoundException(`Channel connection not found: ${identifier}`); + } + + return mapChannelConnectionEntityToDto(channelConnection); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Post('/channel-connections') + async createChannelConnection( + @SubscriberSession() subscriberSession: SubscriberSession, + @Body() body: CreateChannelConnectionRequestDto + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const channelConnection = await this.createChannelConnectionUsecase.execute( + CreateChannelConnectionCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier: body.identifier, + integrationIdentifier: body.integrationIdentifier, + subscriberId: subscriberSession.subscriberId, + context: body.context, + workspace: body.workspace, + auth: body.auth, + }) + ); + + return mapChannelConnectionEntityToDto(channelConnection); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Delete('/channel-connections/:identifier') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteChannelConnection( + @SubscriberSession() subscriberSession: SubscriberSession, + @Param('identifier') identifier: string + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const channelConnection = await this.getChannelConnectionUsecase.execute( + GetChannelConnectionCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier, + }) + ); + + if (channelConnection.subscriberId && channelConnection.subscriberId !== subscriberSession.subscriberId) { + throw new NotFoundException(`Channel connection not found: ${identifier}`); + } + + await this.deleteChannelConnectionUsecase.execute( + DeleteChannelConnectionCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier, + }) + ); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/channel-endpoints') + async listChannelEndpoints( + @SubscriberSession() subscriberSession: SubscriberSession, + @Query() query: ListChannelEndpointsQueryDto + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const result = await this.listChannelEndpointsUsecase.execute( + ListChannelEndpointsCommand.create({ + user: { + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + } as UserSessionData, + subscriberId: subscriberSession.subscriberId, + limit: query.limit || 10, + after: query.after, + before: query.before, + orderDirection: query.orderDirection, + orderBy: query.orderBy || 'createdAt', + includeCursor: query.includeCursor, + contextKeys: query.contextKeys, + channel: query.channel, + providerId: query.providerId, + integrationIdentifier: query.integrationIdentifier, + connectionIdentifier: query.connectionIdentifier, + }) + ); + + return { + data: result.data.map(mapChannelEndpointEntityToDto), + next: result.next, + previous: result.previous, + totalCount: result.totalCount!, + totalCountCapped: result.totalCountCapped!, + }; + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/channel-endpoints/:identifier') + async getChannelEndpoint( + @SubscriberSession() subscriberSession: SubscriberSession, + @Param('identifier') identifier: string + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const channelEndpoint = await this.getChannelEndpointUsecase.execute( + GetChannelEndpointCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier, + }) + ); + + if (channelEndpoint.subscriberId && channelEndpoint.subscriberId !== subscriberSession.subscriberId) { + throw new NotFoundException(`Channel endpoint not found: ${identifier}`); + } + + return mapChannelEndpointEntityToDto(channelEndpoint); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Post('/channel-endpoints') + async createChannelEndpoint( + @SubscriberSession() subscriberSession: SubscriberSession, + @Body() body: CreateChannelEndpointRequest + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + // Cast needed because CreateChannelEndpointRequest is a discriminated union; the type/endpoint + // fields are correctly validated by class-validator before reaching this handler. + const typedBody = body as Extract; + + const channelEndpoint = await this.createChannelEndpointUsecase.execute( + CreateChannelEndpointCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier: typedBody.identifier, + integrationIdentifier: typedBody.integrationIdentifier, + connectionIdentifier: typedBody.connectionIdentifier, + subscriberId: subscriberSession.subscriberId, + context: typedBody.context, + type: typedBody.type, + endpoint: typedBody.endpoint, + } as Parameters[0]) + ); + + return mapChannelEndpointEntityToDto(channelEndpoint); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Delete('/channel-endpoints/:identifier') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteChannelEndpoint( + @SubscriberSession() subscriberSession: SubscriberSession, + @Param('identifier') identifier: string + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const channelEndpoint = await this.getChannelEndpointUsecase.execute( + GetChannelEndpointCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier, + }) + ); + + if (channelEndpoint.subscriberId && channelEndpoint.subscriberId !== subscriberSession.subscriberId) { + throw new NotFoundException(`Channel endpoint not found: ${identifier}`); + } + + await this.deleteChannelEndpointUsecase.execute( + DeleteChannelEndpointCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + identifier, + }) + ); + } + + @UseGuards(AuthGuard('subscriberJwt')) + @Post('/chat/oauth') + async generateChatOAuthUrl( + @SubscriberSession() subscriberSession: SubscriberSession, + @Body() body: GenerateChatOauthUrlRequestDto + ): Promise { + await this.checkChannelFeatureEnabled(subscriberSession._organizationId); + + const url = await this.generateChatOauthUrlUsecase.execute( + GenerateChatOauthUrlCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession.subscriberId, + integrationIdentifier: body.integrationIdentifier, + connectionIdentifier: body.connectionIdentifier, + context: body.context, + scope: body.scope, + userScope: body.userScope, + mode: body.mode, + }) + ); + + return { url }; + } + + private async checkChannelFeatureEnabled(organizationId: string): Promise { + const isEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_SLACK_TEAMS_ENABLED, + defaultValue: false, + organization: { _id: organizationId }, + }); + + if (!isEnabled) { + throw new NotFoundException('Feature not enabled'); + } + } } diff --git a/apps/api/src/app/inbox/inbox.module.ts b/apps/api/src/app/inbox/inbox.module.ts index 013203a03ef..09ab99f7f5f 100644 --- a/apps/api/src/app/inbox/inbox.module.ts +++ b/apps/api/src/app/inbox/inbox.module.ts @@ -8,6 +8,8 @@ import { TopicSubscribersRepository, } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; +import { ChannelConnectionsModule } from '../channel-connections/channel-connections.module'; +import { ChannelEndpointsModule } from '../channel-endpoints/channel-endpoints.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { OrganizationModule } from '../organization/organization.module'; import { OutboundWebhooksModule } from '../outbound-webhooks/outbound-webhooks.module'; @@ -29,6 +31,8 @@ import { USE_CASES } from './usecases'; SubscribersV1Module, AuthModule, IntegrationModule, + ChannelConnectionsModule, + ChannelEndpointsModule, PreferencesModule, OrganizationModule, OutboundWebhooksModule.forRoot(), diff --git a/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts b/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts index 078979864b5..415c1740fa0 100644 --- a/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts +++ b/apps/api/src/app/integrations/dtos/generate-chat-oauth-url.dto.ts @@ -1,8 +1,12 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic'; -import { ContextPayload } from '@novu/shared'; -import { IsArray, IsDefined, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { SLACK_DEFAULT_OAUTH_SCOPES } from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; +import { IsArray, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { + OAuthMode, + SLACK_DEFAULT_OAUTH_SCOPES, + SLACK_LINK_USER_OAUTH_SCOPES, +} from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase'; export class GenerateChatOauthUrlRequestDto { @ApiProperty({ @@ -65,4 +69,48 @@ export class GenerateChatOauthUrlRequestDto { @IsArray() @IsString({ each: true }) scope?: string[]; + + @ApiPropertyOptional({ + type: [String], + description: + `**Slack only, link_user mode**: User-level OAuth scopes to request during authorization. ` + + `Used when mode is "link_user" to identify the Slack user via "Sign in with Slack". ` + + `If not specified, defaults to: ${SLACK_LINK_USER_OAUTH_SCOPES.join(', ')}.`, + example: ['identity.basic'], + required: false, + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + userScope?: string[]; + + @ApiPropertyOptional({ + type: String, + description: + 'OAuth flow mode. Use "connect" (default) to create a workspace channel connection, ' + + 'or "link_user" to identify the subscriber\'s Slack user ID without creating a connection.', + enum: ['connect', 'link_user'], + example: 'link_user', + required: false, + }) + @IsOptional() + @IsString() + @IsIn(['connect', 'link_user']) + mode?: OAuthMode; + + @ApiPropertyOptional({ + type: String, + description: + 'Connection mode that determines how the channel connection is scoped. ' + + 'Use "subscriber" (default) to associate the connection with a specific subscriber. ' + + 'Use "shared" to associate the connection with a context instead of a subscriber — ' + + 'subscriberId will not be stored on the connection.', + enum: ['subscriber', 'shared'], + example: 'shared', + required: false, + }) + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + connectionMode?: ConnectionMode; } diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts index f79a9162b33..b60b2404190 100644 --- a/apps/api/src/app/integrations/integrations.controller.ts +++ b/apps/api/src/app/integrations/integrations.controller.ts @@ -432,6 +432,9 @@ export class IntegrationsController { connectionIdentifier: body.connectionIdentifier, context: body.context, scope: body.scope, + userScope: body.userScope, + mode: body.mode, + connectionMode: body.connectionMode, }) ); diff --git a/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts b/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts index f24e2016a0a..9fb536bb5e9 100644 --- a/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts +++ b/apps/api/src/app/integrations/usecases/chat-oauth-callback/slack-oauth-callback/slack-oauth-callback.usecase.ts @@ -43,9 +43,10 @@ export class SlackOauthCallback { const credentials = await this.getIntegrationCredentials(integration); const authData = await this.exchangeCodeForAuthData(command.providerCode, credentials); - const isIncomingWebhook = authData.incoming_webhook; - if (isIncomingWebhook) { + if (stateData.mode === 'link_user') { + await this.linkUserEndpoint(stateData, integration, authData); + } else if (authData.incoming_webhook) { /* * Incoming webhooks are handled differently from workspace connections: * @@ -60,14 +61,16 @@ export class SlackOauthCallback { */ await this.createIncomingWebhookEndpoint(stateData, integration, authData); } else { - await this.createChannelConnection.execute( + const isSharedMode = stateData.connectionMode === 'shared'; + const connection = await this.createChannelConnection.execute( CreateChannelConnectionCommand.create({ identifier: stateData.identifier, organizationId: stateData.organizationId, environmentId: stateData.environmentId, integrationIdentifier: integration.identifier, - subscriberId: stateData.subscriberId, + subscriberId: isSharedMode ? undefined : stateData.subscriberId, context: stateData.context, + connectionMode: stateData.connectionMode, auth: { accessToken: authData.access_token, }, @@ -77,6 +80,21 @@ export class SlackOauthCallback { }, }) ); + + if (stateData.subscriberId && authData.authed_user?.id) { + await this.createChannelEndpoint.execute( + CreateChannelEndpointCommand.create({ + organizationId: stateData.organizationId, + environmentId: stateData.environmentId, + integrationIdentifier: integration.identifier, + connectionIdentifier: connection.identifier, + subscriberId: stateData.subscriberId, + context: stateData.context, + type: ENDPOINT_TYPES.SLACK_USER, + endpoint: { userId: authData.authed_user.id }, + }) + ); + } } if (credentials.redirectUrl) { @@ -89,6 +107,31 @@ export class SlackOauthCallback { }; } + private async linkUserEndpoint(stateData: StateData, integration: IntegrationEntity, authData: any): Promise { + if (!stateData.subscriberId) { + throw new BadRequestException('subscriberId is required for link_user mode'); + } + + const userId = authData.authed_user?.id; + + if (!userId) { + throw new BadRequestException('Slack did not return a user ID in the OAuth response'); + } + + await this.createChannelEndpoint.execute( + CreateChannelEndpointCommand.create({ + organizationId: stateData.organizationId, + environmentId: stateData.environmentId, + integrationIdentifier: integration.identifier, + connectionIdentifier: stateData.identifier, + subscriberId: stateData.subscriberId, + context: stateData.context, + type: ENDPOINT_TYPES.SLACK_USER, + endpoint: { userId }, + }) + ); + } + private async createIncomingWebhookEndpoint( stateData: StateData, integration: IntegrationEntity, diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts index b09ead97c6c..97b2837f7d4 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command.ts @@ -1,7 +1,8 @@ import { IsValidContextPayload } from '@novu/application-generic'; -import { ContextPayload } from '@novu/shared'; -import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; +import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; +import type { OAuthMode } from './generate-slack-oath-url/generate-slack-oauth-url.usecase'; export class GenerateChatOauthUrlCommand extends EnvironmentCommand { @IsNotEmpty() @@ -24,4 +25,19 @@ export class GenerateChatOauthUrlCommand extends EnvironmentCommand { @IsArray() @IsString({ each: true }) readonly scope?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly userScope?: string[]; + + @IsOptional() + @IsString() + @IsIn(['connect', 'link_user']) + readonly mode?: OAuthMode; + + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + readonly connectionMode?: ConnectionMode; } diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts index 42411761dcf..a419dbdc68b 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase.ts @@ -30,6 +30,9 @@ export class GenerateChatOauthUrl { integration, context: command.context, scope: command.scope, + userScope: command.userScope, + mode: command.mode, + connectionMode: command.connectionMode, }) ); diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts index ef954ba1759..7dc74a166fd 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.command.ts @@ -1,8 +1,9 @@ import { IsValidContextPayload } from '@novu/application-generic'; import { IntegrationEntity } from '@novu/dal'; -import { ContextPayload } from '@novu/shared'; -import { IsArray, IsOptional, IsString } from 'class-validator'; +import { ConnectionMode, ContextPayload } from '@novu/shared'; +import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../../shared/commands/project.command'; +import { OAuthMode } from './generate-slack-oauth-url.usecase'; export class GenerateSlackOauthUrlCommand extends EnvironmentCommand { @IsOptional() @@ -23,4 +24,19 @@ export class GenerateSlackOauthUrlCommand extends EnvironmentCommand { @IsArray() @IsString({ each: true }) readonly scope?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly userScope?: string[]; + + @IsOptional() + @IsString() + @IsIn(['connect', 'link_user']) + readonly mode?: OAuthMode; + + @IsOptional() + @IsString() + @IsIn(['subscriber', 'shared']) + readonly connectionMode?: ConnectionMode; } diff --git a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts index df5d2ccf72a..7bdeb6d7663 100644 --- a/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts +++ b/apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts @@ -1,10 +1,13 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { createHash, GetNovuProviderCredentials, GetNovuProviderCredentialsCommand } from '@novu/application-generic'; import { EnvironmentRepository, ICredentialsEntity, IntegrationEntity, SubscriberRepository } from '@novu/dal'; -import { ChatProviderIdEnum, ContextPayload } from '@novu/shared'; +import { ChatProviderIdEnum, ConnectionMode, ContextPayload } from '@novu/shared'; +import { validateConnectionMode } from '../../../../channel-connections/usecases/channel-connection.utils'; import { CHAT_OAUTH_CALLBACK_PATH } from '../chat-oauth.constants'; import { GenerateSlackOauthUrlCommand } from './generate-slack-oauth-url.command'; +export type OAuthMode = 'connect' | 'link_user'; + export type StateData = { identifier?: string; subscriberId?: string; @@ -14,6 +17,8 @@ export type StateData = { integrationIdentifier: string; providerId: ChatProviderIdEnum; timestamp: number; + mode?: OAuthMode; + connectionMode?: ConnectionMode; }; export const SLACK_DEFAULT_OAUTH_SCOPES = [ @@ -25,6 +30,8 @@ export const SLACK_DEFAULT_OAUTH_SCOPES = [ 'users:read.email', ] as const; +export const SLACK_LINK_USER_OAUTH_SCOPES = ['identity.basic'] as const; + @Injectable() export class GenerateSlackOauthUrl { private readonly SLACK_OAUTH_URL = 'https://slack.com/oauth/v2/authorize?'; @@ -44,14 +51,16 @@ export class GenerateSlackOauthUrl { command.integration, command.subscriberId, command.context, - command.connectionIdentifier + command.connectionIdentifier, + command.mode, + command.connectionMode ); - return this.getOAuthUrl(clientId!, secureState, command.scope); + return this.getOAuthUrl(clientId!, secureState, command.scope, command.userScope, command.mode); } private validateSubscriberIdOrContext(command: GenerateSlackOauthUrlCommand): void { - const { subscriberId, context, scope } = command; + const { subscriberId, scope, connectionMode, context } = command; if (scope?.includes('incoming-webhook')) { if (!subscriberId) { @@ -59,9 +68,7 @@ export class GenerateSlackOauthUrl { } } - if (!subscriberId && !context) { - throw new BadRequestException('Either subscriberId or context must be provided'); - } + validateConnectionMode({ connectionMode, subscriberId, context }); } private async assertResourceExists(command: GenerateSlackOauthUrlCommand) { @@ -82,14 +89,26 @@ export class GenerateSlackOauthUrl { return; } - private async getOAuthUrl(clientId: string, secureState: string, scope?: string[]): Promise { + private async getOAuthUrl( + clientId: string, + secureState: string, + scope?: string[], + userScope?: string[], + mode?: OAuthMode + ): Promise { + const isLinkUser = mode === 'link_user'; const oauthParams = new URLSearchParams({ state: secureState, client_id: clientId, - scope: scope?.join(',') ?? SLACK_DEFAULT_OAUTH_SCOPES.join(','), redirect_uri: GenerateSlackOauthUrl.buildRedirectUri(), }); + if (isLinkUser) { + oauthParams.set('user_scope', userScope?.join(',') ?? SLACK_LINK_USER_OAUTH_SCOPES.join(',')); + } else { + oauthParams.set('scope', scope?.join(',') ?? SLACK_DEFAULT_OAUTH_SCOPES.join(',')); + } + return `${this.SLACK_OAUTH_URL}${oauthParams.toString()}`; } @@ -97,7 +116,9 @@ export class GenerateSlackOauthUrl { integration: IntegrationEntity, subscriberId?: string, context?: ContextPayload, - connectionIdentifier?: string + connectionIdentifier?: string, + mode?: OAuthMode, + connectionMode?: ConnectionMode ): Promise { const { _environmentId, _organizationId, identifier, providerId } = integration; @@ -110,6 +131,8 @@ export class GenerateSlackOauthUrl { integrationIdentifier: identifier, providerId: providerId as ChatProviderIdEnum, timestamp: Date.now(), + mode, + connectionMode, }; const payload = JSON.stringify(stateData); diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index b6941c04c47..4a4b9d0d960 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -15,7 +15,7 @@ const modules = [ { name: 'UMD minified', filePath: umdPath, - limitInBytes: 210_000, + limitInBytes: 213_000, }, { name: 'UMD gzip', diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index bd6e85f8599..52e83a17d6e 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -1,4 +1,13 @@ import type { RulesLogic } from 'json-logic-js'; +import type { + ChannelConnectionResponse, + ChannelEndpointResponse, + CreateChannelConnectionArgs, + CreateChannelEndpointArgs, + GenerateChatOAuthUrlArgs, + ListChannelConnectionsArgs, + ListChannelEndpointsArgs, +} from '../channel-connections/types'; import type { PreferenceFilter } from '../subscriptions/types'; import type { ActionTypeEnum, @@ -23,6 +32,44 @@ export type InboxServiceOptions = HttpClientOptions; const INBOX_ROUTE = '/inbox'; const INBOX_NOTIFICATIONS_ROUTE = `${INBOX_ROUTE}/notifications`; +const CHAT_OAUTH_ROUTE = `${INBOX_ROUTE}/chat/oauth`; +const CHANNEL_CONNECTIONS_ROUTE = `${INBOX_ROUTE}/channel-connections`; +const CHANNEL_ENDPOINTS_ROUTE = `${INBOX_ROUTE}/channel-endpoints`; + +type ChannelListBaseArgs = { + subscriberId?: string; + integrationIdentifier?: string; + connectionIdentifier?: string; + channel?: string; + providerId?: string; + contextKeys?: string[]; + limit?: number; + after?: string; + before?: string; +}; + +function buildChannelListSearchParams(args: ChannelListBaseArgs): string { + const searchParams = new URLSearchParams(); + if (args.subscriberId) searchParams.append('subscriberId', args.subscriberId); + if (args.integrationIdentifier) searchParams.append('integrationIdentifier', args.integrationIdentifier); + if (args.connectionIdentifier) searchParams.append('connectionIdentifier', args.connectionIdentifier); + if (args.channel) searchParams.append('channel', args.channel); + if (args.providerId) searchParams.append('providerId', args.providerId); + if (args.contextKeys !== undefined) { + if (args.contextKeys.length === 0) { + searchParams.append('contextKeys', ''); + } else { + for (const key of args.contextKeys) { + searchParams.append('contextKeys', key); + } + } + } + if (args.limit) searchParams.append('limit', String(args.limit)); + if (args.after) searchParams.append('after', args.after); + if (args.before) searchParams.append('before', args.before); + + return searchParams.size ? `?${searchParams.toString()}` : ''; +} function appendTagsToSearchParams(searchParams: URLSearchParams, tags: TagsFilter | undefined): void { if (tags === undefined) { @@ -494,4 +541,100 @@ export class InboxService { deleteSubscription({ topicKey, identifier }: { topicKey: string; identifier: string }): Promise { return this.#httpClient.delete(`${INBOX_ROUTE}/topics/${topicKey}/subscriptions/${identifier}`); } + + generateChatOAuthUrl({ + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + userScope, + mode, + connectionMode, + }: GenerateChatOAuthUrlArgs): Promise<{ url: string }> { + return this.#httpClient.post(CHAT_OAUTH_ROUTE, { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + userScope, + mode, + connectionMode, + }); + } + + listChannelConnections(args: ListChannelConnectionsArgs = {}): Promise<{ + data: ChannelConnectionResponse[]; + next?: string; + previous?: string; + }> { + const query = buildChannelListSearchParams(args); + + return this.#httpClient.get(`${CHANNEL_CONNECTIONS_ROUTE}${query}`, undefined, false); + } + + getChannelConnection(identifier: string): Promise { + return this.#httpClient.get(`${CHANNEL_CONNECTIONS_ROUTE}/${identifier}`); + } + + createChannelConnection({ + identifier, + integrationIdentifier, + subscriberId, + context, + workspace, + auth, + }: CreateChannelConnectionArgs): Promise { + return this.#httpClient.post(CHANNEL_CONNECTIONS_ROUTE, { + identifier, + integrationIdentifier, + subscriberId, + context, + workspace, + auth, + }); + } + + deleteChannelConnection(identifier: string): Promise { + return this.#httpClient.delete(`${CHANNEL_CONNECTIONS_ROUTE}/${identifier}`); + } + + listChannelEndpoints(args: ListChannelEndpointsArgs = {}): Promise<{ + data: ChannelEndpointResponse[]; + next?: string; + previous?: string; + }> { + const query = buildChannelListSearchParams(args); + + return this.#httpClient.get(`${CHANNEL_ENDPOINTS_ROUTE}${query}`, undefined, false); + } + + getChannelEndpoint(identifier: string): Promise { + return this.#httpClient.get(`${CHANNEL_ENDPOINTS_ROUTE}/${identifier}`); + } + + createChannelEndpoint({ + identifier, + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + type, + endpoint, + }: CreateChannelEndpointArgs): Promise { + return this.#httpClient.post(CHANNEL_ENDPOINTS_ROUTE, { + identifier, + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + type, + endpoint, + }); + } + + deleteChannelEndpoint(identifier: string): Promise { + return this.#httpClient.delete(`${CHANNEL_ENDPOINTS_ROUTE}/${identifier}`); + } } diff --git a/packages/js/src/channel-connections/channel-connections.ts b/packages/js/src/channel-connections/channel-connections.ts new file mode 100644 index 00000000000..1b0bf3476d7 --- /dev/null +++ b/packages/js/src/channel-connections/channel-connections.ts @@ -0,0 +1,64 @@ +import { InboxService } from '../api'; +import { BaseModule } from '../base-module'; +import { NovuEventEmitter } from '../event-emitter'; +import type { Result } from '../types'; +import { deleteChannelConnection, generateChatOAuthUrl, getChannelConnection, listChannelConnections } from './helpers'; +import type { + ChannelConnectionResponse, + DeleteChannelConnectionArgs, + GenerateChatOAuthUrlArgs, + GetChannelConnectionArgs, + ListChannelConnectionsArgs, +} from './types'; + +export class ChannelConnections extends BaseModule { + constructor({ + inboxServiceInstance, + eventEmitterInstance, + }: { + inboxServiceInstance: InboxService; + eventEmitterInstance: NovuEventEmitter; + }) { + super({ inboxServiceInstance, eventEmitterInstance }); + } + + async generateOAuthUrl(args: GenerateChatOAuthUrlArgs): Result<{ url: string }> { + return this.callWithSession(() => + generateChatOAuthUrl({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + + async list(args: ListChannelConnectionsArgs = {}): Result { + return this.callWithSession(() => + listChannelConnections({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + + async get(args: GetChannelConnectionArgs): Result { + return this.callWithSession(() => + getChannelConnection({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + + async delete(args: DeleteChannelConnectionArgs): Result { + return this.callWithSession(() => + deleteChannelConnection({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } +} diff --git a/packages/js/src/channel-connections/helpers.ts b/packages/js/src/channel-connections/helpers.ts new file mode 100644 index 00000000000..c030d4ef652 --- /dev/null +++ b/packages/js/src/channel-connections/helpers.ts @@ -0,0 +1,100 @@ +import type { InboxService } from '../api'; +import type { NovuEventEmitter } from '../event-emitter'; +import type { Result } from '../types'; +import { NovuError } from '../utils/errors'; +import type { + ChannelConnectionResponse, + DeleteChannelConnectionArgs, + GenerateChatOAuthUrlArgs, + GetChannelConnectionArgs, + ListChannelConnectionsArgs, +} from './types'; + +export const generateChatOAuthUrl = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: GenerateChatOAuthUrlArgs; +}): Result<{ url: string }> => { + try { + emitter.emit('channel-connection.oauth-url.pending', { args }); + const data = await apiService.generateChatOAuthUrl(args); + emitter.emit('channel-connection.oauth-url.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-connection.oauth-url.resolved', { args, error }); + + return { error: new NovuError('Failed to generate chat OAuth URL', error) }; + } +}; + +export const listChannelConnections = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: ListChannelConnectionsArgs; +}): Result => { + try { + emitter.emit('channel-connections.list.pending', { args }); + const response = await apiService.listChannelConnections(args); + const data = response.data; + emitter.emit('channel-connections.list.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-connections.list.resolved', { args, error }); + + return { error: new NovuError('Failed to list channel connections', error) }; + } +}; + +export const getChannelConnection = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: GetChannelConnectionArgs; +}): Result => { + try { + emitter.emit('channel-connection.get.pending', { args }); + const data = await apiService.getChannelConnection(args.identifier); + emitter.emit('channel-connection.get.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-connection.get.resolved', { args, error }); + + return { error: new NovuError('Failed to get channel connection', error) }; + } +}; + +export const deleteChannelConnection = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: DeleteChannelConnectionArgs; +}): Result => { + try { + emitter.emit('channel-connection.delete.pending', { args }); + await apiService.deleteChannelConnection(args.identifier); + emitter.emit('channel-connection.delete.resolved', { args }); + + return { data: undefined }; + } catch (error) { + emitter.emit('channel-connection.delete.resolved', { args, error }); + + return { error: new NovuError('Failed to delete channel connection', error) }; + } +}; diff --git a/packages/js/src/channel-connections/index.ts b/packages/js/src/channel-connections/index.ts new file mode 100644 index 00000000000..dc4f76decac --- /dev/null +++ b/packages/js/src/channel-connections/index.ts @@ -0,0 +1,2 @@ +export { ChannelConnections } from './channel-connections'; +export * from './types'; diff --git a/packages/js/src/channel-connections/types.ts b/packages/js/src/channel-connections/types.ts new file mode 100644 index 00000000000..e4b89bd16a0 --- /dev/null +++ b/packages/js/src/channel-connections/types.ts @@ -0,0 +1,100 @@ +import type { Context } from '../types'; + +export type ChannelConnectionResponse = { + identifier: string; + integrationIdentifier: string; + providerId: string; + channel: string; + subscriberId?: string; + contextKeys: string[]; + workspace: { id: string; name?: string }; + createdAt: string; + updatedAt: string; +}; + +export type ChannelEndpointResponse = { + identifier: string; + integrationIdentifier: string; + connectionIdentifier?: string; + providerId: string; + channel: string; + subscriberId: string; + contextKeys: string[]; + type: string; + endpoint: Record; + createdAt: string; + updatedAt: string; +}; + +export type OAuthMode = 'connect' | 'link_user'; + +export type ConnectionMode = 'subscriber' | 'shared'; + +export type GenerateChatOAuthUrlArgs = { + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + scope?: string[]; + userScope?: string[]; + mode?: OAuthMode; + connectionMode?: ConnectionMode; +}; + +export type ListChannelConnectionsArgs = { + subscriberId?: string; + integrationIdentifier?: string; + channel?: string; + providerId?: string; + contextKeys?: string[]; + limit?: number; + after?: string; + before?: string; +}; + +export type GetChannelConnectionArgs = { + identifier: string; +}; + +export type CreateChannelConnectionArgs = { + identifier?: string; + integrationIdentifier: string; + subscriberId?: string; + context?: Context; + workspace: { id: string; name?: string }; + auth: { accessToken: string }; +}; + +export type DeleteChannelConnectionArgs = { + identifier: string; +}; + +export type ListChannelEndpointsArgs = { + subscriberId?: string; + integrationIdentifier?: string; + connectionIdentifier?: string; + channel?: string; + providerId?: string; + contextKeys?: string[]; + limit?: number; + after?: string; + before?: string; +}; + +export type GetChannelEndpointArgs = { + identifier: string; +}; + +export type CreateChannelEndpointArgs = { + identifier?: string; + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId: string; + context?: Context; + type: string; + endpoint: Record; +}; + +export type DeleteChannelEndpointArgs = { + identifier: string; +}; diff --git a/packages/js/src/channel-endpoints/channel-endpoints.ts b/packages/js/src/channel-endpoints/channel-endpoints.ts new file mode 100644 index 00000000000..c0ed21a5b00 --- /dev/null +++ b/packages/js/src/channel-endpoints/channel-endpoints.ts @@ -0,0 +1,64 @@ +import { InboxService } from '../api'; +import { BaseModule } from '../base-module'; +import type { + ChannelEndpointResponse, + CreateChannelEndpointArgs, + DeleteChannelEndpointArgs, + GetChannelEndpointArgs, + ListChannelEndpointsArgs, +} from '../channel-connections/types'; +import { NovuEventEmitter } from '../event-emitter'; +import type { Result } from '../types'; +import { createChannelEndpoint, deleteChannelEndpoint, getChannelEndpoint, listChannelEndpoints } from './helpers'; + +export class ChannelEndpoints extends BaseModule { + constructor({ + inboxServiceInstance, + eventEmitterInstance, + }: { + inboxServiceInstance: InboxService; + eventEmitterInstance: NovuEventEmitter; + }) { + super({ inboxServiceInstance, eventEmitterInstance }); + } + + async list(args: ListChannelEndpointsArgs = {}): Result { + return this.callWithSession(() => + listChannelEndpoints({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + + async get(args: GetChannelEndpointArgs): Result { + return this.callWithSession(() => + getChannelEndpoint({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + + async create(args: CreateChannelEndpointArgs): Result { + return this.callWithSession(() => + createChannelEndpoint({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } + + async delete(args: DeleteChannelEndpointArgs): Result { + return this.callWithSession(() => + deleteChannelEndpoint({ + emitter: this._emitter, + apiService: this._inboxService, + args, + }) + ); + } +} diff --git a/packages/js/src/channel-endpoints/helpers.ts b/packages/js/src/channel-endpoints/helpers.ts new file mode 100644 index 00000000000..cabbb3305ee --- /dev/null +++ b/packages/js/src/channel-endpoints/helpers.ts @@ -0,0 +1,100 @@ +import type { InboxService } from '../api'; +import type { + ChannelEndpointResponse, + CreateChannelEndpointArgs, + DeleteChannelEndpointArgs, + GetChannelEndpointArgs, + ListChannelEndpointsArgs, +} from '../channel-connections/types'; +import type { NovuEventEmitter } from '../event-emitter'; +import type { Result } from '../types'; +import { NovuError } from '../utils/errors'; + +export const listChannelEndpoints = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: ListChannelEndpointsArgs; +}): Result => { + try { + emitter.emit('channel-endpoints.list.pending', { args }); + const response = await apiService.listChannelEndpoints(args); + const data = response.data; + emitter.emit('channel-endpoints.list.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-endpoints.list.resolved', { args, error }); + + return { error: new NovuError('Failed to list channel endpoints', error) }; + } +}; + +export const getChannelEndpoint = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: GetChannelEndpointArgs; +}): Result => { + try { + emitter.emit('channel-endpoint.get.pending', { args }); + const data = await apiService.getChannelEndpoint(args.identifier); + emitter.emit('channel-endpoint.get.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-endpoint.get.resolved', { args, error }); + + return { error: new NovuError('Failed to get channel endpoint', error) }; + } +}; + +export const createChannelEndpoint = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: CreateChannelEndpointArgs; +}): Result => { + try { + emitter.emit('channel-endpoint.create.pending', { args }); + const data = await apiService.createChannelEndpoint(args); + emitter.emit('channel-endpoint.create.resolved', { args, data }); + + return { data }; + } catch (error) { + emitter.emit('channel-endpoint.create.resolved', { args, error }); + + return { error: new NovuError('Failed to create channel endpoint', error) }; + } +}; + +export const deleteChannelEndpoint = async ({ + emitter, + apiService, + args, +}: { + emitter: NovuEventEmitter; + apiService: InboxService; + args: DeleteChannelEndpointArgs; +}): Result => { + try { + emitter.emit('channel-endpoint.delete.pending', { args }); + await apiService.deleteChannelEndpoint(args.identifier); + emitter.emit('channel-endpoint.delete.resolved', { args }); + + return { data: undefined }; + } catch (error) { + emitter.emit('channel-endpoint.delete.resolved', { args, error }); + + return { error: new NovuError('Failed to delete channel endpoint', error) }; + } +}; diff --git a/packages/js/src/channel-endpoints/index.ts b/packages/js/src/channel-endpoints/index.ts new file mode 100644 index 00000000000..d88752a2591 --- /dev/null +++ b/packages/js/src/channel-endpoints/index.ts @@ -0,0 +1 @@ +export { ChannelEndpoints } from './channel-endpoints'; diff --git a/packages/js/src/event-emitter/types.ts b/packages/js/src/event-emitter/types.ts index 7be3de74c64..e5eaf6358d6 100644 --- a/packages/js/src/event-emitter/types.ts +++ b/packages/js/src/event-emitter/types.ts @@ -1,3 +1,15 @@ +import type { + ChannelConnectionResponse, + ChannelEndpointResponse, + CreateChannelEndpointArgs, + DeleteChannelConnectionArgs, + DeleteChannelEndpointArgs, + GenerateChatOAuthUrlArgs, + GetChannelConnectionArgs, + GetChannelEndpointArgs, + ListChannelConnectionsArgs, + ListChannelEndpointsArgs, +} from '../channel-connections/types'; import type { ArchivedArgs, CompleteArgs, @@ -110,6 +122,41 @@ type SubscriptionPreferencesBulkUpdateEvents = BaseEvents< SubscriptionPreference[] >; type SubscriptionDeleteEvents = BaseEvents<'subscription.delete', DeleteSubscriptionArgs, void>; + +type ChannelConnectionOAuthUrlEvents = BaseEvents< + 'channel-connection.oauth-url', + GenerateChatOAuthUrlArgs, + { url: string } +>; +type ChannelConnectionsFetchEvents = BaseEvents< + 'channel-connections.list', + ListChannelConnectionsArgs, + ChannelConnectionResponse[] +>; +type ChannelConnectionGetEvents = BaseEvents< + 'channel-connection.get', + GetChannelConnectionArgs, + ChannelConnectionResponse | null +>; +type ChannelConnectionDeleteEvents = BaseEvents<'channel-connection.delete', DeleteChannelConnectionArgs, void>; + +type ChannelEndpointsFetchEvents = BaseEvents< + 'channel-endpoints.list', + ListChannelEndpointsArgs, + ChannelEndpointResponse[] +>; +type ChannelEndpointGetEvents = BaseEvents< + 'channel-endpoint.get', + GetChannelEndpointArgs, + ChannelEndpointResponse | null +>; +type ChannelEndpointCreateEvents = BaseEvents< + 'channel-endpoint.create', + CreateChannelEndpointArgs, + ChannelEndpointResponse +>; +type ChannelEndpointDeleteEvents = BaseEvents<'channel-endpoint.delete', DeleteChannelEndpointArgs, void>; + type SocketConnectEvents = BaseEvents<'socket.connect', { socketUrl: string }, undefined>; export type NotificationReceivedEvent = `notifications.${WebSocketEvent.RECEIVED}`; export type NotificationUnseenEvent = `notifications.${WebSocketEvent.UNSEEN}`; @@ -153,7 +200,15 @@ export type Events = SessionInitializeEvents & SubscriptionPreferencesBulkUpdateEvents & SubscriptionDeleteEvents & { 'subscriptions.list.updated': { data: { topicKey: string; subscriptions: TopicSubscription[] } }; - } & SocketConnectEvents & + } & ChannelConnectionOAuthUrlEvents & + ChannelConnectionsFetchEvents & + ChannelConnectionGetEvents & + ChannelConnectionDeleteEvents & + ChannelEndpointsFetchEvents & + ChannelEndpointGetEvents & + ChannelEndpointCreateEvents & + ChannelEndpointDeleteEvents & + SocketConnectEvents & SocketEvents & NotificationReadEvents & NotificationUnreadEvents & diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 29860986bc3..47f7a449c78 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,4 +1,17 @@ export type * from 'json-logic-js'; +export type { + ChannelConnectionResponse, + ChannelEndpointResponse, + CreateChannelConnectionArgs, + CreateChannelEndpointArgs, + DeleteChannelConnectionArgs, + DeleteChannelEndpointArgs, + GenerateChatOAuthUrlArgs, + GetChannelConnectionArgs, + GetChannelEndpointArgs, + ListChannelConnectionsArgs, + ListChannelEndpointsArgs, +} from './channel-connections'; export type { EventHandler, Events, SocketEventNames } from './event-emitter'; export { Novu } from './novu'; export type { diff --git a/packages/js/src/novu.ts b/packages/js/src/novu.ts index 1b9ea993878..c0cb7501445 100644 --- a/packages/js/src/novu.ts +++ b/packages/js/src/novu.ts @@ -1,4 +1,6 @@ import { InboxService } from './api'; +import { ChannelConnections } from './channel-connections'; +import { ChannelEndpoints } from './channel-endpoints'; import type { EventHandler, EventNames, Events } from './event-emitter'; import { NovuEventEmitter } from './event-emitter'; import { Notifications } from './notifications'; @@ -19,6 +21,8 @@ export class Novu implements Pick { public readonly notifications: Notifications; public readonly preferences: Preferences; public readonly subscriptions: Subscriptions; + public readonly channelConnections: ChannelConnections; + public readonly channelEndpoints: ChannelEndpoints; public readonly socket: BaseSocketInterface; public on: (eventName: Key, listener: EventHandler) => () => void; @@ -87,6 +91,14 @@ export class Novu implements Pick { inboxServiceInstance: this.#inboxService, eventEmitterInstance: this.#emitter, }); + this.channelConnections = new ChannelConnections({ + inboxServiceInstance: this.#inboxService, + eventEmitterInstance: this.#emitter, + }); + this.channelEndpoints = new ChannelEndpoints({ + inboxServiceInstance: this.#inboxService, + eventEmitterInstance: this.#emitter, + }); this.socket = createSocket({ socketUrl: options.socketUrl, socketOptions: options.socketOptions, diff --git a/packages/js/src/ui/api/hooks/useChannelConnection.ts b/packages/js/src/ui/api/hooks/useChannelConnection.ts new file mode 100644 index 00000000000..6547007c1ef --- /dev/null +++ b/packages/js/src/ui/api/hooks/useChannelConnection.ts @@ -0,0 +1,113 @@ +import { createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js'; +import type { + ChannelConnectionResponse, + DeleteChannelConnectionArgs, + GenerateChatOAuthUrlArgs, + GetChannelConnectionArgs, +} from '../../../channel-connections/types'; +import { useNovu } from '../../context'; + +export type UseChannelConnectionOptions = { + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId?: string; +}; + +export const useChannelConnection = (options: UseChannelConnectionOptions) => { + const novuAccessor = useNovu(); + const [loading, setLoading] = createSignal(true); + + const [connection, { mutate, refetch }] = createResource(options, async ({ connectionIdentifier }) => { + try { + if (!connectionIdentifier) { + return null; + } + + const response = await novuAccessor().channelConnections.get({ + identifier: connectionIdentifier, + }); + + return response.data ?? null; + } catch { + return null; + } + }); + + const connect = async (args: GenerateChatOAuthUrlArgs) => { + setLoading(true); + const response = await novuAccessor().channelConnections.generateOAuthUrl(args); + setLoading(false); + + return response; + }; + + const disconnect = async (identifier: string) => { + setLoading(true); + const response = await novuAccessor().channelConnections.delete({ identifier }); + if (!response.error) { + mutate(null); + } + setLoading(false); + + return response; + }; + + onMount(() => { + const currentNovu = novuAccessor(); + + const cleanupGetPending = currentNovu.on( + 'channel-connection.get.pending', + ({ args }: { args: GetChannelConnectionArgs }) => { + if (!args || args.identifier !== options.connectionIdentifier) { + return; + } + setLoading(true); + } + ); + + const cleanupGetResolved = currentNovu.on( + 'channel-connection.get.resolved', + ({ args, data }: { args: GetChannelConnectionArgs; data?: ChannelConnectionResponse }) => { + if (!args || args.identifier !== options.connectionIdentifier) { + return; + } + mutate((data as ChannelConnectionResponse) ?? null); + setLoading(false); + } + ); + + const cleanupDeletePending = currentNovu.on( + 'channel-connection.delete.pending', + ({ args }: { args: DeleteChannelConnectionArgs }) => { + if (!args || args.identifier !== options.connectionIdentifier) { + return; + } + setLoading(true); + } + ); + + const cleanupDeleteResolved = currentNovu.on( + 'channel-connection.delete.resolved', + ({ args }: { args: DeleteChannelConnectionArgs }) => { + if (!args || args.identifier !== options.connectionIdentifier) { + return; + } + mutate(null); + setLoading(false); + } + ); + + onCleanup(() => { + cleanupGetPending(); + cleanupGetResolved(); + cleanupDeletePending(); + cleanupDeleteResolved(); + }); + }); + + createEffect(() => { + setLoading(connection.loading); + }); + + return { connection, loading, mutate, refetch, connect, disconnect }; +}; diff --git a/packages/js/src/ui/api/hooks/useChannelEndpoint.ts b/packages/js/src/ui/api/hooks/useChannelEndpoint.ts new file mode 100644 index 00000000000..1107e96284d --- /dev/null +++ b/packages/js/src/ui/api/hooks/useChannelEndpoint.ts @@ -0,0 +1,94 @@ +import { createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js'; +import type { ChannelEndpointResponse, CreateChannelEndpointArgs } from '../../../channel-connections/types'; +import { useNovu } from '../../context'; + +export type UseChannelEndpointOptions = { + endpointIdentifier?: string; + subscriberId?: string; + integrationIdentifier?: string; + connectionIdentifier?: string; +}; + +export const useChannelEndpoint = (options: UseChannelEndpointOptions) => { + const novuAccessor = useNovu(); + const [loading, setLoading] = createSignal(true); + + const [endpoint, { mutate, refetch }] = createResource(options, async ({ endpointIdentifier }) => { + try { + if (!endpointIdentifier) { + return null; + } + + const response = await novuAccessor().channelEndpoints.get({ + identifier: endpointIdentifier, + }); + + return response.data ?? null; + } catch { + return null; + } + }); + + const create = async (args: CreateChannelEndpointArgs) => { + setLoading(true); + const response = await novuAccessor().channelEndpoints.create(args); + if (response.data) { + mutate(response.data); + } + setLoading(false); + + return response; + }; + + const remove = async (identifier: string) => { + setLoading(true); + const response = await novuAccessor().channelEndpoints.delete({ identifier }); + if (!response.error) { + mutate(null); + } + setLoading(false); + + return response; + }; + + onMount(() => { + const currentNovu = novuAccessor(); + + const cleanupCreatePending = currentNovu.on('channel-endpoint.create.pending', () => { + setLoading(true); + }); + + const cleanupCreateResolved = currentNovu.on('channel-endpoint.create.resolved', ({ data }) => { + mutate((data as ChannelEndpointResponse) ?? null); + setLoading(false); + }); + + const cleanupDeletePending = currentNovu.on('channel-endpoint.delete.pending', ({ args }) => { + if (!args || args.identifier !== options.endpointIdentifier) { + return; + } + setLoading(true); + }); + + const cleanupDeleteResolved = currentNovu.on('channel-endpoint.delete.resolved', ({ args }) => { + if (!args || args.identifier !== options.endpointIdentifier) { + return; + } + mutate(null); + setLoading(false); + }); + + onCleanup(() => { + cleanupCreatePending(); + cleanupCreateResolved(); + cleanupDeletePending(); + cleanupDeleteResolved(); + }); + }); + + createEffect(() => { + setLoading(endpoint.loading); + }); + + return { endpoint, loading, mutate, refetch, create, remove }; +}; diff --git a/packages/js/src/ui/components/Renderer.tsx b/packages/js/src/ui/components/Renderer.tsx index bd0214c1923..fba2d7dd150 100644 --- a/packages/js/src/ui/components/Renderer.tsx +++ b/packages/js/src/ui/components/Renderer.tsx @@ -23,8 +23,11 @@ import type { RouterPush, Tab, } from '../types'; +import { ConnectChat } from './connect-chat/ConnectChat'; import { Bell, Root } from './elements'; import { Inbox, InboxContent, InboxContentProps, InboxPage } from './Inbox'; +import { SlackConnectButton } from './slack-connect-button/SlackConnectButton'; +import { SlackLinkUser } from './slack-link-user/SlackLinkUser'; import { Subscription } from './subscription/Subscription'; import { SubscriptionButtonWrapper as SubscriptionButton } from './subscription/SubscriptionButtonWrapper'; import { SubscriptionPreferencesWrapper as SubscriptionPreferences } from './subscription/SubscriptionPreferencesWrapper'; @@ -60,9 +63,13 @@ export const novuComponents = { Subscription, SubscriptionButton, SubscriptionPreferences, + ConnectChat, + SlackLinkUser, + SlackConnectButton, }; const SUBSCRIPTION_COMPONENTS = ['Subscription', 'SubscriptionButton', 'SubscriptionPreferences']; +const CHANNEL_COMPONENTS = ['ConnectChat', 'SlackLinkUser', 'SlackConnectButton']; export type NovuComponent = { name: NovuComponentName; props?: any }; @@ -124,7 +131,7 @@ const InboxComponentsRenderer = (props: { ); }; -const SubscriptionComponentsRenderer = (props: { +const SimpleComponentsRenderer = (props: { elements: MountableElement[]; nodes: Map; }) => { @@ -166,7 +173,7 @@ type RendererProps = { export const Renderer = (props: RendererProps) => { const inboxComponents = createMemo(() => [...props.nodes.entries()] - .filter(([_, node]) => !SUBSCRIPTION_COMPONENTS.includes(node.name)) + .filter(([_, node]) => !SUBSCRIPTION_COMPONENTS.includes(node.name) && !CHANNEL_COMPONENTS.includes(node.name)) .map(([element, _]) => element) ); const subscriptionComponents = createMemo(() => @@ -174,6 +181,11 @@ export const Renderer = (props: RendererProps) => { .filter(([_, node]) => SUBSCRIPTION_COMPONENTS.includes(node.name)) .map(([element, _]) => element) ); + const channelComponents = createMemo(() => + [...props.nodes.entries()] + .filter(([_, node]) => CHANNEL_COMPONENTS.includes(node.name)) + .map(([element, _]) => element) + ); onMount(() => { const id = NOVU_DEFAULT_CSS_ID; @@ -209,7 +221,8 @@ export const Renderer = (props: RendererProps) => { routerPush={props.routerPush} > - + + diff --git a/packages/js/src/ui/components/connect-chat/ConnectChat.tsx b/packages/js/src/ui/components/connect-chat/ConnectChat.tsx new file mode 100644 index 00000000000..db48ac039f1 --- /dev/null +++ b/packages/js/src/ui/components/connect-chat/ConnectChat.tsx @@ -0,0 +1,127 @@ +import type { ConnectionMode } from '../../../channel-connections/types'; +import type { Context } from '../../../types'; +import { useChannelConnection } from '../../api/hooks/useChannelConnection'; +import { useNovu } from '../../context'; +import { useStyle } from '../../helpers/useStyle'; +import { Loader } from '../../icons/Loader'; +import { Button, Motion } from '../primitives'; + +export type ConnectChatProps = { + integrationIdentifier: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + scope?: string[]; + connectionMode?: ConnectionMode; + onConnectSuccess?: (connectionIdentifier: string) => void; + onConnectError?: (error: unknown) => void; + onDisconnectSuccess?: () => void; + onDisconnectError?: (error: unknown) => void; +}; + +export const ConnectChat = (props: ConnectChatProps) => { + const style = useStyle(); + const novuAccessor = useNovu(); + const { connection, loading, connect, disconnect } = useChannelConnection({ + integrationIdentifier: props.integrationIdentifier, + connectionIdentifier: props.connectionIdentifier, + subscriberId: props.subscriberId, + }); + + const isConnected = () => !!connection(); + + const handleClick = async () => { + if (isConnected()) { + const identifier = connection()?.identifier; + if (!identifier) return; + + const result = await disconnect(identifier); + if (result.error) { + props.onDisconnectError?.(result.error); + } else { + props.onDisconnectSuccess?.(); + } + } else { + const connectionMode = props.connectionMode ?? 'subscriber'; + const resolvedContext = props.context; + const resolvedSubscriberId = + connectionMode === 'subscriber' ? (props.subscriberId ?? novuAccessor().subscriberId) : undefined; + + if (connectionMode === 'shared' && !resolvedContext) { + props.onConnectError?.( + new Error('context is required when connectionMode is "shared". Provide it on ConnectChat or NovuProvider.') + ); + + return; + } + + const result = await connect({ + integrationIdentifier: props.integrationIdentifier, + connectionIdentifier: props.connectionIdentifier, + subscriberId: resolvedSubscriberId, + context: resolvedContext, + scope: props.scope, + connectionMode, + }); + + if (result.error) { + props.onConnectError?.(result.error); + } else if (result.data?.url) { + window.open(result.data.url, '_blank', 'noopener,noreferrer'); + if (props.connectionIdentifier) { + props.onConnectSuccess?.(props.connectionIdentifier); + } + } + } + }; + + return ( +
+ +
+ ); +}; diff --git a/packages/js/src/ui/components/index.ts b/packages/js/src/ui/components/index.ts index f23261a4141..babdf5aef98 100644 --- a/packages/js/src/ui/components/index.ts +++ b/packages/js/src/ui/components/index.ts @@ -1,6 +1,9 @@ +export * from './connect-chat/ConnectChat'; export * from './elements'; export * from './Inbox'; export * from './primitives'; +export * from './slack-connect-button/SlackConnectButton'; +export * from './slack-link-user/SlackLinkUser'; export * from './subscription/Subscription'; export * from './subscription/SubscriptionButtonWrapper'; export * from './subscription/SubscriptionPreferencesWrapper'; diff --git a/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx b/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx new file mode 100644 index 00000000000..a89349ee46d --- /dev/null +++ b/packages/js/src/ui/components/slack-connect-button/SlackConnectButton.tsx @@ -0,0 +1,270 @@ +import { createSignal, onCleanup, Show } from 'solid-js'; +import type { ConnectionMode } from '../../../channel-connections/types'; +import type { Context } from '../../../types'; +import { useChannelConnection } from '../../api/hooks/useChannelConnection'; +import { useNovu } from '../../context'; +import { useStyle } from '../../helpers/useStyle'; +import { CheckCircleFill } from '../../icons/CheckCircleFill'; +import { Loader } from '../../icons/Loader'; +import { SlackColored } from '../../icons/SlackColored'; +import type { ChannelConnectButtonAppearanceCallback } from '../../types'; +import { Button, Motion } from '../primitives'; +import { IconRendererWrapper } from '../shared/IconRendererWrapper'; +import { DEFAULT_CONNECTION_IDENTIFIER, DEFAULT_INTEGRATION_IDENTIFIER } from '../slack-constants'; + +export type SlackConnectButtonProps = { + integrationIdentifier?: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + scope?: string[]; + connectionMode?: ConnectionMode; + onConnectSuccess?: (connectionIdentifier: string) => void; + onConnectError?: (error: unknown) => void; + onDisconnectSuccess?: () => void; + onDisconnectError?: (error: unknown) => void; + connectLabel?: string; + connectedLabel?: string; +}; + +const POLL_INTERVAL_MS = 2500; +const POLL_TIMEOUT_MS = 120_000; + +export const SlackConnectButton = (props: SlackConnectButtonProps) => { + const style = useStyle(); + const novuAccessor = useNovu(); + const integrationIdentifier = () => props.integrationIdentifier ?? DEFAULT_INTEGRATION_IDENTIFIER; + const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_CONNECTION_IDENTIFIER; + + const { connection, loading, connect, disconnect, mutate } = useChannelConnection({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: props.subscriberId, + }); + + const [actionLoading, setActionLoading] = createSignal(false); + + const isConnected = () => !!connection(); + const isLoading = () => loading() || actionLoading(); + + const intervalIdRef: { current: ReturnType | null } = { current: null }; + + onCleanup(() => { + if (intervalIdRef.current !== null) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + }); + + const startPolling = () => { + if (intervalIdRef.current !== null) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + + const startedAt = Date.now(); + + intervalIdRef.current = setInterval(async () => { + try { + const response = await novuAccessor().channelConnections.get({ + identifier: connectionIdentifier(), + }); + + if (response.data) { + if (intervalIdRef.current !== null) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + + setActionLoading(false); + mutate(response.data); + props.onConnectSuccess?.(connectionIdentifier()); + + return; + } + } catch { + // ignore transient errors during polling + } + + if (Date.now() - startedAt >= POLL_TIMEOUT_MS) { + if (intervalIdRef.current !== null) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + + setActionLoading(false); + props.onConnectError?.(new Error('Slack OAuth timed out. Please try again.')); + } + }, POLL_INTERVAL_MS); + }; + + const handleClick = async () => { + if (isConnected()) { + const identifier = connection()?.identifier; + if (!identifier) return; + + const result = await disconnect(identifier); + if (result.error) { + props.onDisconnectError?.(result.error); + } else { + props.onDisconnectSuccess?.(); + } + } else { + setActionLoading(true); + + const connectionMode = props.connectionMode ?? 'subscriber'; + const resolvedContext = props.context; + const resolvedSubscriberId = + connectionMode === 'subscriber' ? (props.subscriberId ?? novuAccessor().subscriberId) : undefined; + + if (connectionMode === 'shared' && !resolvedContext) { + setActionLoading(false); + props.onConnectError?.( + new Error( + 'context is required when connectionMode is "shared". Provide it on SlackConnectButton or NovuProvider.' + ) + ); + + return; + } + + const result = await connect({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: resolvedSubscriberId, + context: resolvedContext, + scope: props.scope, + connectionMode, + }); + + if (result.error) { + setActionLoading(false); + props.onConnectError?.(result.error); + + return; + } + + if (result.data?.url) { + window.open(result.data.url, '_blank', 'noopener,noreferrer'); + startPolling(); + } + } + }; + + return ( + }> +
[0], + })} + > + +
+
+ ); +}; diff --git a/packages/js/src/ui/components/slack-constants.ts b/packages/js/src/ui/components/slack-constants.ts new file mode 100644 index 00000000000..826597a2c34 --- /dev/null +++ b/packages/js/src/ui/components/slack-constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_CONNECTION_IDENTIFIER = 'chconn-slack-default'; +export const DEFAULT_INTEGRATION_IDENTIFIER = 'slack'; diff --git a/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx b/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx new file mode 100644 index 00000000000..879d3f35f73 --- /dev/null +++ b/packages/js/src/ui/components/slack-link-user/SlackLinkUser.tsx @@ -0,0 +1,275 @@ +import { createResource, createSignal, onCleanup, onMount, Show } from 'solid-js'; +import type { ChannelEndpointResponse } from '../../../channel-connections/types'; +import type { Context } from '../../../types'; +import { useNovu } from '../../context'; +import { useStyle } from '../../helpers/useStyle'; +import { CheckCircleFill } from '../../icons/CheckCircleFill'; +import { Loader } from '../../icons/Loader'; +import { SlackColored } from '../../icons/SlackColored'; +import type { SlackLinkUserAppearanceCallback } from '../../types'; +import { Button, Motion } from '../primitives'; +import { IconRendererWrapper } from '../shared/IconRendererWrapper'; +import { DEFAULT_CONNECTION_IDENTIFIER, DEFAULT_INTEGRATION_IDENTIFIER } from '../slack-constants'; + +export type SlackLinkUserProps = { + integrationIdentifier?: string; + connectionIdentifier?: string; + subscriberId?: string; + context?: Context; + onLinkSuccess?: (endpoint: { identifier: string }) => void; + onLinkError?: (error: unknown) => void; + onUnlinkSuccess?: () => void; + onUnlinkError?: (error: unknown) => void; + linkLabel?: string; + unlinkLabel?: string; +}; + +const POLL_INTERVAL_MS = 2500; +const POLL_TIMEOUT_MS = 120_000; + +export const SlackLinkUser = (props: SlackLinkUserProps) => { + const style = useStyle(); + const novuAccessor = useNovu(); + const integrationIdentifier = () => props.integrationIdentifier ?? DEFAULT_INTEGRATION_IDENTIFIER; + const connectionIdentifier = () => props.connectionIdentifier ?? DEFAULT_CONNECTION_IDENTIFIER; + + const [endpoint, setEndpoint] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [actionLoading, setActionLoading] = createSignal(false); + + let pollingIntervalId: ReturnType | undefined; + + onCleanup(() => { + clearInterval(pollingIntervalId); + }); + + const isLinked = () => !!endpoint(); + const isLoading = () => loading() || actionLoading(); + + createResource( + () => ({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + }), + async ({ integrationIdentifier: intId, connectionIdentifier: connId }) => { + setLoading(true); + + try { + const response = await novuAccessor().channelEndpoints.list({ + integrationIdentifier: intId, + connectionIdentifier: connId, + }); + const existing = response.data?.find((ep) => ep.type === 'slack_user') ?? null; + setEndpoint(existing); + } catch { + setEndpoint(null); + } finally { + setLoading(false); + } + } + ); + + onMount(() => { + const currentNovu = novuAccessor(); + + const cleanupDelete = currentNovu.on('channel-endpoint.delete.resolved', ({ args }) => { + if (args?.identifier && args.identifier === endpoint()?.identifier) { + setEndpoint(null); + } + }); + + onCleanup(() => { + cleanupDelete(); + }); + }); + + const startPolling = () => { + const startedAt = Date.now(); + + pollingIntervalId = setInterval(async () => { + try { + const response = await novuAccessor().channelEndpoints.list({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + }); + const found = response.data?.find((ep) => ep.type === 'slack_user') ?? null; + + if (found) { + clearInterval(pollingIntervalId); + setActionLoading(false); + setEndpoint(found); + props.onLinkSuccess?.({ identifier: found.identifier }); + + return; + } + } catch { + // ignore transient errors during polling + } + + if (Date.now() - startedAt >= POLL_TIMEOUT_MS) { + clearInterval(pollingIntervalId); + setActionLoading(false); + props.onLinkError?.(new Error('Slack OAuth timed out. Please try again.')); + } + }, POLL_INTERVAL_MS); + }; + + const handleClick = async () => { + if (isLinked()) { + const identifier = endpoint()?.identifier; + if (!identifier) return; + + setActionLoading(true); + const result = await novuAccessor().channelEndpoints.delete({ identifier }); + setActionLoading(false); + + if (result.error) { + props.onUnlinkError?.(result.error); + } else { + setEndpoint(null); + props.onUnlinkSuccess?.(); + } + } else { + setActionLoading(true); + + const result = await novuAccessor().channelConnections.generateOAuthUrl({ + integrationIdentifier: integrationIdentifier(), + connectionIdentifier: connectionIdentifier(), + subscriberId: props.subscriberId ?? novuAccessor().subscriberId, + context: props.context, + mode: 'link_user', + userScope: ['identity.basic'], + }); + + if (result.error) { + setActionLoading(false); + props.onLinkError?.(result.error); + + return; + } + + if (result.data?.url) { + window.open(result.data.url, '_blank', 'noopener,noreferrer'); + startPolling(); + } + } + }; + + return ( +
[0], + })} + > + +
+ ); +}; diff --git a/packages/js/src/ui/config/appearanceKeys.ts b/packages/js/src/ui/config/appearanceKeys.ts index 38908590f48..731fe267486 100644 --- a/packages/js/src/ui/config/appearanceKeys.ts +++ b/packages/js/src/ui/config/appearanceKeys.ts @@ -337,4 +337,34 @@ export const subscriptionAppearanceKeys = [ 'subscriptionPreferenceGroupWorkflowLabel', ] as const; -export const appearanceKeys = [...commonAppearanceKeys, ...inboxAppearanceKeys, ...subscriptionAppearanceKeys]; +export const connectChatAppearanceKeys = [ + 'connectChatContainer', + 'connectChatButton', + 'connectChatButtonContainer', + 'connectChatButtonLabel', +] as const; + +export const channelConnectButtonAppearanceKeys = [ + 'channelConnectButtonContainer', + 'channelConnectButton', + 'channelConnectButtonInner', + 'channelConnectButtonIcon', + 'channelConnectButtonLabel', +] as const; + +export const linkSlackUserAppearanceKeys = [ + 'linkSlackUserContainer', + 'linkSlackUserButton', + 'linkSlackUserButtonContainer', + 'linkSlackUserButtonIcon', + 'linkSlackUserButtonLabel', +] as const; + +export const appearanceKeys = [ + ...commonAppearanceKeys, + ...inboxAppearanceKeys, + ...subscriptionAppearanceKeys, + ...connectChatAppearanceKeys, + ...linkSlackUserAppearanceKeys, + ...channelConnectButtonAppearanceKeys, +]; diff --git a/packages/js/src/ui/icons/CheckCircleFill.tsx b/packages/js/src/ui/icons/CheckCircleFill.tsx new file mode 100644 index 00000000000..a789e75a800 --- /dev/null +++ b/packages/js/src/ui/icons/CheckCircleFill.tsx @@ -0,0 +1,16 @@ +import { JSX } from 'solid-js'; + +export const CheckCircleFill = (props?: JSX.HTMLAttributes) => { + return ( + + + + + ); +}; diff --git a/packages/js/src/ui/icons/SlackColored.tsx b/packages/js/src/ui/icons/SlackColored.tsx new file mode 100644 index 00000000000..ec4eea1fce1 --- /dev/null +++ b/packages/js/src/ui/icons/SlackColored.tsx @@ -0,0 +1,24 @@ +import { JSX } from 'solid-js'; + +export const SlackColored = (props?: JSX.HTMLAttributes) => { + return ( + + + + + + + ); +}; diff --git a/packages/js/src/ui/icons/index.ts b/packages/js/src/ui/icons/index.ts index caac9180ee1..43811ca30b7 100644 --- a/packages/js/src/ui/icons/index.ts +++ b/packages/js/src/ui/icons/index.ts @@ -6,6 +6,7 @@ export * from './Bell'; export * from './CalendarSchedule'; export * from './Chat'; export * from './Check'; +export * from './CheckCircleFill'; export * from './Clock'; export * from './Cogs'; export * from './Copy'; @@ -20,6 +21,7 @@ export * from './MarkAsUnarchived'; export * from './MarkAsUnread'; export * from './Novu'; export * from './Push'; +export * from './SlackColored'; export * from './Sms'; export * from './Unread'; export * from './Unsnooze'; diff --git a/packages/js/src/ui/index.ts b/packages/js/src/ui/index.ts index 391aa415feb..ebff5604089 100644 --- a/packages/js/src/ui/index.ts +++ b/packages/js/src/ui/index.ts @@ -1,7 +1,10 @@ export type { Notification } from '../notifications'; export type { + ConnectChatProps, InboxPage, InboxProps, + SlackConnectButtonProps, + SlackLinkUserProps, SubscriptionButtonWrapperProps, SubscriptionPreferencesWrapperProps, SubscriptionProps, @@ -21,6 +24,10 @@ export type { AllTheme, BellRenderer, BodyRenderer, + ChannelConnectButtonAppearanceCallback, + ChannelConnectButtonAppearanceCallbackFunction, + ChannelConnectButtonAppearanceCallbackKeys, + ChannelConnectButtonIconKey, ElementStyles, IconRenderer, InboxAppearance, @@ -43,6 +50,9 @@ export type { PreferencesFilter, PreferencesSort, RouterPush, + SlackLinkUserAppearanceCallback, + SlackLinkUserAppearanceCallbackFunction, + SlackLinkUserAppearanceCallbackKeys, SubjectRenderer, SubscriptionAppearance, SubscriptionAppearanceCallback, diff --git a/packages/js/src/ui/types.ts b/packages/js/src/ui/types.ts index 6135f974d72..c9075bbd903 100644 --- a/packages/js/src/ui/types.ts +++ b/packages/js/src/ui/types.ts @@ -4,7 +4,14 @@ import { Schedule } from '../preferences'; import type { Preference } from '../preferences/preference'; import { SubscriptionPreference, TopicSubscription } from '../subscriptions'; import { type NotificationFilter, type NovuOptions, type UnreadCount, WorkflowCriticalityEnum } from '../types'; -import { commonAppearanceKeys, inboxAppearanceKeys, subscriptionAppearanceKeys } from './config'; +import { + channelConnectButtonAppearanceKeys, + commonAppearanceKeys, + connectChatAppearanceKeys, + inboxAppearanceKeys, + linkSlackUserAppearanceKeys, + subscriptionAppearanceKeys, +} from './config'; import { AllLocalization } from './context/LocalizationContext'; export type NotificationClickHandler = (notification: Notification) => void; @@ -316,6 +323,33 @@ export type SubscriptionAppearanceCallbackKeys = keyof SubscriptionAppearanceCal export type SubscriptionAppearanceCallbackFunction = SubscriptionAppearanceCallback[K]; export type SubscriptionAppearanceKey = (typeof subscriptionAppearanceKeys)[number]; +export type ConnectChatAppearanceKey = (typeof connectChatAppearanceKeys)[number]; +export type SlackLinkUserAppearanceKey = (typeof linkSlackUserAppearanceKeys)[number]; +export type ChannelConnectButtonAppearanceKey = (typeof channelConnectButtonAppearanceKeys)[number]; + +// SLACK LINK USER APPEARANCE +export type SlackLinkUserAppearanceCallback = { + linkSlackUserContainer: (context: { linked: boolean }) => string; + linkSlackUserButton: (context: { linked: boolean }) => string; + linkSlackUserButtonContainer: (context: { linked: boolean }) => string; + linkSlackUserButtonIcon: (context: { linked: boolean }) => string; + linkSlackUserButtonLabel: (context: { linked: boolean }) => string; +}; +export type SlackLinkUserAppearanceCallbackKeys = keyof SlackLinkUserAppearanceCallback; +export type SlackLinkUserAppearanceCallbackFunction = + SlackLinkUserAppearanceCallback[K]; + +// CHANNEL CONNECT BUTTON APPEARANCE +export type ChannelConnectButtonAppearanceCallback = { + channelConnectButtonContainer: (context: { connected: boolean }) => string; + channelConnectButton: (context: { connected: boolean }) => string; + channelConnectButtonInner: (context: { connected: boolean }) => string; + channelConnectButtonIcon: (context: { connected: boolean }) => string; + channelConnectButtonLabel: (context: { connected: boolean }) => string; +}; +export type ChannelConnectButtonAppearanceCallbackKeys = keyof ChannelConnectButtonAppearanceCallback; +export type ChannelConnectButtonAppearanceCallbackFunction = + ChannelConnectButtonAppearanceCallback[K]; export type SubscriptionElements = Partial< { [K in CommonAppearanceKey]: ElementStyles } & { [K in Exclude]: ElementStyles; @@ -338,13 +372,27 @@ export type SubscriptionTheme = { export type SubscriptionAppearance = SubscriptionTheme & { baseTheme?: SubscriptionTheme | SubscriptionTheme[] }; // ALL APPEARANCE -export type AllAppearanceCallbackKeys = InboxAppearanceCallbackKeys | SubscriptionAppearanceCallbackKeys; +export type AllAppearanceCallbackKeys = + | InboxAppearanceCallbackKeys + | SubscriptionAppearanceCallbackKeys + | SlackLinkUserAppearanceCallbackKeys + | ChannelConnectButtonAppearanceCallbackKeys; export type AllAppearanceCallbackFunction = K extends InboxAppearanceCallbackKeys ? InboxAppearanceCallbackFunction : K extends SubscriptionAppearanceCallbackKeys ? SubscriptionAppearanceCallbackFunction - : never; -export type AllAppearanceKey = CommonAppearanceKey | InboxAppearanceKey | SubscriptionAppearanceKey; + : K extends SlackLinkUserAppearanceCallbackKeys + ? SlackLinkUserAppearanceCallbackFunction + : K extends ChannelConnectButtonAppearanceCallbackKeys + ? ChannelConnectButtonAppearanceCallbackFunction + : never; +export type AllAppearanceKey = + | CommonAppearanceKey + | InboxAppearanceKey + | SubscriptionAppearanceKey + | ConnectChatAppearanceKey + | SlackLinkUserAppearanceKey + | ChannelConnectButtonAppearanceKey; export type AllElements = Partial< { [K in CommonAppearanceKey]: ElementStyles; @@ -356,7 +404,8 @@ export type AllElements = Partial< [K in Extract]: ElementStyles | AllAppearanceCallbackFunction; } >; -export type AllIconKey = CommonIconKey | InboxIconKey | SubscriptionIconKey; +export type ChannelConnectButtonIconKey = 'channelConnect' | 'channelConnected'; +export type AllIconKey = CommonIconKey | InboxIconKey | SubscriptionIconKey | ChannelConnectButtonIconKey; export type AllIconOverrides = { [key in AllIconKey]?: IconRenderer; }; diff --git a/packages/nextjs/src/app-router/index.ts b/packages/nextjs/src/app-router/index.ts index b9d5737a701..33869f7edcf 100644 --- a/packages/nextjs/src/app-router/index.ts +++ b/packages/nextjs/src/app-router/index.ts @@ -10,8 +10,11 @@ export { PreferenceLevel, Preferences, SeverityLevelEnum, + SlackConnectButton, + SlackLinkUser, SubscriptionButton, SubscriptionPreferences, + useNovu, WorkflowCriticalityEnum, } from '@novu/react'; export { Inbox } from './Inbox'; diff --git a/packages/nextjs/src/pages-router/index.ts b/packages/nextjs/src/pages-router/index.ts index b9d5737a701..33869f7edcf 100644 --- a/packages/nextjs/src/pages-router/index.ts +++ b/packages/nextjs/src/pages-router/index.ts @@ -10,8 +10,11 @@ export { PreferenceLevel, Preferences, SeverityLevelEnum, + SlackConnectButton, + SlackLinkUser, SubscriptionButton, SubscriptionPreferences, + useNovu, WorkflowCriticalityEnum, } from '@novu/react'; export { Inbox } from './Inbox'; diff --git a/packages/react/src/components/NovuUI.tsx b/packages/react/src/components/NovuUI.tsx index 102b5288301..73480bb0299 100644 --- a/packages/react/src/components/NovuUI.tsx +++ b/packages/react/src/components/NovuUI.tsx @@ -6,11 +6,11 @@ import { NovuUIProvider } from '../context/NovuUIContext'; import { useRenderer } from '../context/RendererContext'; import { useDataRef } from '../hooks/internal/useDataRef'; import { adaptAppearanceForJs } from '../utils/appearance'; -import type { ReactInboxAppearance, ReactSubscriptionAppearance } from '../utils/types'; +import type { ReactAllAppearance, ReactInboxAppearance, ReactSubscriptionAppearance } from '../utils/types'; import { ShadowRootDetector } from './ShadowRootDetector'; export type NovuUIOptions = Omit & { - appearance?: ReactInboxAppearance | ReactSubscriptionAppearance; + appearance?: ReactInboxAppearance | ReactSubscriptionAppearance | ReactAllAppearance; }; type NovuUIProps = React.PropsWithChildren<{ diff --git a/packages/react/src/components/connect-chat/ConnectChat.tsx b/packages/react/src/components/connect-chat/ConnectChat.tsx new file mode 100644 index 00000000000..735adf13bd6 --- /dev/null +++ b/packages/react/src/components/connect-chat/ConnectChat.tsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import { useNovu } from '../../hooks/NovuProvider'; +import { NovuUI, NovuUIOptions } from '../NovuUI'; +import { withRenderer } from '../Renderer'; +import { DefaultConnectChat, DefaultConnectChatProps } from './DefaultConnectChat'; + +export type ConnectChatProps = DefaultConnectChatProps & Pick; + +const ConnectChatInternal = withRenderer((props) => { + const { container, appearance, ...defaultProps } = props; + const novu = useNovu(); + + const options: NovuUIOptions = useMemo(() => { + return { + container, + appearance, + options: novu.options, + }; + }, [container, appearance, novu.options]); + + return ( + + + + ); +}); + +ConnectChatInternal.displayName = 'ConnectChatInternal'; + +export const ConnectChat = React.memo((props: ConnectChatProps) => { + return ; +}); + +ConnectChat.displayName = 'ConnectChat'; diff --git a/packages/react/src/components/connect-chat/DefaultConnectChat.tsx b/packages/react/src/components/connect-chat/DefaultConnectChat.tsx new file mode 100644 index 00000000000..3678eff3ffb --- /dev/null +++ b/packages/react/src/components/connect-chat/DefaultConnectChat.tsx @@ -0,0 +1,70 @@ +import { ConnectChatProps } from '@novu/js/ui'; +import { useCallback } from 'react'; +import { useNovuUI } from '../../context/NovuUIContext'; +import { Mounter } from '../Mounter'; + +export type DefaultConnectChatProps = Pick< + ConnectChatProps, + | 'integrationIdentifier' + | 'connectionIdentifier' + | 'subscriberId' + | 'context' + | 'scope' + | 'connectionMode' + | 'onConnectSuccess' + | 'onConnectError' + | 'onDisconnectSuccess' + | 'onDisconnectError' +>; + +export const DefaultConnectChat = (props: DefaultConnectChatProps) => { + const { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + } = props; + const { novuUI } = useNovuUI(); + + const mount = useCallback( + (element: HTMLElement) => { + return novuUI.mountComponent({ + name: 'ConnectChat', + props: { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + }, + element, + }); + }, + [ + novuUI, + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + ] + ); + + return ; +}; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 975e1f1972f..b62f13cec71 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -4,6 +4,8 @@ export * from './Inbox'; export * from './InboxContent'; export * from './Notifications'; export * from './Preferences'; +export * from './slack-connect-button/SlackConnectButton'; +export * from './slack-link-user/SlackLinkUser'; export * from './subscription/Subscription'; export * from './subscription/SubscriptionButton'; export * from './subscription/SubscriptionPreferences'; diff --git a/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx b/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx new file mode 100644 index 00000000000..04aed1405eb --- /dev/null +++ b/packages/react/src/components/slack-connect-button/DefaultSlackConnectButton.tsx @@ -0,0 +1,78 @@ +import { SlackConnectButtonProps } from '@novu/js/ui'; +import { useCallback } from 'react'; +import { useNovuUI } from '../../context/NovuUIContext'; +import { Mounter } from '../Mounter'; + +export type DefaultSlackConnectButtonProps = Pick< + SlackConnectButtonProps, + | 'integrationIdentifier' + | 'connectionIdentifier' + | 'subscriberId' + | 'context' + | 'scope' + | 'connectionMode' + | 'onConnectSuccess' + | 'onConnectError' + | 'onDisconnectSuccess' + | 'onDisconnectError' + | 'connectLabel' + | 'connectedLabel' +>; + +export const DefaultSlackConnectButton = (props: DefaultSlackConnectButtonProps) => { + const { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + connectLabel, + connectedLabel, + } = props; + const { novuUI } = useNovuUI(); + + const mount = useCallback( + (element: HTMLElement) => { + return novuUI.mountComponent({ + name: 'SlackConnectButton', + props: { + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + connectLabel, + connectedLabel, + }, + element, + }); + }, + [ + novuUI, + integrationIdentifier, + connectionIdentifier, + subscriberId, + context, + scope, + connectionMode, + onConnectSuccess, + onConnectError, + onDisconnectSuccess, + onDisconnectError, + connectLabel, + connectedLabel, + ] + ); + + return ; +}; diff --git a/packages/react/src/components/slack-connect-button/SlackConnectButton.tsx b/packages/react/src/components/slack-connect-button/SlackConnectButton.tsx new file mode 100644 index 00000000000..db0d2a5a332 --- /dev/null +++ b/packages/react/src/components/slack-connect-button/SlackConnectButton.tsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import { useNovu } from '../../hooks/NovuProvider'; +import { NovuUI, NovuUIOptions } from '../NovuUI'; +import { withRenderer } from '../Renderer'; +import { DefaultSlackConnectButton, DefaultSlackConnectButtonProps } from './DefaultSlackConnectButton'; + +export type SlackConnectButtonProps = DefaultSlackConnectButtonProps & Pick; + +const SlackConnectButtonInternal = withRenderer((props) => { + const { container, appearance, ...defaultProps } = props; + const novu = useNovu(); + + const options: NovuUIOptions = useMemo(() => { + return { + container, + appearance, + options: novu.options, + }; + }, [container, appearance, novu.options]); + + return ( + + + + ); +}); + +SlackConnectButtonInternal.displayName = 'SlackConnectButtonInternal'; + +export const SlackConnectButton = React.memo((props: SlackConnectButtonProps) => { + return ; +}); + +SlackConnectButton.displayName = 'SlackConnectButton'; diff --git a/packages/react/src/components/slack-link-user/DefaultSlackLinkUser.tsx b/packages/react/src/components/slack-link-user/DefaultSlackLinkUser.tsx new file mode 100644 index 00000000000..209235c816a --- /dev/null +++ b/packages/react/src/components/slack-link-user/DefaultSlackLinkUser.tsx @@ -0,0 +1,66 @@ +import { SlackLinkUserProps } from '@novu/js/ui'; +import { useCallback } from 'react'; +import { useNovuUI } from '../../context/NovuUIContext'; +import { Mounter } from '../Mounter'; + +export type DefaultSlackLinkUserProps = Pick< + SlackLinkUserProps, + | 'integrationIdentifier' + | 'connectionIdentifier' + | 'context' + | 'onLinkSuccess' + | 'onLinkError' + | 'onUnlinkSuccess' + | 'onUnlinkError' + | 'linkLabel' + | 'unlinkLabel' +>; + +export const DefaultSlackLinkUser = (props: DefaultSlackLinkUserProps) => { + const { + integrationIdentifier, + connectionIdentifier, + context, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + onUnlinkError, + linkLabel, + unlinkLabel, + } = props; + const { novuUI } = useNovuUI(); + + const mount = useCallback( + (element: HTMLElement) => { + return novuUI.mountComponent({ + name: 'SlackLinkUser', + props: { + integrationIdentifier, + connectionIdentifier, + context, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + onUnlinkError, + linkLabel, + unlinkLabel, + }, + element, + }); + }, + [ + novuUI, + integrationIdentifier, + connectionIdentifier, + context, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + onUnlinkError, + linkLabel, + unlinkLabel, + ] + ); + + return ; +}; diff --git a/packages/react/src/components/slack-link-user/SlackLinkUser.tsx b/packages/react/src/components/slack-link-user/SlackLinkUser.tsx new file mode 100644 index 00000000000..112c288670e --- /dev/null +++ b/packages/react/src/components/slack-link-user/SlackLinkUser.tsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import { useNovu } from '../../hooks/NovuProvider'; +import { NovuUI, NovuUIOptions } from '../NovuUI'; +import { withRenderer } from '../Renderer'; +import { DefaultSlackLinkUser, DefaultSlackLinkUserProps } from './DefaultSlackLinkUser'; + +export type SlackLinkUserProps = DefaultSlackLinkUserProps & Pick; + +const SlackLinkUserInternal = withRenderer((props) => { + const { container, appearance, ...defaultProps } = props; + const novu = useNovu(); + + const options: NovuUIOptions = useMemo(() => { + return { + container, + appearance, + options: novu.options, + }; + }, [container, appearance, novu.options]); + + return ( + + + + ); +}); + +SlackLinkUserInternal.displayName = 'SlackLinkUserInternal'; + +export const SlackLinkUser = React.memo((props: SlackLinkUserProps) => { + return ; +}); + +SlackLinkUser.displayName = 'SlackLinkUser'; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 21bafa6fe11..4519505d519 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,8 +1,13 @@ export type * from '@novu/js'; export { PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/js'; export { NovuProvider, useNovu } from './NovuProvider'; +export * from './useChannelConnection'; +export * from './useChannelConnections'; +export * from './useChannelEndpoint'; export * from './useCounts'; +export * from './useCreateChannelEndpoint'; export * from './useCreateSubscription'; +export * from './useDeleteChannelEndpoint'; export * from './useNotifications'; export * from './usePreferences'; export * from './useRemoveSubscription'; diff --git a/packages/react/src/hooks/useChannelConnection.ts b/packages/react/src/hooks/useChannelConnection.ts new file mode 100644 index 00000000000..dadd7156cb3 --- /dev/null +++ b/packages/react/src/hooks/useChannelConnection.ts @@ -0,0 +1,105 @@ +import type { ChannelConnectionResponse, NovuError } from '@novu/js'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNovu } from './NovuProvider'; + +export type UseChannelConnectionProps = { + identifier: string; + onSuccess?: (data: ChannelConnectionResponse | null) => void; + onError?: (error: NovuError) => void; +}; + +export type UseChannelConnectionResult = { + connection?: ChannelConnectionResponse | null; + error?: NovuError; + isLoading: boolean; + isFetching: boolean; + refetch: () => Promise; +}; + +/** + * Get a channel connection by identifier. + */ +export const useChannelConnection = (props: UseChannelConnectionProps): UseChannelConnectionResult => { + const novu = useNovu(); + const propsRef = useRef(props); + propsRef.current = props; + + const [connection, setConnection] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [isFetching, setIsFetching] = useState(false); + + const fetchConnection = useCallback( + async (options?: { refetch: boolean }) => { + const { identifier, onSuccess, onError } = propsRef.current; + if (options?.refetch) { + setError(undefined); + setIsLoading(true); + } + + setIsFetching(true); + + const response = await novu.channelConnections.get({ identifier }); + + if (response.error) { + setError(response.error as NovuError); + onError?.(response.error as NovuError); + } else if (response.data !== undefined) { + onSuccess?.(response.data); + setConnection(response.data); + } + setIsLoading(false); + setIsFetching(false); + }, + [novu] + ); + + // props.identifier triggers a re-fetch and re-registration whenever the + // caller switches to a different identifier; fetchConnection reads it from + // propsRef at call-time so it doesn't appear in the callback body directly. + // biome-ignore lint/correctness/useExhaustiveDependencies: props.identifier is an intentional trigger dependency + useEffect(() => { + const cleanupGetPending = novu.on('channel-connection.get.pending', () => { + setIsFetching(true); + }); + + const cleanupGetResolved = novu.on('channel-connection.get.resolved', ({ data, error: resolvedError }) => { + const { onSuccess, onError } = propsRef.current; + if (resolvedError) { + setError(resolvedError as NovuError); + onError?.(resolvedError as NovuError); + } else { + setConnection((data as ChannelConnectionResponse) ?? null); + onSuccess?.((data as ChannelConnectionResponse) ?? null); + } + setIsFetching(false); + }); + + const cleanupDeleteResolved = novu.on('channel-connection.delete.resolved', ({ args }) => { + if (!args || args.identifier !== propsRef.current.identifier) { + return; + } + setConnection(null); + propsRef.current.onSuccess?.(null); + setIsFetching(false); + }); + + void fetchConnection({ refetch: true }); + + return () => { + cleanupGetPending(); + cleanupGetResolved(); + cleanupDeleteResolved(); + }; + }, [novu, fetchConnection, props.identifier]); + + const refetch = useCallback(() => fetchConnection({ refetch: true }), [fetchConnection]); + + return { + connection, + error, + isLoading, + isFetching, + refetch, + }; +}; diff --git a/packages/react/src/hooks/useChannelConnections.ts b/packages/react/src/hooks/useChannelConnections.ts new file mode 100644 index 00000000000..2b1f2cbaaa9 --- /dev/null +++ b/packages/react/src/hooks/useChannelConnections.ts @@ -0,0 +1,84 @@ +import type { ChannelConnectionResponse, ListChannelConnectionsArgs, NovuError } from '@novu/js'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNovu } from './NovuProvider'; + +export type UseChannelConnectionsProps = ListChannelConnectionsArgs & { + onSuccess?: (data: ChannelConnectionResponse[]) => void; + onError?: (error: NovuError) => void; +}; + +export type UseChannelConnectionsResult = { + connections: ChannelConnectionResponse[]; + error?: NovuError; + isLoading: boolean; + isFetching: boolean; + refetch: () => Promise; +}; + +/** + * List channel connections for the current subscriber. Use this to discover an active + * connection identifier after a chat OAuth flow so it can be passed to `LinkUser`. + * + * @example + * const { connections } = useChannelConnections({ integrationIdentifier: 'my-chat-integration' }); + * const connectionIdentifier = connections[0]?.identifier; + */ +export const useChannelConnections = (props: UseChannelConnectionsProps = {}): UseChannelConnectionsResult => { + const novu = useNovu(); + const propsRef = useRef(props); + propsRef.current = props; + + const [connections, setConnections] = useState([]); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [isFetching, setIsFetching] = useState(false); + + const fetchConnections = useCallback( + async (options?: { refetch: boolean }) => { + const { onSuccess, onError, ...listArgs } = propsRef.current; + + if (options?.refetch) { + setError(undefined); + setIsLoading(true); + } + + setIsFetching(true); + + const response = await novu.channelConnections.list(listArgs); + + if (response.error) { + setError(response.error as NovuError); + onError?.(response.error as NovuError); + } else if (response.data !== undefined) { + setConnections(response.data); + onSuccess?.(response.data); + } + + setIsLoading(false); + setIsFetching(false); + }, + [novu] + ); + + useEffect(() => { + const cleanupDeleteResolved = novu.on('channel-connection.delete.resolved', () => { + void fetchConnections({ refetch: true }); + }); + + void fetchConnections({ refetch: true }); + + return () => { + cleanupDeleteResolved(); + }; + }, [novu, fetchConnections]); + + const refetch = useCallback(() => fetchConnections({ refetch: true }), [fetchConnections]); + + return { + connections, + error, + isLoading, + isFetching, + refetch, + }; +}; diff --git a/packages/react/src/hooks/useChannelEndpoint.ts b/packages/react/src/hooks/useChannelEndpoint.ts new file mode 100644 index 00000000000..18a403414f1 --- /dev/null +++ b/packages/react/src/hooks/useChannelEndpoint.ts @@ -0,0 +1,111 @@ +import type { ChannelEndpointResponse, NovuError } from '@novu/js'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNovu } from './NovuProvider'; + +export type UseChannelEndpointProps = { + identifier: string; + onSuccess?: (data: ChannelEndpointResponse | null) => void; + onError?: (error: NovuError) => void; +}; + +export type UseChannelEndpointResult = { + endpoint?: ChannelEndpointResponse | null; + error?: NovuError; + isLoading: boolean; + isFetching: boolean; + refetch: () => Promise; +}; + +/** + * Get a channel endpoint by identifier. + */ +export const useChannelEndpoint = (props: UseChannelEndpointProps): UseChannelEndpointResult => { + const novu = useNovu(); + const propsRef = useRef(props); + propsRef.current = props; + + const [endpoint, setEndpoint] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [isFetching, setIsFetching] = useState(false); + + const fetchEndpoint = useCallback( + async (options?: { refetch: boolean }) => { + const { identifier, onSuccess, onError } = propsRef.current; + if (options?.refetch) { + setError(undefined); + setIsLoading(true); + } + + setIsFetching(true); + + const response = await novu.channelEndpoints.get({ identifier }); + + if (response.error) { + setError(response.error as NovuError); + onError?.(response.error as NovuError); + } else if (response.data !== undefined) { + onSuccess?.(response.data); + setEndpoint(response.data); + } + setIsLoading(false); + setIsFetching(false); + }, + [novu] + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: props.identifier is needed to re-run the effect and initial fetch when the identifier changes + useEffect(() => { + const cleanupGetPending = novu.on('channel-endpoint.get.pending', () => { + setIsFetching(true); + }); + + const cleanupGetResolved = novu.on('channel-endpoint.get.resolved', ({ data, error: resolvedError }) => { + const { onSuccess, onError } = propsRef.current; + if (resolvedError) { + setError(resolvedError as NovuError); + onError?.(resolvedError as NovuError); + } else { + setEndpoint((data as ChannelEndpointResponse) ?? null); + onSuccess?.((data as ChannelEndpointResponse) ?? null); + } + setIsFetching(false); + }); + + const cleanupCreateResolved = novu.on('channel-endpoint.create.resolved', ({ data }) => { + if (data) { + setEndpoint(data as ChannelEndpointResponse); + propsRef.current.onSuccess?.(data as ChannelEndpointResponse); + } + setIsFetching(false); + }); + + const cleanupDeleteResolved = novu.on('channel-endpoint.delete.resolved', ({ args }) => { + if (!args || args.identifier !== propsRef.current.identifier) { + return; + } + setEndpoint(null); + propsRef.current.onSuccess?.(null); + setIsFetching(false); + }); + + void fetchEndpoint({ refetch: true }); + + return () => { + cleanupGetPending(); + cleanupGetResolved(); + cleanupCreateResolved(); + cleanupDeleteResolved(); + }; + }, [novu, fetchEndpoint, props.identifier]); + + const refetch = useCallback(() => fetchEndpoint({ refetch: true }), [fetchEndpoint]); + + return { + endpoint, + error, + isLoading, + isFetching, + refetch, + }; +}; diff --git a/packages/react/src/hooks/useCreateChannelEndpoint.ts b/packages/react/src/hooks/useCreateChannelEndpoint.ts new file mode 100644 index 00000000000..691a2f5ca60 --- /dev/null +++ b/packages/react/src/hooks/useCreateChannelEndpoint.ts @@ -0,0 +1,53 @@ +import type { ChannelEndpointResponse, CreateChannelEndpointArgs, NovuError } from '@novu/js'; +import { useCallback, useRef, useState } from 'react'; +import { useNovu } from './NovuProvider'; + +export type UseCreateChannelEndpointProps = { + onSuccess?: (data: ChannelEndpointResponse) => void; + onError?: (error: NovuError) => void; +}; + +export type UseCreateChannelEndpointResult = { + isCreating: boolean; + error?: NovuError; + create: (args: CreateChannelEndpointArgs) => Promise<{ + data?: ChannelEndpointResponse | undefined; + error?: NovuError | undefined; + }>; +}; + +export const useCreateChannelEndpoint = (props: UseCreateChannelEndpointProps = {}): UseCreateChannelEndpointResult => { + const propsRef = useRef(props); + propsRef.current = props; + const novu = useNovu(); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(); + + const create = useCallback( + async (args: CreateChannelEndpointArgs) => { + const { onSuccess, onError } = propsRef.current; + setError(undefined); + setIsCreating(true); + + const response = await novu.channelEndpoints.create(args); + + setIsCreating(false); + + if (response.error) { + setError(response.error as NovuError); + onError?.(response.error as NovuError); + } else if (response.data) { + onSuccess?.(response.data as ChannelEndpointResponse); + } + + return response as { data?: ChannelEndpointResponse; error?: NovuError }; + }, + [novu] + ); + + return { + create, + isCreating, + error, + }; +}; diff --git a/packages/react/src/hooks/useDeleteChannelEndpoint.ts b/packages/react/src/hooks/useDeleteChannelEndpoint.ts new file mode 100644 index 00000000000..0a0984805c3 --- /dev/null +++ b/packages/react/src/hooks/useDeleteChannelEndpoint.ts @@ -0,0 +1,60 @@ +import type { NovuError } from '@novu/js'; +import { useCallback, useRef, useState } from 'react'; +import { useNovu } from './NovuProvider'; + +export type UseDeleteChannelEndpointProps = { + onSuccess?: () => void; + onError?: (error: NovuError) => void; +}; + +export type UseDeleteChannelEndpointResult = { + isDeleting: boolean; + error?: NovuError; + remove: (identifier: string) => Promise<{ + data?: undefined; + error?: NovuError | undefined; + }>; +}; + +export const useDeleteChannelEndpoint = (props: UseDeleteChannelEndpointProps = {}): UseDeleteChannelEndpointResult => { + const propsRef = useRef(props); + propsRef.current = props; + const novu = useNovu(); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(); + + const remove = useCallback( + async (identifier: string) => { + setError(undefined); + setIsDeleting(true); + + try { + const response = await novu.channelEndpoints.delete({ identifier }); + + if (response.error) { + setError(response.error as NovuError); + propsRef.current.onError?.(response.error as NovuError); + } else { + propsRef.current.onSuccess?.(); + } + + return response as { data?: undefined; error?: NovuError }; + } catch (err) { + const novuError = err as NovuError; + setError(novuError); + propsRef.current.onError?.(novuError); + + return { error: novuError }; + } finally { + setIsDeleting(false); + } + }, + [novu] + ); + + return { + remove, + isDeleting, + error, + }; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9fb8ec7d7e3..b992fe0c565 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ export { PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '@no export type { AllLocalization, AllLocalizationKey, + ChannelConnectButtonIconKey, ElementStyles, InboxAppearance, InboxAppearanceCallback, @@ -38,6 +39,8 @@ export type { InboxProps, NotificationProps, NovuProviderProps, + SlackConnectButtonProps, + SlackLinkUserProps, SubscriptionButtonProps, SubscriptionPreferencesProps, SubscriptionProps, @@ -49,21 +52,38 @@ export { Notifications, NovuProvider, Preferences, + SlackConnectButton, + SlackLinkUser, Subscription, SubscriptionButton, SubscriptionPreferences, } from './components'; export type { + UseChannelConnectionProps, + UseChannelConnectionResult, + UseChannelConnectionsProps, + UseChannelConnectionsResult, + UseChannelEndpointProps, + UseChannelEndpointResult, UseCountsProps, UseCountsResult, + UseCreateChannelEndpointProps, + UseCreateChannelEndpointResult, + UseDeleteChannelEndpointProps, + UseDeleteChannelEndpointResult, UseNotificationsProps, UseNotificationsResult, UsePreferencesResult, UseScheduleProps as UsePreferencesProps, } from './hooks'; export { + useChannelConnection, + useChannelConnections, + useChannelEndpoint, useCounts, + useCreateChannelEndpoint, useCreateSubscription, + useDeleteChannelEndpoint, useNotifications, useNovu, usePreferences, @@ -83,6 +103,7 @@ export type { NoRendererProps, NotificationRendererProps, NotificationsRenderer, + ReactAllAppearance, ReactInboxAppearance, ReactInboxTheme, ReactSubscriptionAppearance, diff --git a/packages/react/src/utils/appearance.ts b/packages/react/src/utils/appearance.ts index d393378ca31..1d76ea4f97c 100644 --- a/packages/react/src/utils/appearance.ts +++ b/packages/react/src/utils/appearance.ts @@ -1,9 +1,9 @@ import type { AllAppearance, AllIconKey, AllIconOverrides } from '@novu/js/ui'; import { MountedElement } from '../context/RendererContext'; -import type { ReactIconRenderer, ReactInboxAppearance, ReactSubscriptionAppearance } from './types'; +import type { ReactAllAppearance, ReactIconRenderer, ReactInboxAppearance, ReactSubscriptionAppearance } from './types'; export function adaptAppearanceForJs( - appearance: ReactInboxAppearance | ReactSubscriptionAppearance, + appearance: ReactInboxAppearance | ReactSubscriptionAppearance | ReactAllAppearance, mountElement: (el: HTMLElement, mountedElement: MountedElement) => () => void ): AllAppearance | undefined { if (!appearance) { diff --git a/packages/shared/src/types/channel-connection.ts b/packages/shared/src/types/channel-connection.ts index 427995c388b..de2cd393330 100644 --- a/packages/shared/src/types/channel-connection.ts +++ b/packages/shared/src/types/channel-connection.ts @@ -3,6 +3,8 @@ import { EnvironmentId } from './environment'; import { OrganizationId } from './organization'; import { ProvidersIdEnum } from './providers'; +export type ConnectionMode = 'subscriber' | 'shared'; + export type ChannelConnection = { _id: string; identifier: string; diff --git a/playground/nextjs/.env.example b/playground/nextjs/.env.example index 23914f8d57a..dee054cc12b 100644 --- a/playground/nextjs/.env.example +++ b/playground/nextjs/.env.example @@ -8,3 +8,10 @@ OPENAI_API_KEY= NOVU_SECRET_KEY= NOVU_SUBSCRIBER_ID= NOVU_HITL_WORKFLOW_ID=refund-approval + +# Slack Connect Chat demo +NEXT_PUBLIC_NOVU_SLACK_INTEGRATION_IDENTIFIER= +NEXT_PUBLIC_SLACK_USER_ID= +SLACK_BOT_USER_OAUTH_TOKEN= +# Workflow ID to trigger when clicking "Send Test Message" on the connect-chat playground page +NEXT_PUBLIC_NOVU_SLACK_TEST_WORKFLOW_ID= diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 1051f4c79f8..33903b26d64 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@ai-sdk/openai": "^3.0.0", + "@novu/api": "workspace:*", "@ai-sdk/react": "^3.0.0", "@novu/agent-toolkit": "workspace:*", "@novu/nextjs": "workspace:*", diff --git a/playground/nextjs/src/components/SideNav.tsx b/playground/nextjs/src/components/SideNav.tsx index 2c0a01f97fb..55030ea353b 100644 --- a/playground/nextjs/src/components/SideNav.tsx +++ b/playground/nextjs/src/components/SideNav.tsx @@ -15,6 +15,7 @@ const LINKS: LinkType[] = [ { href: '/render-notification', label: 'Render Notification', category: 'Components' }, { href: '/notifications', label: 'Notifications', category: 'Components' }, { href: '/preferences', label: 'Preferences', category: 'Components' }, + { href: '/connect-chat', label: 'Connect Chat', category: 'Components' }, { href: '/subscription', label: 'Subscription', category: 'Components' }, { href: '/subscription-components', label: 'Subscription Components', category: 'Components' }, { href: '/subscription-hooks', label: 'Subscription Hooks', category: 'Components' }, diff --git a/playground/nextjs/src/lib/slack-dm-endpoint-connect.ts b/playground/nextjs/src/lib/slack-dm-endpoint-connect.ts new file mode 100644 index 00000000000..82ace63396d --- /dev/null +++ b/playground/nextjs/src/lib/slack-dm-endpoint-connect.ts @@ -0,0 +1,177 @@ +/** + * Server-side helper for registering a Slack DM channel endpoint in Novu. + * + * WHY THIS MUST BE SERVER-SIDE + * The Slack `users.lookupByEmail` API requires a Bot User OAuth Token (xoxb-...). + * That token must never be exposed to the browser. Use this from a Next.js API route + * or any server-side handler. + * + * FULL FLOW + * 1. User clicks `ConnectChat` → Novu OAuth stores a ChannelConnection for the subscriber. + * 2. Your backend (this helper) resolves the subscriber email → Slack user ID via + * `users.lookupByEmail` using the workspace bot token. + * 3. `ensureSlackUserDmEndpoint` creates a `slack_user` ChannelEndpoint so Novu can + * send Slack DMs to the subscriber's personal account. + * + * REQUIRED ENV VARS + * NOVU_SECRET_KEY Novu API secret key (sk_...) + * NOVU_API_BASE_URL Novu API base URL (optional; falls back to NEXT_PUBLIC_NOVU_BACKEND_URL, then https://api.novu.co) + * NOVU_SLACK_INTEGRATION_IDENTIFIER Novu integration identifier for the Slack integration + * SLACK_BOT_USER_OAUTH_TOKEN Slack workspace Bot User OAuth Token (xoxb-...) + */ + +import { Novu } from '@novu/api'; + +const SLACK_LOOKUP_BY_EMAIL_URL = 'https://slack.com/api/users.lookupByEmail'; +const SLACK_USER_TYPE = 'slack_user' as const; + +export type EnsureSlackUserDmEndpointResult = { ok: true; slackUserId: string } | { ok: false; error: string }; + +function getNovuClient(): Novu { + const secretKey = process.env.NOVU_SECRET_KEY?.trim(); + + if (!secretKey) { + throw new Error('NOVU_SECRET_KEY is required'); + } + + const serverURL = ( + process.env.NOVU_API_BASE_URL ?? + process.env.NEXT_PUBLIC_NOVU_BACKEND_URL ?? + 'https://api.novu.co' + ).replace(/\/v1$/, ''); + + // Workspace `@novu/api` (internal SDK) resolves auth from `security`, not top-level `secretKey`. + return new Novu({ security: { secretKey }, serverURL }); +} + +/** + * Resolve a Slack user ID from an email address using the workspace bot token. + * Returns undefined when the lookup fails or the user cannot be found. + */ +export async function lookupSlackUserIdByEmail(botAccessToken: string, email: string): Promise { + try { + const res = await fetch(SLACK_LOOKUP_BY_EMAIL_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${botAccessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ email: email.trim().toLowerCase() }), + }); + + const data = (await res.json()) as { ok?: boolean; user?: { id?: string } }; + + if (data?.ok !== true || !data?.user?.id) { + return undefined; + } + + return data.user.id; + } catch { + return undefined; + } +} + +/** + * After a subscriber completes Novu Slack OAuth (`ConnectChat`), call this server-side + * function to register a `slack_user` ChannelEndpoint so Novu can send Slack DMs. + * + * Idempotent: if an endpoint for this slackUserId already exists, returns immediately. + * + * @param subscriberId The Novu subscriber ID + * @param integrationIdentifier The Novu Slack integration identifier + * @param emailOverride Use this email instead of the subscriber profile email + * @param slackUserIdOverride Skip the Slack API lookup and use this Slack user ID directly + */ +export async function ensureSlackUserDmEndpoint(args: { + subscriberId: string; + integrationIdentifier: string; + emailOverride?: string; + slackUserIdOverride?: string; +}): Promise { + const novu = getNovuClient(); + const { subscriberId, integrationIdentifier } = args; + + let slackUserId = args.slackUserIdOverride?.trim(); + + if (!slackUserId) { + const subRes = await novu.subscribers.retrieve(subscriberId); + const profileEmail = + args.emailOverride?.trim() || (typeof subRes.result?.email === 'string' ? subRes.result.email.trim() : ''); + + const botToken = (process.env.SLACK_BOT_USER_OAUTH_TOKEN ?? '').trim(); + + if (!profileEmail) { + return { + ok: false, + error: 'Subscriber has no email. Pass email when creating the subscriber or provide emailOverride.', + }; + } + + if (!botToken) { + return { + ok: false, + error: 'Missing workspace bot token. Set SLACK_BOT_USER_OAUTH_TOKEN (Bot User OAuth Token from the Slack app).', + }; + } + + slackUserId = await lookupSlackUserIdByEmail(botToken, profileEmail); + + if (!slackUserId) { + return { + ok: false, + error: + 'Could not resolve Slack user ID via users.lookupByEmail. Use an email that matches a Slack account, or pass slackUserIdOverride.', + }; + } + } + + const endpoints = await novu.channelEndpoints.list({ + subscriberId, + integrationIdentifier, + limit: 100, + }); + + const alreadyLinked = endpoints.result.data.some((ep) => { + const endpointData = ep.endpoint as Record; + + return ep.type === SLACK_USER_TYPE && endpointData.userId === slackUserId; + }); + + if (alreadyLinked) { + return { ok: true, slackUserId }; + } + + const connections = await novu.channelConnections.list({ + subscriberId, + integrationIdentifier, + limit: 100, + }); + + const connectionIdentifier = connections.result.data.find( + (c) => c.identifier && c.providerId === 'slack' + )?.identifier; + + if (!connectionIdentifier) { + return { + ok: false, + error: + 'No Slack channel connection found for this subscriber. The subscriber must complete ConnectChat OAuth first.', + }; + } + + try { + await novu.channelEndpoints.create({ + subscriberId, + integrationIdentifier, + connectionIdentifier, + type: SLACK_USER_TYPE, + endpoint: { userId: slackUserId }, + }); + + return { ok: true, slackUserId }; + } catch (e) { + const message = e instanceof Error ? e.message : 'channelEndpoints.create failed'; + + return { ok: false, error: message }; + } +} diff --git a/playground/nextjs/src/pages/api/slack-dm-endpoint.ts b/playground/nextjs/src/pages/api/slack-dm-endpoint.ts new file mode 100644 index 00000000000..c8b08b8d35c --- /dev/null +++ b/playground/nextjs/src/pages/api/slack-dm-endpoint.ts @@ -0,0 +1,80 @@ +/** + * POST /api/slack-dm-endpoint + * + * Server-side companion for the `LinkUser` SDK component. + * + * This route implements the email → Slack user ID resolution that cannot run in the + * browser (requires the SLACK_BOT_USER_OAUTH_TOKEN bot secret). After this route + * succeeds, Novu has a `slack_user` ChannelEndpoint and can send DMs to the subscriber. + * + * The `LinkUser` SDK component handles the ChannelEndpoint creation client-side when + * you already know the Slack user ID. Use this route when you need to resolve the + * Slack user ID server-side from a subscriber email. + * + * Required ENV vars: + * NOVU_SECRET_KEY Novu API secret (sk_...) + * NOVU_API_BASE_URL Optional Novu API base URL + * NOVU_SLACK_INTEGRATION_IDENTIFIER Novu Slack integration identifier + * SLACK_BOT_USER_OAUTH_TOKEN Slack workspace Bot User OAuth Token (xoxb-...) + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { ensureSlackUserDmEndpoint } from '@/lib/slack-dm-endpoint-connect'; + +type RequestBody = { + subscriberId?: string; + integrationIdentifier?: string; + emailOverride?: string; + slackUserIdOverride?: string; +}; + +type ResponseData = { slackUserId: string } | { error: string }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + res.status(405).json({ error: 'Method not allowed' }); + + return; + } + + try { + const body = req.body as RequestBody; + const subscriberId = typeof body.subscriberId === 'string' ? body.subscriberId.trim() : ''; + + if (!subscriberId) { + res.status(400).json({ error: 'subscriberId is required' }); + + return; + } + + const integrationIdentifier = + (typeof body.integrationIdentifier === 'string' && body.integrationIdentifier.trim()) || + process.env.NOVU_SLACK_INTEGRATION_IDENTIFIER; + + if (!integrationIdentifier) { + res.status(400).json({ error: 'integrationIdentifier is required (body or NOVU_SLACK_INTEGRATION_IDENTIFIER)' }); + + return; + } + + const result = await ensureSlackUserDmEndpoint({ + subscriberId, + integrationIdentifier, + emailOverride: typeof body.emailOverride === 'string' ? body.emailOverride : undefined, + slackUserIdOverride: typeof body.slackUserIdOverride === 'string' ? body.slackUserIdOverride : undefined, + }); + + if (!result.ok) { + res.status(422).json({ error: result.error }); + + return; + } + + res.status(200).json({ slackUserId: result.slackUserId }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + + res.status(500).json({ error: message }); + } +} diff --git a/playground/nextjs/src/pages/api/trigger-event.ts b/playground/nextjs/src/pages/api/trigger-event.ts new file mode 100644 index 00000000000..7f20e89a79b --- /dev/null +++ b/playground/nextjs/src/pages/api/trigger-event.ts @@ -0,0 +1,76 @@ +/** + * POST /api/trigger-event + * + * Thin server-side proxy that forwards the request to the Novu `/v1/events/trigger` + * endpoint, injecting the secret key from the server environment. + * + * Required ENV vars: + * NOVU_SECRET_KEY Novu API secret (sk_...) + * NOVU_API_BASE_URL Optional Novu API base URL (falls back to NEXT_PUBLIC_NOVU_BACKEND_URL) + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; + +type RequestBody = { + name?: string; + to?: unknown; + payload?: unknown; +}; + +type ResponseData = Record; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + res.status(405).json({ error: 'Method not allowed' }); + + return; + } + + const secretKey = process.env.NOVU_SECRET_KEY?.trim(); + + if (!secretKey) { + res.status(500).json({ error: 'NOVU_SECRET_KEY is not configured' }); + + return; + } + + const body = req.body as RequestBody; + + if (!body.name) { + res.status(400).json({ error: 'name (workflow ID) is required' }); + + return; + } + + if (!body.to) { + res.status(400).json({ error: 'to (subscriber) is required' }); + + return; + } + + const backendUrl = ( + process.env.NOVU_API_BASE_URL ?? + process.env.NEXT_PUBLIC_NOVU_BACKEND_URL ?? + 'https://api.novu.co' + ).replace(/\/+$/, ''); + + try { + const upstream = await fetch(`${backendUrl}/v1/events/trigger`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `ApiKey ${secretKey}`, + }, + body: JSON.stringify(body), + }); + + const data = (await upstream.json()) as ResponseData; + + res.status(upstream.status).json(data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + + res.status(500).json({ error: message }); + } +} diff --git a/playground/nextjs/src/pages/connect-chat/index.tsx b/playground/nextjs/src/pages/connect-chat/index.tsx new file mode 100644 index 00000000000..7184a831f79 --- /dev/null +++ b/playground/nextjs/src/pages/connect-chat/index.tsx @@ -0,0 +1,254 @@ +import { NovuProvider, SlackConnectButton, SlackLinkUser } from '@novu/nextjs'; +import { useState } from 'react'; +import Title from '@/components/Title'; +import { novuConfig } from '@/utils/config'; + +const INTEGRATION_IDENTIFIER = process.env.NEXT_PUBLIC_NOVU_SLACK_INTEGRATION_IDENTIFIER ?? 'slack'; +const CONNECTION_IDENTIFIER = 'slack-workspace-connection'; +const SLACK_TEST_WORKFLOW_ID = process.env.NEXT_PUBLIC_NOVU_SLACK_TEST_WORKFLOW_ID ?? ''; +// const context = { key: 'value1' }; +const context = undefined; + +export default function ConnectChatPage() { + const [dmStatus, setDmStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [dmLoading, setDmLoading] = useState(false); + const [triggerWorkflowId, setTriggerWorkflowId] = useState(SLACK_TEST_WORKFLOW_ID); + const [triggerStatus, setTriggerStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [triggerLoading, setTriggerLoading] = useState(false); + + const handleCreateDmEndpoint = async () => { + setDmLoading(true); + setDmStatus(null); + + try { + const res = await fetch('/api/slack-dm-endpoint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriberId: novuConfig.subscriberId, + integrationIdentifier: INTEGRATION_IDENTIFIER, + }), + }); + + const data = (await res.json()) as { slackUserId?: string; error?: string }; + + if (!res.ok || data.error) { + setDmStatus({ type: 'error', message: data.error ?? 'Unknown error' }); + } else { + setDmStatus({ type: 'success', message: `DM endpoint created for Slack user: ${data.slackUserId}` }); + } + } catch (err) { + setDmStatus({ type: 'error', message: err instanceof Error ? err.message : 'Request failed' }); + } finally { + setDmLoading(false); + } + }; + + const handleSendTestMessage = async () => { + if (!triggerWorkflowId.trim()) { + setTriggerStatus({ type: 'error', message: 'Workflow ID is required' }); + + return; + } + + setTriggerLoading(true); + setTriggerStatus(null); + + try { + const res = await fetch('/api/trigger-event', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: triggerWorkflowId.trim(), + to: { subscriberId: novuConfig.subscriberId }, + payload: { message: 'Test message from connect-chat playground' }, + ...(context && { context: context }), + }), + }); + + const data = (await res.json()) as { data?: { transactionId?: string }; error?: string; message?: string }; + + if (!res.ok) { + setTriggerStatus({ type: 'error', message: data.message ?? data.error ?? `HTTP ${res.status}` }); + } else { + const txId = data.data?.transactionId ?? '—'; + + setTriggerStatus({ type: 'success', message: `Triggered ✓ transactionId: ${txId}` }); + } + } catch (err) { + setTriggerStatus({ type: 'error', message: err instanceof Error ? err.message : 'Request failed' }); + } finally { + setTriggerLoading(false); + } + }; + + return ( + <> + + <div className="flex flex-col gap-8 p-4 max-w-xl"> + <section className="flex flex-col gap-3"> + <h4 className="text-sm font-semibold">Step 1 — SlackConnectButton: OAuth with endpoint configuration</h4> + <p className="text-xs text-muted-foreground"> + OAuth can create the <code>ChannelEndpoint</code> automatically — the Step 2 Link User flow is optional. + </p> + <NovuProvider {...novuConfig}> + <SlackConnectButton + integrationIdentifier={INTEGRATION_IDENTIFIER} + // connectLabel="Connect to Slack AAA" + // connectedLabel="Connected to Slack AAA" + appearance={{ + elements: { + // Static: hide the icon in both states + // channelConnectButtonIcon: { display: 'none' }, + // Callback: hide only when connected, show when not connected + channelConnectButtonIcon: ({ connected }) => (connected ? 'nt-hidden' : ''), + // channelConnectButtonIcon: ({ connected }) => (connected ? '' : 'nt-hidden'), + }, + }} + // connectionIdentifier={CONNECTION_IDENTIFIER} + // connectionStrategy: 'subscriber' | 'shared' DEFAULT 'subscriber' + + // in NovuProvider + // subscriberId: string // redundant + // ...(context && { context: context }), + /> + </NovuProvider> + + <NovuProvider {...novuConfig}> + <SlackConnectButton + integrationIdentifier={INTEGRATION_IDENTIFIER} + connectLabel="Connect to Slack BBB" + connectedLabel="Connected to Slack BBB" + appearance={{ + icons: { + channelConnect: ({ class: cls }) => ( + <svg className={cls} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" /> + <path d="M5 8h6M8 5v6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + </svg> + ), + channelConnected: ({ class: cls }) => ( + <svg className={cls} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" /> + <path + d="M5.5 8l2 2 3-3" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ), + }, + }} + /> + </NovuProvider> + </section> + + <section className="flex flex-col gap-3"> + <h4 className="text-sm font-semibold">Step 2 — SlackLinkUser: Link subscriber via Slack OAuth</h4> + <p className="text-xs text-muted-foreground"> + Starts a Slack OAuth flow (<code>user_scope=identity.basic</code>) to automatically resolve the + subscriber's Slack user ID and create a <code>ChannelEndpoint</code> of type <code>slack_user</code>. + Requires an active workspace connection from Step 1. + </p> + <NovuProvider {...novuConfig}> + <SlackLinkUser + integrationIdentifier={INTEGRATION_IDENTIFIER} + appearance={{ + elements: { + linkSlackUserButtonIcon: ({ linked }) => (linked ? '' : 'nt-hidden'), + }, + }} + // connectionIdentifier={CONNECTION_IDENTIFIER} + /> + </NovuProvider> + + <NovuProvider {...novuConfig}> + <SlackLinkUser + integrationIdentifier={INTEGRATION_IDENTIFIER} + appearance={{ + icons: { + channelConnect: ({ class: cls }) => ( + <svg className={cls} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" /> + <path d="M5 8h6M8 5v6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + </svg> + ), + channelConnected: ({ class: cls }) => ( + <svg className={cls} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" /> + <path + d="M5.5 8l2 2 3-3" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ), + }, + }} + /> + </NovuProvider> + </section> + + <section className="flex flex-col gap-3"> + <h4 className="text-sm font-semibold">Server-side DM Endpoint — Resolve email to Slack user ID</h4> + <p className="text-xs text-muted-foreground"> + Calls <code>/api/slack-dm-endpoint</code> which looks up the subscriber email via the Slack bot token ( + <code>SLACK_BOT_USER_OAUTH_TOKEN</code>) and registers a <code>slack_user</code>{' '} + <code>ChannelEndpoint</code>. Requires the subscriber to have completed OAuth via <em>ConnectChat</em>{' '} + first. + </p> + <button + onClick={handleCreateDmEndpoint} + disabled={dmLoading} + className="self-start rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50" + > + {dmLoading ? 'Creating…' : 'Create DM Endpoint'} + </button> + {dmStatus && ( + <p className={`text-xs ${dmStatus.type === 'success' ? 'text-green-600' : 'text-destructive'}`}> + {dmStatus.message} + </p> + )} + </section> + + <section className="flex flex-col gap-3"> + <h4 className="text-sm font-semibold"> + Send Test Message — Trigger a workflow via <code>/v1/events/trigger</code> + </h4> + <p className="text-xs text-muted-foreground"> + Calls the Novu trigger engine directly to dispatch a workflow to the current subscriber. Use this to verify + the full e2e path: OAuth → endpoint registration → message delivery. + </p> + <div className="flex items-center gap-2"> + <input + type="text" + value={triggerWorkflowId} + onChange={(e) => setTriggerWorkflowId(e.target.value)} + placeholder="workflow-id (e.g. slack-dm-test)" + className="flex-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> + <button + onClick={handleSendTestMessage} + disabled={triggerLoading || !triggerWorkflowId.trim()} + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50" + > + {triggerLoading ? 'Sending…' : 'Send'} + </button> + </div> + <p className="text-xs text-muted-foreground"> + Subscriber: <code>{novuConfig.subscriberId}</code> + </p> + {triggerStatus && ( + <p className={`text-xs ${triggerStatus.type === 'success' ? 'text-green-600' : 'text-destructive'}`}> + {triggerStatus.message} + </p> + )} + </section> + </div> + </> + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e451a4588c3..bfbfd6bb744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4094,6 +4094,9 @@ importers: '@novu/agent-toolkit': specifier: workspace:* version: link:../../packages/agent-toolkit + '@novu/api': + specifier: workspace:* + version: link:../../libs/internal-sdk '@novu/nextjs': specifier: workspace:* version: link:../../packages/nextjs