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 20, 2025
1 parent c525028 commit fd2942f
Show file tree
Hide file tree
Showing 35 changed files with 1,213 additions and 65 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
184 changes: 184 additions & 0 deletions apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { randomBytes } from 'crypto';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';

const v2Prefix = '/v2';
let session: UserSession;

describe('List Subscriber Permutations', () => {
it('should not return subscribers if not matching query', async () => {
await createSubscriberAndValidate('XYZ');
await createSubscriberAndValidate('XYZ2');
const subscribers = await getAllAndValidate({
searchQuery: 'ABC',
expectedTotalResults: 0,
expectedArraySize: 0,
});
expect(subscribers).to.be.empty;
});

it('should not return subscribers if offset is bigger than available subscribers', async () => {
const uuid = generateUUID();
await create10Subscribers(uuid);
await getAllAndValidate({
searchQuery: uuid,
offset: 11,
limit: 15,
expectedTotalResults: 10,
expectedArraySize: 0,
});
});

it('should return all results within range', async () => {
const uuid = generateUUID();
await create10Subscribers(uuid);
await getAllAndValidate({
searchQuery: uuid,
offset: 0,
limit: 15,
expectedTotalResults: 10,
expectedArraySize: 10,
});
});

it('should return results without query', async () => {
const uuid = generateUUID();
await create10Subscribers(uuid);
await getAllAndValidate({
searchQuery: uuid,
offset: 0,
limit: 15,
expectedTotalResults: 10,
expectedArraySize: 10,
});
});

it('should page subscribers without overlap', async () => {
const uuid = generateUUID();
await create10Subscribers(uuid);
const listResponse1 = await getAllAndValidate({
searchQuery: uuid,
offset: 0,
limit: 5,
expectedTotalResults: 10,
expectedArraySize: 5,
});
const listResponse2 = await getAllAndValidate({
searchQuery: uuid,
offset: 5,
limit: 5,
expectedTotalResults: 10,
expectedArraySize: 5,
});
const idsDeduplicated = buildIdSet(listResponse1, listResponse2);
expect(idsDeduplicated.size).to.be.equal(10);
});
});

// Helper functions
async function createSubscriberAndValidate(nameSuffix: string = '') {
const createSubscriberDto = {
subscriberId: `test-subscriber-${nameSuffix}`,
firstName: `Test ${nameSuffix}`,
lastName: 'Subscriber',
email: `test-${nameSuffix}@subscriber.com`,
phone: '+1234567890',
};

const res = await session.testAgent.post(`/v1/subscribers`).send(createSubscriberDto);
expect(res.status).to.equal(201);

const subscriber = res.body.data;
validateCreateSubscriberResponse(subscriber, createSubscriberDto);

return subscriber;
}

async function create10Subscribers(uuid: string) {
for (let i = 0; i < 10; i += 1) {
await createSubscriberAndValidate(`${uuid}-${i}`);
}
}

async function getListSubscribers(query: string, offset: number, limit: number) {
const res = await session.testAgent.get(`${v2Prefix}/subscribers`).query({
query,
page: Math.floor(offset / limit) + 1,
limit,
});
expect(res.status).to.equal(200);

return res.body.data;
}

interface IAllAndValidate {
msgPrefix?: string;
searchQuery: string;
offset?: number;
limit?: number;
expectedTotalResults: number;
expectedArraySize: number;
}

async function getAllAndValidate({
msgPrefix = '',
searchQuery = '',
offset = 0,
limit = 50,
expectedTotalResults,
expectedArraySize,
}: IAllAndValidate) {
const listResponse = await getListSubscribers(searchQuery, offset, limit);
const summary = buildLogMsg(
{
msgPrefix,
searchQuery,
offset,
limit,
expectedTotalResults,
expectedArraySize,
},
listResponse
);

expect(listResponse.subscribers).to.be.an('array', summary);
expect(listResponse.subscribers).lengthOf(expectedArraySize, `subscribers length ${summary}`);
expect(listResponse.totalCount).to.be.equal(expectedTotalResults, `total Results don't match ${summary}`);

return listResponse.subscribers;
}

function buildLogMsg(params: IAllAndValidate, listResponse: any): string {
return `Log - msgPrefix: ${params.msgPrefix},
searchQuery: ${params.searchQuery},
offset: ${params.offset},
limit: ${params.limit},
expectedTotalResults: ${params.expectedTotalResults ?? 'Not specified'},
expectedArraySize: ${params.expectedArraySize ?? 'Not specified'}
response:
${JSON.stringify(listResponse || 'Not specified', null, 2)}`;
}

function buildIdSet(listResponse1: any[], listResponse2: any[]) {
return new Set([...extractIDs(listResponse1), ...extractIDs(listResponse2)]);
}

function extractIDs(subscribers: any[]) {
return subscribers.map((subscriber) => subscriber._id);
}

function generateUUID(): string {
const randomHex = () => randomBytes(2).toString('hex');

return `${randomHex()}${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}${randomHex()}${randomHex()}`;
}

function validateCreateSubscriberResponse(subscriber: any, createDto: any) {
expect(subscriber).to.be.ok;
expect(subscriber._id).to.be.ok;
expect(subscriber.subscriberId).to.equal(createDto.subscriberId);
expect(subscriber.firstName).to.equal(createDto.firstName);
expect(subscriber.lastName).to.equal(createDto.lastName);
expect(subscriber.email).to.equal(createDto.email);
expect(subscriber.phone).to.equal(createDto.phone);
}
38 changes: 38 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,38 @@
import { ClassSerializerInterceptor, Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { UserAuthGuard, UserSession } from '@novu/application-generic';
import { DirectionEnum, IListSubscribersRequestDto, IListSubscribersResponseDto, 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';

@Controller({ path: '/subscribers', version: '2' })
@UseInterceptors(ClassSerializerInterceptor)
@ApiTags('Subscribers')
@ApiCommonResponses()
export class SubscriberController {
constructor(private listSubscribersUsecase: ListSubscribersUseCase) {}

@Get('')
@UseGuards(UserAuthGuard)
async getSubscribers(
@UserSession() user: UserSessionData,
@Query() query: IListSubscribersRequestDto
): Promise<IListSubscribersResponseDto> {
return await this.listSubscribersUsecase.execute(
ListSubscribersCommand.create({
user,
limit: Number(query.limit || '10'),
cursor: query.cursor,
orderDirection: query.orderDirection || DirectionEnum.DESC,
orderBy: query.orderBy || 'createdAt',
query: query.query,
email: query.email,
phone: query.phone,
subscriberId: query.subscriberId,
name: query.name,
})
);
}
}
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,33 @@
import { DirectionEnum } from '@novu/shared';
import { IsOptional, IsString, IsEnum } from 'class-validator';
import { CursorPaginatedCommand } from '@novu/application-generic';

export class ListSubscribersCommand extends CursorPaginatedCommand {
@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;

@IsString()
@IsOptional()
subscriberId?: string;

@IsString()
@IsOptional()
name?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Injectable } from '@nestjs/common';
import { InstrumentUsecase } from '@novu/application-generic';
import { SubscriberRepository } from '@novu/dal';
import { DirectionEnum, IListSubscribersResponseDto } from '@novu/shared';
import { ListSubscribersCommand } from './list-subscribers.command';

@Injectable()
export class ListSubscribersUseCase {
constructor(private subscriberRepository: SubscriberRepository) {}

@InstrumentUsecase()
async execute(command: ListSubscribersCommand): Promise<IListSubscribersResponseDto> {
const query = {
_environmentId: command.user.environmentId,
_organizationId: command.user.organizationId,
} as const;

if (command.query || command.email || command.phone || command.subscriberId || command.name) {
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' } });
}

if (command.subscriberId) {
searchConditions.push({ subscriberId: { $regex: command.subscriberId, $options: 'i' } });
}

if (command.name) {
searchConditions.push({
$expr: {
$regexMatch: {
input: { $concat: ['$firstName', ' ', '$lastName'] },
regex: command.name,
options: 'i',
},
},
});
}

Object.assign(query, { $or: searchConditions });
}

if (command.cursor) {
const operator = command.orderDirection === DirectionEnum.ASC ? '$gt' : '$lt';
Object.assign(query, {
subscriberId: { [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,
nextCursor: hasMore ? subscribers[subscribers.length - 1].subscriberId : undefined,
};
}
}
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
Loading

0 comments on commit fd2942f

Please sign in to comment.