Skip to content

Commit

Permalink
feat(api-service,dashboard): New subscribers page and api
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Jan 17, 2025
1 parent c5fd5c7 commit 1f79320
Show file tree
Hide file tree
Showing 23 changed files with 585 additions and 46 deletions.
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { WorkflowModule } from './app/workflows-v2/workflow.module';
import { WorkflowModuleV1 } from './app/workflows-v1/workflow-v1.module';
import { EnvironmentsModuleV1 } from './app/environments-v1/environments-v1.module';
import { EnvironmentsModule } from './app/environments-v2/environments.module';
import { SubscriberModule } from './app/subscribers-v2/subscriber.module';

const enterpriseImports = (): Array<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> => {
const modules: Array<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> = [];
Expand Down Expand Up @@ -97,6 +98,7 @@ const baseModules: Array<Type | DynamicModule | Promise<DynamicModule> | Forward
IntegrationModule,
ChangeModule,
SubscribersModule,
SubscriberModule,
FeedsModule,
LayoutsModule,
MessagesModule,
Expand Down
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;
}
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;
}
47 changes: 47 additions & 0 deletions apps/api/src/app/subscribers-v2/subscriber.controller.ts
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,
})
);
}
}
13 changes: 13 additions & 0 deletions apps/api/src/app/subscribers-v2/subscriber.module.ts
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 {}
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;
}
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,
};
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { IExternalSubscribersEntity, SubscriberEntity, SubscriberRepository } from '@novu/dal';
import { SubscriberDto } from '@novu/shared';

Expand All @@ -25,7 +25,7 @@ export class SearchByExternalSubscriberIds {
}

private mapFromEntity(entity: SubscriberEntity): SubscriberDto {
const { _id, createdAt, updatedAt, ...rest } = entity;
const { _id, ...rest } = entity;

return {
...rest,
Expand Down
28 changes: 28 additions & 0 deletions apps/dashboard/src/api/subscribers.ts
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;
};
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>
);
};
Loading

0 comments on commit 1f79320

Please sign in to comment.