-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api-service,dashboard): New subscribers page and api
- Loading branch information
Showing
21 changed files
with
505 additions
and
46 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
55 changes: 55 additions & 0 deletions
55
apps/api/src/app/subscribers-v2/dtos/subscriber-pagination.dto.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { ApiProperty } from '@nestjs/swagger'; | ||
import { DirectionEnum, SubscriberPaginationDto } from '@novu/shared'; | ||
import { Type } from 'class-transformer'; | ||
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; | ||
|
||
export class SubscriberPaginationRequestDto implements SubscriberPaginationDto { | ||
@ApiProperty({ | ||
description: 'Number of items per page', | ||
required: false, | ||
default: 10, | ||
maximum: 100, | ||
}) | ||
@IsNumber() | ||
@Min(1) | ||
@Max(100) | ||
@IsOptional() | ||
@Type(() => Number) | ||
limit: number = 10; | ||
|
||
@ApiProperty({ | ||
description: 'Cursor for pagination', | ||
required: false, | ||
}) | ||
@IsString() | ||
@IsOptional() | ||
cursor?: string; | ||
|
||
@ApiProperty({ | ||
description: 'Sort direction', | ||
required: false, | ||
enum: DirectionEnum, | ||
default: DirectionEnum.DESC, | ||
}) | ||
@IsEnum(DirectionEnum) | ||
@IsOptional() | ||
orderDirection?: DirectionEnum = DirectionEnum.DESC; | ||
|
||
@ApiProperty({ | ||
description: 'Field to order by', | ||
required: false, | ||
enum: ['updatedAt', 'createdAt', 'lastOnlineAt'], | ||
default: 'createdAt', | ||
}) | ||
@IsEnum(['updatedAt', 'createdAt', 'lastOnlineAt']) | ||
@IsOptional() | ||
orderBy?: 'updatedAt' | 'createdAt' | 'lastOnlineAt' = 'createdAt'; | ||
|
||
@ApiProperty({ | ||
description: 'Search query to filter subscribers by name, email, phone, or subscriberId', | ||
required: false, | ||
}) | ||
@IsString() | ||
@IsOptional() | ||
query?: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common'; | ||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; | ||
import { ClassSerializerInterceptor } from '@nestjs/common'; | ||
import { UserSession, UserAuthGuard } from '@novu/application-generic'; | ||
import { ApiCommonResponses } from '../shared/framework/response.decorator'; | ||
import { DirectionEnum, ListSubscriberResponse, UserSessionData } from '@novu/shared'; | ||
|
||
import { ListSubscribersCommand } from './usecases/list-subscribers/list-subscribers.command'; | ||
import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; | ||
import { SubscriberPaginationRequestDto } from './dtos/subscriber-pagination.dto'; | ||
|
||
interface IListSubscriberResponse { | ||
data: ISubscriberResponseDto[]; | ||
hasMore: boolean; | ||
page: number; | ||
pageSize: number; | ||
} | ||
|
||
@Controller({ path: '/subscribers', version: '2' }) | ||
@UseInterceptors(ClassSerializerInterceptor) | ||
@UseGuards(UserAuthGuard) | ||
@ApiTags('Subscribers') | ||
@ApiCommonResponses() | ||
export class SubscriberController { | ||
constructor(private listSubscribersUsecase: ListSubscribersUseCase) {} | ||
|
||
@Get('') | ||
@ApiOperation({ | ||
summary: 'List subscribers', | ||
description: 'Returns a paginated list of subscribers', | ||
}) | ||
@ApiResponse({ | ||
status: 200, | ||
description: 'A list of subscribers with pagination information', | ||
type: ListSubscriberResponse, | ||
}) | ||
async getSubscribers( | ||
@UserSession() user: UserSessionData, | ||
@Query() query: SubscriberPaginationRequestDto | ||
): Promise<ListSubscriberResponse> { | ||
return await this.listSubscribersUsecase.execute( | ||
ListSubscribersCommand.create({ | ||
environmentId: user.environmentId, | ||
organizationId: user.organizationId, | ||
limit: query.limit || 10, | ||
cursor: query.cursor, | ||
orderDirection: query.orderDirection || DirectionEnum.DESC, | ||
orderBy: query.orderBy || 'createdAt', | ||
query: query.query, | ||
}) | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { Module } from '@nestjs/common'; | ||
import { SharedModule } from '../shared/shared.module'; | ||
import { SubscriberController } from './subscriber.controller'; | ||
import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; | ||
|
||
const USE_CASES = [ListSubscribersUseCase]; | ||
|
||
@Module({ | ||
imports: [SharedModule], | ||
controllers: [SubscriberController], | ||
providers: [...USE_CASES], | ||
}) | ||
export class SubscriberModule {} |
26 changes: 26 additions & 0 deletions
26
apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { IsNumber, IsOptional, IsString, Max, Min, IsEnum } from 'class-validator'; | ||
import { EnvironmentCommand } from '../../../shared/commands/project.command'; | ||
import { DirectionEnum } from '@novu/shared'; | ||
|
||
export class ListSubscribersCommand extends EnvironmentCommand { | ||
@IsNumber() | ||
@Min(1) | ||
@Max(100) | ||
limit: number; | ||
|
||
@IsString() | ||
@IsOptional() | ||
cursor?: string; | ||
|
||
@IsEnum(DirectionEnum) | ||
@IsOptional() | ||
orderDirection: DirectionEnum = DirectionEnum.DESC; | ||
|
||
@IsEnum(['updatedAt', 'createdAt', 'lastOnlineAt']) | ||
@IsOptional() | ||
orderBy: 'updatedAt' | 'createdAt' | 'lastOnlineAt' = 'createdAt'; | ||
|
||
@IsString() | ||
@IsOptional() | ||
query?: string; | ||
} |
58 changes: 58 additions & 0 deletions
58
apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { SubscriberRepository } from '@novu/dal'; | ||
import { InstrumentUsecase } from '@novu/application-generic'; | ||
import { DirectionEnum, ISubscriberResponseDto, ListSubscriberResponse } from '@novu/shared'; | ||
import { ListSubscribersCommand } from './list-subscribers.command'; | ||
|
||
@Injectable() | ||
export class ListSubscribersUseCase { | ||
constructor(private subscriberRepository: SubscriberRepository) {} | ||
|
||
@InstrumentUsecase() | ||
async execute(command: ListSubscribersCommand): Promise<ListSubscriberResponse> { | ||
const query = { | ||
_environmentId: command.environmentId, | ||
_organizationId: command.organizationId, | ||
} as const; | ||
|
||
if (command.query) { | ||
Object.assign(query, { | ||
$or: [ | ||
{ subscriberId: { $regex: command.query, $options: 'i' } }, | ||
{ email: { $regex: command.query, $options: 'i' } }, | ||
{ phone: { $regex: command.query, $options: 'i' } }, | ||
{ | ||
$expr: { | ||
$regexMatch: { | ||
input: { $concat: ['$firstName', ' ', '$lastName'] }, | ||
regex: command.query, | ||
options: 'i', | ||
}, | ||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
|
||
if (command.cursor) { | ||
const operator = command.orderDirection === DirectionEnum.ASC ? '$gt' : '$lt'; | ||
Object.assign(query, { | ||
[command.orderBy]: { [operator]: command.cursor }, | ||
}); | ||
} | ||
|
||
const subscribers = await this.subscriberRepository.find(query, undefined, { | ||
limit: command.limit + 1, // Get one extra to determine if there are more items | ||
sort: { [command.orderBy]: command.orderDirection === DirectionEnum.ASC ? 1 : -1 }, | ||
}); | ||
|
||
const hasMore = subscribers.length > command.limit; | ||
const data = hasMore ? subscribers.slice(0, -1) : subscribers; | ||
|
||
return { | ||
subscribers: data, | ||
hasMore, | ||
pageSize: command.limit, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import type { IEnvironment, ListSubscriberResponse } from '@novu/shared'; | ||
import { getV2 } from './api.client'; | ||
|
||
export const getSubscribers = async ({ | ||
environment, | ||
cursor, | ||
limit, | ||
query, | ||
}: { | ||
environment: IEnvironment; | ||
cursor: string; | ||
query: string; | ||
limit: number; | ||
}): Promise<ListSubscriberResponse> => { | ||
const { data } = await getV2<{ data: ListSubscriberResponse }>( | ||
`/subscribers?cursor=${cursor}&limit=${limit}&query=${query}`, | ||
{ | ||
environment, | ||
} | ||
); | ||
return data; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import { CreateWorkflowButton } from '@/components/create-workflow-button'; | ||
import { VersionControlDev } from '@/components/icons/version-control-dev'; | ||
import { VersionControlProd } from '@/components/icons/version-control-prod'; | ||
import { Button } from '@/components/primitives/button'; | ||
import { useEnvironment } from '@/context/environment/hooks'; | ||
import { RiBookMarkedLine, RiRouteFill } from 'react-icons/ri'; | ||
import { Link } from 'react-router-dom'; | ||
import { LinkButton } from './primitives/button-link'; | ||
|
||
export const SubscriberListEmpty = () => { | ||
const { currentEnvironment, switchEnvironment, oppositeEnvironment } = useEnvironment(); | ||
|
||
const isProd = currentEnvironment?.name === 'Production'; | ||
|
||
return isProd ? ( | ||
<WorkflowListEmptyProd switchToDev={() => switchEnvironment(oppositeEnvironment?.slug)} /> | ||
) : ( | ||
<WorkflowListEmptyDev /> | ||
); | ||
}; | ||
|
||
const WorkflowListEmptyProd = ({ switchToDev }: { switchToDev: () => void }) => ( | ||
<div className="flex h-full w-full flex-col items-center justify-center gap-6"> | ||
<VersionControlProd /> | ||
<div className="flex flex-col items-center gap-2 text-center"> | ||
<span className="text-foreground-900 block font-medium">No workflows in production</span> | ||
<p className="text-foreground-400 max-w-[60ch] text-sm"> | ||
To sync workflows to production, switch to Development environment, select a workflow and click on 'Sync to | ||
Production,' or sync via novu CLI for code-first workflows. | ||
</p> | ||
</div> | ||
|
||
<div className="flex items-center justify-center gap-6"> | ||
<Link to={'https://docs.novu.co/concepts/workflows'} target="_blank"> | ||
<LinkButton trailingIcon={RiBookMarkedLine}>View docs</LinkButton> | ||
</Link> | ||
|
||
<Button variant="secondary" className="gap-2" onClick={switchToDev}> | ||
Switch to Development | ||
</Button> | ||
</div> | ||
</div> | ||
); | ||
|
||
const WorkflowListEmptyDev = () => ( | ||
<div className="flex h-full w-full flex-col items-center justify-center gap-6"> | ||
<VersionControlDev /> | ||
<div className="flex flex-col items-center gap-2 text-center"> | ||
<span className="text-foreground-900 block font-medium">Create your first workflow to send notifications</span> | ||
<p className="text-foreground-400 max-w-[60ch] text-sm"> | ||
Workflows handle notifications across multiple channels in a single, version-controlled flow, with the ability | ||
to manage preference for each subscriber. | ||
</p> | ||
</div> | ||
|
||
<div className="flex items-center justify-center gap-6"> | ||
<Link to={'https://docs.novu.co/concepts/workflows'} target="_blank"> | ||
<LinkButton variant="gray" trailingIcon={RiBookMarkedLine}> | ||
View docs | ||
</LinkButton> | ||
</Link> | ||
|
||
<CreateWorkflowButton asChild> | ||
<Button variant="primary" leadingIcon={RiRouteFill} className="gap-2"> | ||
Create workflow | ||
</Button> | ||
</CreateWorkflowButton> | ||
</div> | ||
</div> | ||
); |
Oops, something went wrong.