diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index e52163da0b3..d136972233e 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -86,6 +86,8 @@ CLERK_LONG_LIVED_TOKEN= TUNNEL_BASE_ADDRESS= PLAIN_SUPPORT_KEY='PLAIN_SUPPORT_KEY' PLAIN_IDENTITY_VERIFICATION_SECRET_KEY='PLAIN_IDENTITY_VERIFICATION_SECRET_KEY' +PLAIN_CARDS_HMAC_SECRET_KEY='PLAIN_CARDS_HMAC_SECRET_KEY' + NOVU_INTERNAL_SECRET_KEY= # expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms.js). Eg: 60, "2 days", "10h", "7d" SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME='15 days' diff --git a/apps/api/src/app/support/dto/plain-card.dto.ts b/apps/api/src/app/support/dto/plain-card.dto.ts new file mode 100644 index 00000000000..d6424f61176 --- /dev/null +++ b/apps/api/src/app/support/dto/plain-card.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PlainCustomer { + @ApiProperty() + id: string; + + @ApiProperty() + externalId?: string; + + @ApiProperty() + email?: string; +} + +export class PlainTenant { + @ApiProperty() + id?: string; + + @ApiProperty() + externalId?: string; +} + +export class PlainThread { + @ApiProperty() + id?: string; + + @ApiProperty() + externalId?: string; +} + +export class PlainCardRequestDto { + @ApiProperty() + cardKeys?: string[]; + + @ApiProperty() + customer?: PlainCustomer | null; + + @ApiProperty() + tenant?: PlainTenant | null; + + @ApiProperty() + thread?: PlainThread | null; + + @ApiProperty() + timestamp: string; +} diff --git a/apps/api/src/app/support/guards/plain-cards.guard.ts b/apps/api/src/app/support/guards/plain-cards.guard.ts new file mode 100644 index 00000000000..24708d6924f --- /dev/null +++ b/apps/api/src/app/support/guards/plain-cards.guard.ts @@ -0,0 +1,18 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import crypto from 'node:crypto'; + +@Injectable() +export class PlainCardsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const request = context.switchToHttp().getRequest(); + + const requestBody = JSON.stringify(request.body); + const plainCardsHMACSecretKey = process.env.PLAIN_CARDS_HMAC_SECRET_KEY as string; + const incomingSignature = request.headers['plain-request-signature']; + if (!incomingSignature) throw new UnauthorizedException('Plain request signature is missing'); + const expectedSignature = crypto.createHmac('sha-256', plainCardsHMACSecretKey).update(requestBody).digest('hex'); + + return incomingSignature === expectedSignature; + } +} diff --git a/apps/api/src/app/support/support.controller.ts b/apps/api/src/app/support/support.controller.ts index 77a0aa64938..9ab9d13a014 100644 --- a/apps/api/src/app/support/support.controller.ts +++ b/apps/api/src/app/support/support.controller.ts @@ -1,127 +1,24 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards, Request, Response, RawBodyRequest } from '@nestjs/common'; import { UserAuthGuard, UserSession } from '@novu/application-generic'; -import { UserRepository } from '@novu/dal'; import { UserSessionData } from '@novu/shared'; import { CreateSupportThreadDto } from './dto/create-thread.dto'; -import { CreateSupportThreadUsecase } from './usecases/create-thread.usecase'; import { CreateSupportThreadCommand } from './usecases/create-thread.command'; +import { PlainCardRequestDto } from './dto/plain-card.dto'; +import { PlainCardsCommand } from './usecases/plain-cards.command'; +import { CreateSupportThreadUsecase, PlainCardsUsecase } from './usecases'; +import { PlainCardsGuard } from './guards/plain-cards.guard'; @Controller('/support') export class SupportController { constructor( - private readonly userRepository: UserRepository, - private createSupportThreadUsecase: CreateSupportThreadUsecase + private createSupportThreadUsecase: CreateSupportThreadUsecase, + private plainCardsUsecase: PlainCardsUsecase ) {} - @Post('plain/cards') - async getPlainCards() { - return { - data: {}, - - cards: [ - { - key: 'plain-customer-details', - components: [ - { - componentSpacer: { - spacerSize: 'S', - }, - }, - { - componentRow: { - rowMainContent: [ - { - componentText: { - text: 'Registered at', - textColor: 'MUTED', - }, - }, - ], - rowAsideContent: [ - { - componentText: { - text: '7/18/2024, 1:00 PM', - }, - }, - ], - }, - }, - { - componentSpacer: { - spacerSize: 'M', - }, - }, - { - componentRow: { - rowMainContent: [ - { - componentText: { - text: 'Last signed in', - textColor: 'MUTED', - }, - }, - ], - rowAsideContent: [ - { - componentText: { - text: '10/20/2024, 12:57 PM', - }, - }, - ], - }, - }, - { - componentSpacer: { - spacerSize: 'M', - }, - }, - { - componentRow: { - rowMainContent: [ - { - componentText: { - text: 'Last device used', - textColor: 'MUTED', - }, - }, - ], - rowAsideContent: [ - { - componentText: { - text: 'iPhone 13 🍎', - }, - }, - ], - }, - }, - { - componentSpacer: { - spacerSize: 'M', - }, - }, - { - componentRow: { - rowMainContent: [ - { - componentText: { - text: 'Marketing preferences', - textColor: 'MUTED', - }, - }, - ], - rowAsideContent: [ - { - componentText: { - text: 'Opted out 🙅', - }, - }, - ], - }, - }, - ], - }, - ], - }; + @UseGuards(PlainCardsGuard) + @Post('user-organizations') + async fetchUserOrganizations(@Body() body: PlainCardRequestDto) { + return this.plainCardsUsecase.fetchUserOrganizations(PlainCardsCommand.create({ ...body })); } @UseGuards(UserAuthGuard) diff --git a/apps/api/src/app/support/support.module.ts b/apps/api/src/app/support/support.module.ts index ea3ae839616..1007966f39a 100644 --- a/apps/api/src/app/support/support.module.ts +++ b/apps/api/src/app/support/support.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { SupportService } from '@novu/application-generic'; +import { OrganizationRepository } from '@novu/dal'; import { SupportController } from './support.controller'; import { SharedModule } from '../shared/shared.module'; -import { CreateSupportThreadUsecase } from './usecases/create-thread.usecase'; +import { CreateSupportThreadUsecase, PlainCardsUsecase } from './usecases'; +import { PlainCardsGuard } from './guards/plain-cards.guard'; @Module({ imports: [SharedModule], controllers: [SupportController], - providers: [CreateSupportThreadUsecase, SupportService], + providers: [CreateSupportThreadUsecase, PlainCardsUsecase, SupportService, OrganizationRepository, PlainCardsGuard], }) export class SupportModule {} diff --git a/apps/api/src/app/support/usecases/index.ts b/apps/api/src/app/support/usecases/index.ts new file mode 100644 index 00000000000..49cd186ddf2 --- /dev/null +++ b/apps/api/src/app/support/usecases/index.ts @@ -0,0 +1,2 @@ +export * from './create-thread.usecase'; +export * from './plain-cards.usecase'; diff --git a/apps/api/src/app/support/usecases/plain-cards.command.ts b/apps/api/src/app/support/usecases/plain-cards.command.ts new file mode 100644 index 00000000000..749971b1524 --- /dev/null +++ b/apps/api/src/app/support/usecases/plain-cards.command.ts @@ -0,0 +1,22 @@ +import { BaseCommand } from '@novu/application-generic'; +import { IsArray, IsDefined, IsOptional, IsString } from 'class-validator'; +import { PlainCustomer, PlainTenant, PlainThread } from '../dto/plain-card.dto'; + +export class PlainCardsCommand extends BaseCommand { + @IsOptional() + @IsArray() + cardKeys?: string[]; + + @IsOptional() + customer?: PlainCustomer | null; + + @IsOptional() + tenant?: PlainTenant | null; + + @IsOptional() + thread?: PlainThread | null; + + @IsDefined() + @IsString() + timestamp: string; +} diff --git a/apps/api/src/app/support/usecases/plain-cards.usecase.ts b/apps/api/src/app/support/usecases/plain-cards.usecase.ts new file mode 100644 index 00000000000..c81be8d3c67 --- /dev/null +++ b/apps/api/src/app/support/usecases/plain-cards.usecase.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@nestjs/common'; +import { OrganizationRepository } from '@novu/dal'; +import { PlainCardsCommand } from './plain-cards.command'; + +@Injectable() +export class PlainCardsUsecase { + constructor(private organizationRepository: OrganizationRepository) {} + async fetchUserOrganizations(command: PlainCardsCommand) { + if (!command?.customer?.externalId) { + return { + data: {}, + cards: [ + { + key: 'plain-customer-details', + components: [ + { + componentSpacer: { + spacerSize: 'S', + }, + }, + { + componentText: { + text: 'This user is not yet registered on Novu', + }, + }, + ], + }, + ], + }; + } + const organizations = await this.organizationRepository.findUserActiveOrganizations(command?.customer?.externalId); + + const organizationsComponent = organizations?.map((organization) => { + return { + componentContainer: { + containerContent: [ + { + componentSpacer: { + spacerSize: 'XS', + }, + }, + { + componentText: { + text: 'Novu Org Id', + textSize: 'S', + textColor: 'MUTED', + }, + }, + { + componentSpacer: { + spacerSize: 'XS', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: organization?._id, + textSize: 'S', + }, + }, + ], + rowAsideContent: [ + { + componentCopyButton: { + copyButtonTooltipLabel: 'Copy Novu Org Id', + copyButtonValue: organization?._id, + }, + }, + ], + }, + }, + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentText: { + text: 'Clerk Org Id', + textSize: 'S', + textColor: 'MUTED', + }, + }, + { + componentSpacer: { + spacerSize: 'XS', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: organization?.externalId, + textSize: 'S', + }, + }, + ], + rowAsideContent: [ + { + componentCopyButton: { + copyButtonTooltipLabel: 'Copy Clerk Org Id', + copyButtonValue: organization?.externalId, + }, + }, + ], + }, + }, + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentText: { + text: 'Org Name', + textSize: 'S', + textColor: 'MUTED', + }, + }, + { + componentSpacer: { + spacerSize: 'XS', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: organization?.name, + textSize: 'S', + }, + }, + ], + rowAsideContent: [ + { + componentCopyButton: { + copyButtonTooltipLabel: 'Copy Org Name', + copyButtonValue: organization?.name, + }, + }, + ], + }, + }, + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: 'Org Tier', + textSize: 'S', + }, + }, + ], + rowAsideContent: [ + { + componentText: { + text: organization?.apiServiceLevel || 'NA', + }, + }, + ], + }, + }, + + { + componentSpacer: { + spacerSize: 'M', + }, + }, + { + componentRow: { + rowMainContent: [ + { + componentText: { + text: 'Org Created At', + textSize: 'S', + }, + }, + ], + rowAsideContent: [ + { + componentText: { + text: organization?.createdAt, + }, + }, + ], + }, + }, + ], + }, + }; + }); + + return { + data: {}, + cards: [ + { + key: 'plain-customer-details', + components: organizationsComponent, + }, + ], + }; + } +} diff --git a/apps/api/src/config/env.validators.ts b/apps/api/src/config/env.validators.ts index 706179b815e..2bb75a1a5bb 100644 --- a/apps/api/src/config/env.validators.ts +++ b/apps/api/src/config/env.validators.ts @@ -52,6 +52,8 @@ export const envValidators = { NEW_RELIC_APP_NAME: str({ default: '' }), NEW_RELIC_LICENSE_KEY: str({ default: '' }), PLAIN_SUPPORT_KEY: str({ default: undefined }), + PLAIN_IDENTITY_VERIFICATION_SECRET_KEY: str({ default: undefined }), + PLAIN_CARDS_HMAC_SECRET_KEY: str({ default: undefined }), STRIPE_API_KEY: str({ default: undefined }), STRIPE_CONNECT_SECRET: str({ default: undefined }), }), diff --git a/apps/dashboard/src/components/header-navigation/customer-support-button.tsx b/apps/dashboard/src/components/header-navigation/customer-support-button.tsx index ce4e30d88d0..3ef44d5c411 100644 --- a/apps/dashboard/src/components/header-navigation/customer-support-button.tsx +++ b/apps/dashboard/src/components/header-navigation/customer-support-button.tsx @@ -21,6 +21,7 @@ export const CustomerSupportButton = () => { window?.Plain?.init({ appId: PLAIN_SUPPORT_CHAT_APP_ID, hideLauncher: true, + hideBranding: true, title: 'Chat with us', links: [ { @@ -44,6 +45,7 @@ export const CustomerSupportButton = () => { customerDetails: { email: currentUser?.email, emailHash: currentUser?.servicesHashes?.plain, + externalId: currentUser?._id, }, style: { brandColor: '#DD2450', diff --git a/apps/web/src/components/layout/components/v2/HeaderNav.tsx b/apps/web/src/components/layout/components/v2/HeaderNav.tsx index f6f860960fc..2da9a717d3a 100644 --- a/apps/web/src/components/layout/components/v2/HeaderNav.tsx +++ b/apps/web/src/components/layout/components/v2/HeaderNav.tsx @@ -51,6 +51,7 @@ export function HeaderNav() { window?.Plain?.init({ appId: process.env.REACT_APP_PLAIN_SUPPORT_CHAT_APP_ID, hideLauncher: true, + hideBranding: true, title: 'Chat with us', links: [ { @@ -73,6 +74,7 @@ export function HeaderNav() { customerDetails: { email: currentUser?.email, emailHash: currentUser?.servicesHashes?.plain, + externalId: currentUser?._id, }, }); } catch (error) {