From 6670ead087015048263aa51ef24e0cb3a75f1a52 Mon Sep 17 00:00:00 2001 From: desiprisg Date: Wed, 22 Jan 2025 15:19:01 +0200 Subject: [PATCH] feat(api-service,dashboard): Implement before after pagination --- .../subscriber.controller.e2e.ts | 287 +++++++++++++++--- .../subscribers-v2/subscriber.controller.ts | 3 +- .../list-subscribers.command.ts | 8 +- .../list-subscribers.usecase.ts | 79 ++--- apps/dashboard/src/api/subscribers.ts | 14 +- .../src/components/primitives/table.tsx | 2 +- .../subscribers/subscriber-list.tsx | 44 ++- .../src/hooks/use-fetch-subscribers.ts | 20 +- .../src/commands/project.command.ts | 8 +- libs/dal/src/repositories/base-repository.ts | 97 ++++++ .../dto/subscriber/list-subscribers.dto.ts | 24 +- 11 files changed, 430 insertions(+), 156 deletions(-) diff --git a/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts b/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts index 90682116b1d..ad0f1afadda 100644 --- a/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts @@ -6,76 +6,261 @@ const v2Prefix = '/v2'; let session: UserSession; describe('List Subscriber Permutations', () => { - it('should not return subscribers if not matching query', async () => { + it('should not return subscribers if not matching search params', async () => { await createSubscriberAndValidate('XYZ'); await createSubscriberAndValidate('XYZ2'); const subscribers = await getAllAndValidate({ - searchQuery: 'ABC', + searchParams: { email: 'nonexistent@email.com' }, expectedTotalResults: 0, expectedArraySize: 0, }); expect(subscribers).to.be.empty; }); - it('should not return subscribers if offset is bigger than available subscribers', async () => { + it('should return all results within range', async () => { const uuid = generateUUID(); await create10Subscribers(uuid); await getAllAndValidate({ - searchQuery: uuid, - offset: 11, limit: 15, expectedTotalResults: 10, - expectedArraySize: 0, + expectedArraySize: 10, }); }); - it('should return all results within range', async () => { + it('should return results without any search params', 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 () => { + it('should page subscribers without overlap using cursors', async () => { const uuid = generateUUID(); await create10Subscribers(uuid); - await getAllAndValidate({ - searchQuery: uuid, - offset: 0, - limit: 15, - expectedTotalResults: 10, - expectedArraySize: 10, + + const firstPage = await getListSubscribers({ + limit: 5, + }); + + const secondPage = await getListSubscribers({ + after: firstPage.next, + limit: 5, + }); + + const idsDeduplicated = buildIdSet(firstPage.subscribers, secondPage.subscribers); + expect(idsDeduplicated.size).to.be.equal(10); + }); +}); + +describe('List Subscriber Search Filters', () => { + it('should find subscriber by email', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { email: `test-${uuid}@subscriber` }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].email).to.contain(uuid); + }); + + it('should find subscriber by phone', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { phone: '1234567' }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].phone).to.equal('+1234567890'); + }); + + it('should find subscriber by full name', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { name: `Test ${uuid} Subscriber` }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].firstName).to.equal(`Test ${uuid}`); + expect(subscribers[0].lastName).to.equal('Subscriber'); + }); + + it('should find subscriber by subscriberId', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { subscriberId: `test-subscriber-${uuid}` }, + expectedTotalResults: 1, + expectedArraySize: 1, }); + + expect(subscribers[0].subscriberId).to.equal(`test-subscriber-${uuid}`); }); +}); - it('should page subscribers without overlap', async () => { +describe('List Subscriber Cursor Pagination', () => { + it('should paginate forward using after cursor', async () => { const uuid = generateUUID(); await create10Subscribers(uuid); - const listResponse1 = await getAllAndValidate({ - searchQuery: uuid, - offset: 0, + + const firstPage = await getListSubscribers({ limit: 5, - expectedTotalResults: 10, - expectedArraySize: 5, }); - const listResponse2 = await getAllAndValidate({ - searchQuery: uuid, - offset: 5, + + const secondPage = await getListSubscribers({ + after: firstPage.next, limit: 5, - expectedTotalResults: 10, - expectedArraySize: 5, }); - const idsDeduplicated = buildIdSet(listResponse1, listResponse2); - expect(idsDeduplicated.size).to.be.equal(10); + + expect(firstPage.subscribers).to.have.lengthOf(5); + expect(secondPage.subscribers).to.have.lengthOf(5); + expect(firstPage.next).to.exist; + expect(secondPage.previous).to.exist; + + const idsDeduplicated = buildIdSet(firstPage.subscribers, secondPage.subscribers); + expect(idsDeduplicated.size).to.equal(10); + }); + + it('should paginate backward using before cursor', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + limit: 5, + }); + + const secondPage = await getListSubscribers({ + after: firstPage.next, + limit: 5, + }); + + const previousPage = await getListSubscribers({ + before: secondPage.previous, + limit: 5, + }); + + expect(previousPage.subscribers).to.have.lengthOf(5); + expect(previousPage.next).to.exist; + expect(previousPage.subscribers).to.deep.equal(firstPage.subscribers); + }); + + it('should handle pagination with limit=1', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + limit: 1, + }); + + expect(firstPage.subscribers).to.have.lengthOf(1); + expect(firstPage.next).to.exist; + expect(firstPage.previous).to.not.exist; + }); + + it('should return empty array when no more results after cursor', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const allResults = await getListSubscribers({ + limit: 10, + }); + + const nextPage = await getListSubscribers({ + after: allResults.next, + limit: 5, + }); + + expect(nextPage.subscribers).to.have.lengthOf(0); + expect(nextPage.next).to.not.exist; + expect(nextPage.previous).to.exist; + }); +}); + +describe('List Subscriber Sorting', () => { + it('should sort subscribers by createdAt in ascending order', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const response = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: 'asc', + limit: 10, + }); + + const timestamps = response.subscribers.map((sub) => new Date(sub.createdAt).getTime()); + const sortedTimestamps = [...timestamps].sort((a, b) => a - b); + expect(timestamps).to.deep.equal(sortedTimestamps); + }); + + it('should sort subscribers by createdAt in descending order', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const response = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: 'desc', + limit: 10, + }); + + const timestamps = response.subscribers.map((sub) => new Date(sub.createdAt).getTime()); + const sortedTimestamps = [...timestamps].sort((a, b) => b - a); + expect(timestamps).to.deep.equal(sortedTimestamps); + }); + + it('should sort subscribers by subscriberId', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const response = await getListSubscribers({ + sortBy: 'subscriberId', + sortDirection: 'asc', + limit: 10, + }); + + const ids = response.subscribers.map((sub) => sub.subscriberId); + const sortedIds = [...ids].sort(); + expect(ids).to.deep.equal(sortedIds); + }); + + it('should maintain sort order across pages', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: 'asc', + limit: 5, + }); + + const secondPage = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: 'asc', + after: firstPage.next, + limit: 5, + }); + + const allTimestamps = [ + ...firstPage.subscribers.map((sub) => new Date(sub.createdAt).getTime()), + ...secondPage.subscribers.map((sub) => new Date(sub.createdAt).getTime()), + ]; + + const sortedTimestamps = [...allTimestamps].sort((a, b) => a - b); + expect(allTimestamps).to.deep.equal(sortedTimestamps); }); }); -// Helper functions async function createSubscriberAndValidate(nameSuffix: string = '') { const createSubscriberDto = { subscriberId: `test-subscriber-${nameSuffix}`, @@ -100,12 +285,20 @@ async function create10Subscribers(uuid: string) { } } -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, - }); +interface IListSubscribersQuery { + email?: string; + phone?: string; + name?: string; + subscriberId?: string; + after?: string; + before?: string; + limit?: number; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; +} + +async function getListSubscribers(params: IListSubscribersQuery = {}) { + const res = await session.testAgent.get(`${v2Prefix}/subscribers`).query(params); expect(res.status).to.equal(200); return res.body.data; @@ -113,8 +306,7 @@ async function getListSubscribers(query: string, offset: number, limit: number) interface IAllAndValidate { msgPrefix?: string; - searchQuery: string; - offset?: number; + searchParams?: IListSubscribersQuery; limit?: number; expectedTotalResults: number; expectedArraySize: number; @@ -122,19 +314,19 @@ interface IAllAndValidate { async function getAllAndValidate({ msgPrefix = '', - searchQuery = '', - offset = 0, - limit = 50, + searchParams = {}, + limit = 15, expectedTotalResults, expectedArraySize, }: IAllAndValidate) { - const listResponse = await getListSubscribers(searchQuery, offset, limit); + const listResponse = await getListSubscribers({ + ...searchParams, + limit, + }); const summary = buildLogMsg( { msgPrefix, - searchQuery, - offset, - limit, + searchParams, expectedTotalResults, expectedArraySize, }, @@ -143,16 +335,13 @@ async function getAllAndValidate({ 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}, + searchParams: ${JSON.stringify(params.searchParams || 'Not specified', null, 2)}, expectedTotalResults: ${params.expectedTotalResults ?? 'Not specified'}, expectedArraySize: ${params.expectedArraySize ?? 'Not specified'} response: diff --git a/apps/api/src/app/subscribers-v2/subscriber.controller.ts b/apps/api/src/app/subscribers-v2/subscriber.controller.ts index 0fb2c9a60fa..45ce4c6a5f6 100644 --- a/apps/api/src/app/subscribers-v2/subscriber.controller.ts +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.ts @@ -24,7 +24,8 @@ export class SubscriberController { ListSubscribersCommand.create({ user, limit: Number(query.limit || '10'), - cursor: query.cursor, + after: query.after, + before: query.before, orderDirection: query.orderDirection || DirectionEnum.DESC, orderBy: query.orderBy || 'createdAt', email: query.email, diff --git a/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts index 497b7e87e15..ef90dd9ff1b 100644 --- a/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts @@ -1,15 +1,15 @@ import { DirectionEnum } from '@novu/shared'; import { IsOptional, IsString, IsEnum } from 'class-validator'; -import { CursorPaginatedCommand } from '@novu/application-generic'; +import { BeforeAfterPaginatedCommand } from '@novu/application-generic'; -export class ListSubscribersCommand extends CursorPaginatedCommand { +export class ListSubscribersCommand extends BeforeAfterPaginatedCommand { @IsEnum(DirectionEnum) @IsOptional() orderDirection: DirectionEnum = DirectionEnum.DESC; - @IsEnum(['updatedAt', 'createdAt', 'lastOnlineAt']) + @IsEnum(['updatedAt', 'createdAt']) @IsOptional() - orderBy: 'updatedAt' | 'createdAt' | 'lastOnlineAt' = 'createdAt'; + orderBy: 'updatedAt' | 'createdAt' = 'createdAt'; @IsString() @IsOptional() diff --git a/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts index efa78270d18..21a66030890 100644 --- a/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts @@ -10,61 +10,38 @@ export class ListSubscribersUseCase { @InstrumentUsecase() async execute(command: ListSubscribersCommand): Promise { - const query = { - _environmentId: command.user.environmentId, - _organizationId: command.user.organizationId, - } as const; - - if (command.email || command.phone || command.subscriberId || command.name) { - const searchConditions: Record[] = []; - - 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: command.subscriberId }); - } - - if (command.name) { - searchConditions.push({ - $expr: { - $regexMatch: { - input: { $concat: ['$firstName', ' ', '$lastName'] }, - regex: command.name, - options: 'i', - }, + const pagination = await this.subscriberRepository.beforeAfterPagination({ + after: command.after || undefined, + before: command.before || undefined, + paginateField: command.orderBy, + limit: command.limit, + sortDirection: command.orderDirection, + query: { + _environmentId: command.user.environmentId, + _organizationId: command.user.organizationId, + $and: [ + { + ...(command.email && { email: { $regex: command.email, $options: 'i' } }), + ...(command.phone && { phone: { $regex: command.phone, $options: 'i' } }), + ...(command.subscriberId && { subscriberId: command.subscriberId }), + ...(command.name && { + $expr: { + $regexMatch: { + input: { $concat: ['$firstName', ' ', '$lastName'] }, + regex: command.name, + options: 'i', + }, + }, + }), }, - }); - } - - Object.assign(query, { $and: searchConditions }); - } - - if (command.cursor) { - const operator = command.orderDirection === DirectionEnum.ASC ? '$gt' : '$lt'; - Object.assign(query, { - _id: { [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]._id : undefined, + subscribers: pagination.data, + next: pagination.next, + previous: pagination.previous, }; } } diff --git a/apps/dashboard/src/api/subscribers.ts b/apps/dashboard/src/api/subscribers.ts index 3c5e624a3d9..557dbcac03b 100644 --- a/apps/dashboard/src/api/subscribers.ts +++ b/apps/dashboard/src/api/subscribers.ts @@ -1,26 +1,32 @@ -import type { IEnvironment, IListSubscribersResponseDto } from '@novu/shared'; +import type { DirectionEnum, IEnvironment, IListSubscribersResponseDto } from '@novu/shared'; import { getV2 } from './api.client'; export const getSubscribers = async ({ environment, - cursor, + after, + before, limit, email, + orderDirection, phone, subscriberId, name, }: { environment: IEnvironment; - cursor: string; + after?: string; + before?: string; limit: number; email?: string; phone?: string; subscriberId?: string; name?: string; + orderDirection?: DirectionEnum; }): Promise => { const params = new URLSearchParams({ - cursor, limit: limit.toString(), + ...(after && { after }), + ...(before && { before }), + ...(orderDirection && { orderDirection }), ...(email && { email }), ...(phone && { phone }), ...(subscriberId && { subscriberId }), diff --git a/apps/dashboard/src/components/primitives/table.tsx b/apps/dashboard/src/components/primitives/table.tsx index 79a8e9bd9ca..bfcd291e7ef 100644 --- a/apps/dashboard/src/components/primitives/table.tsx +++ b/apps/dashboard/src/components/primitives/table.tsx @@ -50,7 +50,7 @@ const TableHeader = React.forwardRef ( ) diff --git a/apps/dashboard/src/components/subscribers/subscriber-list.tsx b/apps/dashboard/src/components/subscribers/subscriber-list.tsx index f270f02f2d6..3c3c9a3353c 100644 --- a/apps/dashboard/src/components/subscribers/subscriber-list.tsx +++ b/apps/dashboard/src/components/subscribers/subscriber-list.tsx @@ -6,7 +6,8 @@ import { defaultSubscribersFilters, SubscribersFilters } from '@/components/subs import { useFetchSubscribers } from '@/hooks/use-fetch-subscribers'; import { useSubscribersUrlState } from '@/hooks/use-subscribers-url-state'; import { cn } from '@/utils/ui'; -import { HTMLAttributes, useState } from 'react'; +import { DirectionEnum } from '@novu/shared'; +import { HTMLAttributes } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; type SubscriberListProps = HTMLAttributes; @@ -15,60 +16,57 @@ export const SubscriberList = (props: SubscriberListProps) => { const { className, ...rest } = props; const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [cursorHistory, setCursorHistory] = useState(['']); const { filterValues, handleFiltersChange } = useSubscribersUrlState(); const email = searchParams.get('email') || ''; const phone = searchParams.get('phone') || ''; const name = searchParams.get('name') || ''; const subscriberId = searchParams.get('subscriberId') || ''; + const orderDirection = (searchParams.get('orderDirection') as DirectionEnum) || DirectionEnum.DESC; const limit = parseInt(searchParams.get('limit') || '10'); - const currentCursor = searchParams.get('cursor') || ''; - - const currentIndex = cursorHistory.indexOf(currentCursor); + const before = searchParams.get('before') || ''; + const after = searchParams.get('after') || ''; const { data, isPending } = useFetchSubscribers( { - cursor: currentCursor, + after: after, + before: before, limit, email, phone, subscriberId, name, + orderDirection, }, { meta: { errorMessage: 'Issue fetching subscribers' } } ); const handleNext = () => { - if (!data?.nextCursor) return; + if (!data?.next) return; const newParams = new URLSearchParams(searchParams); - newParams.set('cursor', data.nextCursor); + newParams.delete('before'); - navigate(`${location.pathname}?${newParams}`); + newParams.set('after', data.next); - if (data.nextCursor && !cursorHistory.includes(data.nextCursor)) { - setCursorHistory((prev) => [...prev, data.nextCursor!]); - } + navigate(`${location.pathname}?${newParams}`); }; const handlePrevious = () => { - if (currentIndex <= 0) return; - const previousCursor = cursorHistory[currentIndex - 1]; + if (!data?.previous) return; + const newParams = new URLSearchParams(searchParams); + newParams.delete('after'); - if (previousCursor === '') { - newParams.delete('cursor'); - } else { - newParams.set('cursor', previousCursor); - } + newParams.set('before', data.previous); - navigate(`${location.pathname}?${newParams}`, { replace: true }); + navigate(`${location.pathname}?${newParams}`); }; const handleFirst = () => { const newParams = new URLSearchParams(searchParams); - newParams.delete('cursor'); + newParams.delete('after'); + newParams.delete('before'); navigate(`${location.pathname}?${newParams}`, { replace: true }); }; @@ -117,8 +115,8 @@ export const SubscriberList = (props: SubscriberListProps) => { )} {data && ( 0} + hasNext={!!data.next} + hasPrevious={!!data.previous} onNext={handleNext} onPrevious={handlePrevious} onFirst={handleFirst} diff --git a/apps/dashboard/src/hooks/use-fetch-subscribers.ts b/apps/dashboard/src/hooks/use-fetch-subscribers.ts index af4d18ae6b4..008af9bc3e9 100644 --- a/apps/dashboard/src/hooks/use-fetch-subscribers.ts +++ b/apps/dashboard/src/hooks/use-fetch-subscribers.ts @@ -1,12 +1,15 @@ import { getSubscribers } from '@/api/subscribers'; import { QueryKeys } from '@/utils/query-keys'; +import { DirectionEnum } from '@novu/shared'; import { keepPreviousData, useQuery, UseQueryOptions } from '@tanstack/react-query'; import { useEnvironment } from '../context/environment/hooks'; interface UseSubscribersParams { - cursor?: string; + after?: string; + before?: string; email?: string; phone?: string; + orderDirection?: DirectionEnum; name?: string; subscriberId?: string; limit?: number; @@ -15,7 +18,16 @@ interface UseSubscribersParams { type SubscribersResponse = Awaited>; export function useFetchSubscribers( - { cursor = '', email = '', phone = '', name = '', subscriberId = '', limit = 8 }: UseSubscribersParams = {}, + { + after = '', + before = '', + email = '', + phone = '', + orderDirection = DirectionEnum.DESC, + name = '', + subscriberId = '', + limit = 8, + }: UseSubscribersParams = {}, options: Omit, 'queryKey' | 'queryFn'> = {} ) { const { currentEnvironment } = useEnvironment(); @@ -24,10 +36,10 @@ export function useFetchSubscribers( queryKey: [ QueryKeys.fetchSubscribers, currentEnvironment?._id, - { cursor, limit, email, phone, subscriberId, name }, + { after, before, limit, email, phone, subscriberId, name, orderDirection }, ], queryFn: () => - getSubscribers({ environment: currentEnvironment!, cursor, limit, email, phone, subscriberId, name }), + getSubscribers({ environment: currentEnvironment!, after, before, limit, email, phone, subscriberId, name }), placeholderData: keepPreviousData, enabled: !!currentEnvironment?._id, refetchOnWindowFocus: true, diff --git a/libs/application-generic/src/commands/project.command.ts b/libs/application-generic/src/commands/project.command.ts index 3b16e57d8ba..ceaddede935 100644 --- a/libs/application-generic/src/commands/project.command.ts +++ b/libs/application-generic/src/commands/project.command.ts @@ -99,7 +99,7 @@ export abstract class EnvironmentCommand extends BaseCommand { @IsNotEmpty() readonly organizationId: string; } -export abstract class CursorPaginatedCommand extends EnvironmentWithUserObjectCommand { +export abstract class BeforeAfterPaginatedCommand extends EnvironmentWithUserObjectCommand { @IsDefined() @IsNumber() @Min(1) @@ -108,5 +108,9 @@ export abstract class CursorPaginatedCommand extends EnvironmentWithUserObjectCo @IsString() @IsOptional() - cursor?: string; + after?: string; + + @IsString() + @IsOptional() + before?: string; } diff --git a/libs/dal/src/repositories/base-repository.ts b/libs/dal/src/repositories/base-repository.ts index 906e3044c13..14e0e8be3f9 100644 --- a/libs/dal/src/repositories/base-repository.ts +++ b/libs/dal/src/repositories/base-repository.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ClassConstructor, plainToInstance } from 'class-transformer'; +import { DirectionEnum } from '@novu/shared'; import { ClientSession, FilterQuery, @@ -320,6 +321,102 @@ export class BaseRepository { async withTransaction(fn: Parameters[0]) { return (await this._model.db.startSession()).withTransaction(fn); } + + async beforeAfterPagination({ + query = {} as FilterQuery & T_Enforcement, + limit, + before, + after, + sortDirection = DirectionEnum.DESC, + paginateField, + enhanceQuery, + }: { + query?: FilterQuery & T_Enforcement; + limit: number; + before?: string; + after?: string; + sortDirection: DirectionEnum; + paginateField: string; + enhanceQuery?: (query: QueryWithHelpers, T_DBModel>) => any; + }): Promise<{ data: T_MappedEntity[]; next: string | null; previous: string | null }> { + if (before && after) { + throw new DalException('Cannot specify both "before" and "after" cursors at the same time.'); + } + + const isDesc = sortDirection === DirectionEnum.DESC; + const sortValue = isDesc ? -1 : 1; + + const paginationQuery: any = { ...query }; + + if (after) { + paginationQuery[paginateField] = isDesc ? { $lt: after } : { $gt: after }; + } else if (before) { + paginationQuery[paginateField] = isDesc ? { $gt: before } : { $lt: before }; + } + + let builder = this.MongooseModel.find(paginationQuery) + .sort({ [paginateField]: sortValue }) + .limit(limit + 1); + + if (enhanceQuery) { + builder = enhanceQuery(builder); + } + + const rawResults = await builder.exec(); + + const hasExtraItem = rawResults.length > limit; + const pageResults = rawResults.slice(0, limit); + + if (pageResults.length === 0) { + return { + data: [], + next: null, + previous: null, + }; + } + + let nextCursor: string | null = null; + let prevCursor: string | null = null; + + const firstItem = pageResults[0]; + const lastItem = pageResults[pageResults.length - 1]; + + if (hasExtraItem) { + nextCursor = lastItem[paginateField]; + } + + if (after) { + const prevQuery: any = { ...query }; + prevQuery[paginateField] = isDesc ? { $gt: lastItem[paginateField] } : { $lt: lastItem[paginateField] }; + + const maybePrev = await this.MongooseModel.findOne(prevQuery) + .sort({ [paginateField]: sortValue }) + .limit(1) + .exec(); + if (maybePrev) { + prevCursor = lastItem[paginateField]; + } + } else { + const boundaryValue = firstItem[paginateField]; + const oppositeQuery: any = { ...query }; + + oppositeQuery[paginateField] = isDesc ? { $gt: boundaryValue } : { $lt: boundaryValue }; + + const maybePrev = await this.MongooseModel.findOne(oppositeQuery) + .sort({ [paginateField]: sortValue }) + .exec(); + + if (maybePrev) { + prevCursor = boundaryValue; + } + } + + return { + data: this.mapEntities(pageResults), + next: nextCursor, + previous: prevCursor, + }; + } } interface IOptions { diff --git a/packages/shared/src/dto/subscriber/list-subscribers.dto.ts b/packages/shared/src/dto/subscriber/list-subscribers.dto.ts index 9e3238319fd..50998821cbf 100644 --- a/packages/shared/src/dto/subscriber/list-subscribers.dto.ts +++ b/packages/shared/src/dto/subscriber/list-subscribers.dto.ts @@ -1,16 +1,16 @@ import { ISubscriber } from '../../entities/subscriber'; import { DirectionEnum } from '../../types/response'; -import { CursorPaginationDto } from '../pagination/pagination.dto'; -import { ISubscriberResponseDto } from './subscriber.dto'; -export interface IListSubscribersRequestDto extends ISubscriberGetListQueryParams { +export interface IListSubscribersRequestDto { limit: number; - cursor?: string; + before?: string; + + after?: string; orderDirection: DirectionEnum; - orderBy: 'updatedAt' | 'createdAt' | 'lastOnlineAt'; + orderBy: 'updatedAt' | 'createdAt'; email?: string; @@ -24,17 +24,7 @@ export interface IListSubscribersRequestDto extends ISubscriberGetListQueryParam export interface IListSubscribersResponseDto { subscribers: ISubscriber[]; - hasMore: boolean; - - pageSize: number; - - nextCursor?: string; -} + next: string | null; -export interface ISubscriberGetListQueryParams - extends CursorPaginationDto { - email?: string; - phone?: string; - subscriberId?: string; - name?: string; + previous: string | null; }