-
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
23 changed files
with
585 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
79 changes: 79 additions & 0 deletions
79
apps/api/src/app/subscribers-v2/dtos/list-subscribers-request.dto.ts.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,79 @@ | ||
import { ApiProperty } from '@nestjs/swagger'; | ||
import { DirectionEnum, SubscriberGetListQueryParams } from '@novu/shared'; | ||
import { Type } from 'class-transformer'; | ||
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; | ||
|
||
export class ListSubscribersRequestDto implements SubscriberGetListQueryParams { | ||
@ApiProperty({ | ||
description: 'Number of items per page', | ||
required: false, | ||
default: 10, | ||
maximum: 100, | ||
}) | ||
@Type(() => Number) | ||
@IsNumber() | ||
@Min(1) | ||
@Max(100) | ||
@IsOptional() | ||
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; | ||
|
||
@ApiProperty({ | ||
description: 'Email to filter subscribers by', | ||
required: false, | ||
}) | ||
@IsString() | ||
@IsOptional() | ||
email?: string; | ||
|
||
@ApiProperty({ | ||
description: 'Subscriber ID to filter subscribers by', | ||
required: false, | ||
}) | ||
@IsString() | ||
@IsOptional() | ||
subscriberId?: string; | ||
|
||
@ApiProperty({ | ||
description: 'Phone number to filter subscribers by', | ||
required: false, | ||
}) | ||
@IsString() | ||
@IsOptional() | ||
phone?: string; | ||
} |
24 changes: 24 additions & 0 deletions
24
apps/api/src/app/subscribers-v2/dtos/list-subscribers-response.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,24 @@ | ||
import { ApiProperty } from '@nestjs/swagger'; | ||
import { ISubscriber } from '@novu/shared'; | ||
import { SubscriberResponseDto } from '../../subscribers/dtos'; | ||
|
||
export class ListSubscribersResponseDto { | ||
@ApiProperty({ | ||
description: 'Array of subscriber objects', | ||
type: [SubscriberResponseDto], | ||
}) | ||
subscribers: ISubscriber[]; | ||
|
||
@ApiProperty({ | ||
description: 'Indicates if there are more subscribers to fetch', | ||
required: false, | ||
type: Boolean, | ||
}) | ||
hasMore: boolean; | ||
|
||
@ApiProperty({ | ||
description: 'Number of subscribers in the current page', | ||
type: Number, | ||
}) | ||
pageSize: number; | ||
} |
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,47 @@ | ||
import { Controller, Get, Query, UseGuards, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common'; | ||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; | ||
import { UserSession, UserAuthGuard } from '@novu/application-generic'; | ||
import { DirectionEnum, ListSubscriberResponse, UserSessionData } from '@novu/shared'; | ||
import { ApiCommonResponses } from '../shared/framework/response.decorator'; | ||
|
||
import { ListSubscribersCommand } from './usecases/list-subscribers/list-subscribers.command'; | ||
import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; | ||
import { ListSubscribersRequestDto } from './dtos/list-subscribers-request.dto.ts'; | ||
|
||
@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: ListSubscribersRequestDto | ||
): 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, | ||
email: query.email, | ||
phone: query.phone, | ||
}) | ||
); | ||
} | ||
} |
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 {} |
34 changes: 34 additions & 0 deletions
34
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,34 @@ | ||
import { DirectionEnum } from '@novu/shared'; | ||
import { IsNumber, IsOptional, IsString, Max, Min, IsEnum } from 'class-validator'; | ||
import { EnvironmentCommand } from '../../../shared/commands/project.command'; | ||
|
||
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; | ||
|
||
@IsString() | ||
@IsOptional() | ||
email?: string; | ||
|
||
@IsString() | ||
@IsOptional() | ||
phone?: string; | ||
} |
73 changes: 73 additions & 0 deletions
73
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,73 @@ | ||
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'; | ||
import { ListSubscribersResponseDto } from '../../dtos/list-subscribers-response.dto'; | ||
|
||
@Injectable() | ||
export class ListSubscribersUseCase { | ||
constructor(private subscriberRepository: SubscriberRepository) {} | ||
|
||
@InstrumentUsecase() | ||
async execute(command: ListSubscribersCommand): Promise<ListSubscribersResponseDto> { | ||
const query = { | ||
_environmentId: command.environmentId, | ||
_organizationId: command.organizationId, | ||
} as const; | ||
|
||
if (command.query || command.email || command.phone) { | ||
const searchConditions: Record<string, unknown>[] = []; | ||
|
||
if (command.query) { | ||
searchConditions.push( | ||
...[ | ||
{ 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.email) { | ||
searchConditions.push({ email: { $regex: command.email, $options: 'i' } }); | ||
} | ||
|
||
if (command.phone) { | ||
searchConditions.push({ phone: { $regex: command.phone, $options: 'i' } }); | ||
} | ||
|
||
Object.assign(query, { $or: searchConditions }); | ||
} | ||
|
||
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,28 @@ | ||
import type { IEnvironment, ListSubscriberResponse } from '@novu/shared'; | ||
import { getV2 } from './api.client'; | ||
|
||
export const getSubscribers = async ({ | ||
environment, | ||
cursor, | ||
limit, | ||
query, | ||
email, | ||
phone, | ||
subscriberId, | ||
}: { | ||
environment: IEnvironment; | ||
cursor: string; | ||
query: string; | ||
limit: number; | ||
email?: string; | ||
phone?: string; | ||
subscriberId?: string; | ||
}): Promise<ListSubscriberResponse> => { | ||
const { data } = await getV2<{ data: ListSubscriberResponse }>( | ||
`/subscribers?cursor=${cursor}&limit=${limit}&query=${query}&email=${email}&phone=${phone}&subscriberId=${subscriberId}`, | ||
{ | ||
environment, | ||
} | ||
); | ||
return data; | ||
}; |
25 changes: 25 additions & 0 deletions
25
apps/dashboard/src/components/icons/add-subscriber-illustration.tsx
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,25 @@ | ||
import type { HTMLAttributes } from 'react'; | ||
|
||
type AddSubscriberIllustrationProps = HTMLAttributes<HTMLOrSVGElement>; | ||
export const AddSubscriberIllustration = (props: AddSubscriberIllustrationProps) => { | ||
return ( | ||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="126" fill="none" {...props}> | ||
<rect width="135" height="45" x="1" y="80" stroke="#CACFD8" strokeDasharray="5 3" rx="7.5" /> | ||
<rect width="127" height="37" x="5" y="84" fill="#fff" rx="5.5" /> | ||
<rect width="127" height="37" x="5" y="84" stroke="#F2F5F8" rx="5.5" /> | ||
<path fill="#99A0AE" d="M68.125 102.125v-2.25h.75v2.25h2.25v.75h-2.25v2.25h-.75v-2.25h-2.25v-.75h2.25Z" /> | ||
<rect width="135" height="45" x="1" y="1" stroke="#DD2450" rx="7.5" /> | ||
<rect width="128" height="38" x="4.5" y="4.5" fill="#fff" rx="6" /> | ||
<rect width="127" height="37" x="5" y="5" stroke="#FB3748" strokeOpacity=".24" rx="5.5" /> | ||
|
||
<g transform="translate(60, 15)"> | ||
<path | ||
fill="#D82651" | ||
d="M7.03 8.2a1.35 1.35 0 1 1 0-2.7 1.35 1.35 0 0 1 0 2.7Zm.27 4.949V11.14c0-.293.086-.562.242-.803a3.883 3.883 0 0 0-3.02.85A4.807 4.807 0 0 0 7.3 13.15Zm-3.328-3.053A5.077 5.077 0 0 1 7 9.1c.626 0 1.226.113 1.78.32a5.057 5.057 0 0 1 1.82-.32c.996 0 1.911.254 2.524.694a4.8 4.8 0 1 0-9.152.302Zm8.655.856c-.235-.32-1.024-.652-2.027-.652-1.204 0-2.1.478-2.1.84v2.16a4.797 4.797 0 0 0 4.128-2.348ZM8.5 14.5a6 6 0 1 1 0-12 6 6 0 0 1 0 12Zm2.1-5.7a1.2 1.2 0 1 1 0-2.4 1.2 1.2 0 0 1 0 2.4Z" | ||
/> | ||
</g> | ||
|
||
<path stroke="#CACFD8" strokeDasharray="5 3" strokeLinejoin="bevel" strokeWidth="1.33" d="M68.5 49.665v26.67" /> | ||
</svg> | ||
); | ||
}; |
Oops, something went wrong.