-
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
35 changed files
with
1,213 additions
and
65 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
184 changes: 184 additions & 0 deletions
184
apps/api/src/app/subscribers-v2/subscriber.controller.e2e.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,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); | ||
} |
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,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, | ||
}) | ||
); | ||
} | ||
} |
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 {} |
33 changes: 33 additions & 0 deletions
33
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,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; | ||
} |
89 changes: 89 additions & 0 deletions
89
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,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, | ||
}; | ||
} | ||
} |
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
Oops, something went wrong.