From c3af06dc932c459011322c2e1a2a37ab7bcc9282 Mon Sep 17 00:00:00 2001 From: yusuftomilola Date: Sun, 29 Mar 2026 04:08:06 +0100 Subject: [PATCH] Added GET /courses/cursor endpoint --- src/common/dto/pagination.dto.ts | 34 +++ src/common/utils/pagination.util.spec.ts | 354 +++++++++++++++++++++++ src/common/utils/pagination.util.ts | 142 ++++++++- src/courses/courses.controller.ts | 7 +- src/courses/courses.service.ts | 44 ++- src/courses/dto/course-search.dto.ts | 23 +- 6 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 src/common/utils/pagination.util.spec.ts diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts index d6dba6a..6f2dacc 100644 --- a/src/common/dto/pagination.dto.ts +++ b/src/common/dto/pagination.dto.ts @@ -6,6 +6,11 @@ export enum SortOrder { DESC = 'DESC', } +export enum CursorDirection { + FORWARD = 'forward', + BACKWARD = 'backward', +} + export class PaginationQueryDto { @IsOptional() @Type(() => Number) @@ -34,3 +39,32 @@ export class PaginationQueryDto { @IsString() search?: string; } + +export class CursorPaginationQueryDto { + @IsOptional() + @IsString() + cursor?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @IsOptional() + @IsIn([SortOrder.ASC, SortOrder.DESC]) + order?: SortOrder = SortOrder.DESC; + + @IsOptional() + @IsIn([CursorDirection.FORWARD, CursorDirection.BACKWARD]) + direction?: CursorDirection = CursorDirection.FORWARD; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/common/utils/pagination.util.spec.ts b/src/common/utils/pagination.util.spec.ts new file mode 100644 index 0000000..52a4e1f --- /dev/null +++ b/src/common/utils/pagination.util.spec.ts @@ -0,0 +1,354 @@ +import { BadRequestException } from '@nestjs/common'; +import { + generateCursor, + decodeCursor, + validateCursor, + paginateWithCursor, + paginate, +} from './pagination.util'; +import { SortOrder, CursorDirection } from '../dto/pagination.dto'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildMockQueryBuilder(items: any[]) { + const qb: any = { + alias: 'entity', + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(items.length), + getMany: jest.fn().mockResolvedValue(items), + }; + return qb; +} + +function makeItems(count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: `id-${String(i + 1).padStart(3, '0')}`, + createdAt: new Date(2024, 0, count - i).toISOString(), + title: `Course ${i + 1}`, + })); +} + +// --------------------------------------------------------------------------- +// generateCursor +// --------------------------------------------------------------------------- + +describe('generateCursor', () => { + it('produces a non-empty base64 string', () => { + const entity = { id: 'abc-123', createdAt: '2024-06-01T00:00:00.000Z' }; + const cursor = generateCursor(entity, 'createdAt'); + expect(typeof cursor).toBe('string'); + expect(cursor.length).toBeGreaterThan(0); + }); + + it('encodes id and sortValue', () => { + const entity = { id: 'uuid-x', createdAt: '2024-06-01T00:00:00.000Z' }; + const cursor = generateCursor(entity, 'createdAt'); + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + expect(decoded.id).toBe('uuid-x'); + expect(decoded.sortValue).toBe('2024-06-01T00:00:00.000Z'); + }); + + it('generates different cursors for different entities', () => { + const e1 = { id: 'id-1', createdAt: '2024-01-01' }; + const e2 = { id: 'id-2', createdAt: '2024-01-02' }; + expect(generateCursor(e1, 'createdAt')).not.toBe(generateCursor(e2, 'createdAt')); + }); + + it('generates different cursors for same createdAt but different ids', () => { + const ts = '2024-01-01T00:00:00.000Z'; + const e1 = { id: 'id-1', createdAt: ts }; + const e2 = { id: 'id-2', createdAt: ts }; + expect(generateCursor(e1, 'createdAt')).not.toBe(generateCursor(e2, 'createdAt')); + }); +}); + +// --------------------------------------------------------------------------- +// decodeCursor +// --------------------------------------------------------------------------- + +describe('decodeCursor', () => { + it('round-trips correctly with generateCursor', () => { + const entity = { id: 'test-id', createdAt: '2024-03-15T12:00:00.000Z' }; + const cursor = generateCursor(entity, 'createdAt'); + const decoded = decodeCursor(cursor); + expect(decoded.id).toBe('test-id'); + expect(decoded.sortValue).toBe('2024-03-15T12:00:00.000Z'); + }); + + it('throws BadRequestException for non-base64 input', () => { + expect(() => decodeCursor('!!!not-base64!!!')).toThrow(BadRequestException); + }); + + it('throws BadRequestException when id field is missing', () => { + const bad = Buffer.from(JSON.stringify({ sortValue: '2024-01-01' })).toString('base64'); + expect(() => decodeCursor(bad)).toThrow(BadRequestException); + }); + + it('throws BadRequestException when sortValue field is missing', () => { + const bad = Buffer.from(JSON.stringify({ id: 'some-id' })).toString('base64'); + expect(() => decodeCursor(bad)).toThrow(BadRequestException); + }); + + it('throws BadRequestException for valid base64 but invalid JSON', () => { + const bad = Buffer.from('not json at all').toString('base64'); + expect(() => decodeCursor(bad)).toThrow(BadRequestException); + }); +}); + +// --------------------------------------------------------------------------- +// validateCursor +// --------------------------------------------------------------------------- + +describe('validateCursor', () => { + it('returns true for a cursor produced by generateCursor', () => { + const cursor = generateCursor({ id: 'id-1', createdAt: '2024-01-01' }, 'createdAt'); + expect(validateCursor(cursor)).toBe(true); + }); + + it('returns false for a random string', () => { + expect(validateCursor('garbage')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(validateCursor('')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// paginateWithCursor — forward pagination +// --------------------------------------------------------------------------- + +describe('paginateWithCursor — forward (default)', () => { + it('returns all items and no cursors when fewer items than limit', async () => { + const items = makeItems(3); + const qb = buildMockQueryBuilder(items); + + const result = await paginateWithCursor(qb, { limit: 10 }); + + expect(result.data).toHaveLength(3); + expect(result.meta.hasNextPage).toBe(false); + expect(result.meta.hasPrevPage).toBe(false); + expect(result.meta.nextCursor).toBeNull(); + expect(result.meta.prevCursor).toBeNull(); + expect(result.meta.limit).toBe(10); + }); + + it('returns nextCursor and hasNextPage=true when limit+1 items returned', async () => { + const items = makeItems(11); // limit=10 → 11th item signals more + const qb = buildMockQueryBuilder(items); + + const result = await paginateWithCursor(qb, { limit: 10 }); + + expect(result.data).toHaveLength(10); + expect(result.meta.hasNextPage).toBe(true); + expect(result.meta.nextCursor).not.toBeNull(); + expect(result.meta.prevCursor).toBeNull(); // no cursor provided → first page + }); + + it('sets hasPrevPage=true and prevCursor when cursor is provided', async () => { + const items = makeItems(5); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + const result = await paginateWithCursor(qb, { cursor, limit: 10 }); + + expect(result.meta.hasPrevPage).toBe(true); + expect(result.meta.prevCursor).not.toBeNull(); + }); + + it('applies WHERE condition when cursor is provided', async () => { + const items = makeItems(3); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + await paginateWithCursor(qb, { cursor, limit: 10 }); + + expect(qb.andWhere).toHaveBeenCalledTimes(1); + const [whereStr] = qb.andWhere.mock.calls[0]; + expect(whereStr).toContain('entity.createdAt'); + expect(whereStr).toContain('entity.id'); + }); + + it('does NOT call andWhere when no cursor is provided', async () => { + const qb = buildMockQueryBuilder(makeItems(3)); + + await paginateWithCursor(qb, { limit: 10 }); + + expect(qb.andWhere).not.toHaveBeenCalled(); + }); + + it('uses DESC operator (<) for forward DESC pagination', async () => { + const items = makeItems(3); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + await paginateWithCursor(qb, { cursor, limit: 5, order: SortOrder.DESC }); + + const [whereStr] = qb.andWhere.mock.calls[0]; + expect(whereStr).toContain('<'); + }); + + it('uses ASC operator (>) for forward ASC pagination', async () => { + const items = makeItems(3); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + await paginateWithCursor(qb, { cursor, limit: 5, order: SortOrder.ASC }); + + const [whereStr] = qb.andWhere.mock.calls[0]; + expect(whereStr).toContain('>'); + }); + + it('applies orderBy with the specified sortBy field', async () => { + const qb = buildMockQueryBuilder(makeItems(2)); + + await paginateWithCursor(qb, { sortBy: 'title', order: SortOrder.ASC }); + + expect(qb.orderBy).toHaveBeenCalledWith('entity.title', SortOrder.ASC); + }); + + it('uses default limit of 10 when not specified', async () => { + const qb = buildMockQueryBuilder(makeItems(0)); + + const result = await paginateWithCursor(qb, {}); + + expect(result.meta.limit).toBe(10); + // take() called with limit+1 + expect(qb.take).toHaveBeenCalledWith(11); + }); +}); + +// --------------------------------------------------------------------------- +// paginateWithCursor — backward pagination +// --------------------------------------------------------------------------- + +describe('paginateWithCursor — backward', () => { + it('reverses results to natural order', async () => { + // Mock returns items in reversed sort order (ASC after inversion) + const items = [ + { id: 'id-001', createdAt: '2024-01-01' }, + { id: 'id-002', createdAt: '2024-01-02' }, + { id: 'id-003', createdAt: '2024-01-03' }, + ]; + const cursor = generateCursor({ id: 'id-004', createdAt: '2024-01-04' }, 'createdAt'); + const qb = buildMockQueryBuilder(items); + + const result = await paginateWithCursor(qb, { + cursor, + limit: 10, + direction: CursorDirection.BACKWARD, + }); + + // Reversed: [id-003, id-002, id-001] + expect(result.data[0].id).toBe('id-003'); + expect(result.data[2].id).toBe('id-001'); + }); + + it('sets hasNextPage=true when cursor is provided on backward navigation', async () => { + const items = makeItems(3); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + const result = await paginateWithCursor(qb, { + cursor, + limit: 10, + direction: CursorDirection.BACKWARD, + }); + + expect(result.meta.hasNextPage).toBe(true); + expect(result.meta.nextCursor).not.toBeNull(); + }); + + it('sets hasPrevPage=true and prevCursor when limit+1 items returned', async () => { + const items = makeItems(11); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + const result = await paginateWithCursor(qb, { + cursor, + limit: 10, + direction: CursorDirection.BACKWARD, + }); + + expect(result.data).toHaveLength(10); + expect(result.meta.hasPrevPage).toBe(true); + expect(result.meta.prevCursor).not.toBeNull(); + }); + + it('inverts sort order for backward direction (DESC → ASC)', async () => { + const cursor = generateCursor({ id: 'id-x', createdAt: '2024-01-10' }, 'createdAt'); + const qb = buildMockQueryBuilder([]); + + await paginateWithCursor(qb, { + cursor, + limit: 5, + order: SortOrder.DESC, + direction: CursorDirection.BACKWARD, + }); + + expect(qb.orderBy).toHaveBeenCalledWith('entity.createdAt', SortOrder.ASC); + }); + + it('uses > operator for backward DESC pagination', async () => { + const items = makeItems(2); + const cursor = generateCursor(items[0], 'createdAt'); + const qb = buildMockQueryBuilder(items); + + await paginateWithCursor(qb, { + cursor, + limit: 5, + order: SortOrder.DESC, + direction: CursorDirection.BACKWARD, + }); + + const [whereStr] = qb.andWhere.mock.calls[0]; + expect(whereStr).toContain('>'); + }); +}); + +// --------------------------------------------------------------------------- +// paginate (offset-based — regression) +// --------------------------------------------------------------------------- + +describe('paginate (offset-based)', () => { + it('returns correct metadata for a given page', async () => { + const allItems = makeItems(25); + const pageItems = allItems.slice(10, 20); + const qb = buildMockQueryBuilder(pageItems); + qb.getCount.mockResolvedValue(25); + + const result = await paginate(qb, { page: 2, limit: 10 }); + + expect(result.meta.totalItems).toBe(25); + expect(result.meta.currentPage).toBe(2); + expect(result.meta.totalPages).toBe(3); + expect(result.meta.itemsPerPage).toBe(10); + expect(result.meta.itemCount).toBe(pageItems.length); + }); + + it('applies skip and take correctly', async () => { + const qb = buildMockQueryBuilder([]); + qb.getCount.mockResolvedValue(0); + + await paginate(qb, { page: 3, limit: 5 }); + + expect(qb.skip).toHaveBeenCalledWith(10); + expect(qb.take).toHaveBeenCalledWith(5); + }); + + it('defaults to page 1 and limit 10', async () => { + const qb = buildMockQueryBuilder([]); + qb.getCount.mockResolvedValue(0); + + const result = await paginate(qb, {}); + + expect(result.meta.currentPage).toBe(1); + expect(result.meta.itemsPerPage).toBe(10); + }); +}); diff --git a/src/common/utils/pagination.util.ts b/src/common/utils/pagination.util.ts index e774b34..4a64000 100644 --- a/src/common/utils/pagination.util.ts +++ b/src/common/utils/pagination.util.ts @@ -1,5 +1,6 @@ +import { BadRequestException } from '@nestjs/common'; import { SelectQueryBuilder } from 'typeorm'; -import { PaginationQueryDto } from '../dto/pagination.dto'; +import { PaginationQueryDto, SortOrder, CursorPaginationQueryDto, CursorDirection } from '../dto/pagination.dto'; export interface PaginatedResponse { data: T[]; @@ -12,6 +13,17 @@ export interface PaginatedResponse { }; } +export interface CursorPaginatedResponse { + data: T[]; + meta: { + nextCursor: string | null; + prevCursor: string | null; + hasNextPage: boolean; + hasPrevPage: boolean; + limit: number; + }; +} + export async function paginate( queryBuilder: SelectQueryBuilder, options: PaginationQueryDto, @@ -45,3 +57,131 @@ export async function paginate( }, }; } + +/** + * Encodes entity fields into a base64 opaque cursor string. + * The cursor captures the sort field value and the entity id for stable pagination. + */ +export function generateCursor(entity: Record, sortBy: string): string { + const cursorData = { id: entity.id, sortValue: entity[sortBy] }; + return Buffer.from(JSON.stringify(cursorData)).toString('base64'); +} + +/** + * Decodes a cursor string back to its constituent fields. + * Throws BadRequestException if the cursor is malformed or missing required fields. + */ +export function decodeCursor(cursor: string): { id: string; sortValue: any } { + try { + const json = Buffer.from(cursor, 'base64').toString('utf8'); + const data = JSON.parse(json); + if (typeof data.id !== 'string' || data.sortValue === undefined) { + throw new BadRequestException('Invalid cursor structure'); + } + return data; + } catch (error) { + if (error instanceof BadRequestException) throw error; + throw new BadRequestException('Invalid cursor value'); + } +} + +/** + * Returns true if the cursor can be decoded without errors, false otherwise. + */ +export function validateCursor(cursor: string): boolean { + try { + decodeCursor(cursor); + return true; + } catch { + return false; + } +} + +/** + * Cursor-based pagination for TypeORM query builders. + * + * Supports bidirectional navigation: + * - direction=forward (default): fetch items after the cursor (newer → older for DESC) + * - direction=backward: fetch items before the cursor, reversing results to natural order + * + * The cursor encodes {id, sortValue} of a boundary item so pages stay stable even + * as new records are inserted. + */ +export async function paginateWithCursor>( + queryBuilder: SelectQueryBuilder, + options: CursorPaginationQueryDto, +): Promise> { + const limit = options.limit || 10; + const sortBy = options.sortBy || 'createdAt'; + const order = options.order || SortOrder.DESC; + const direction = options.direction || CursorDirection.FORWARD; + const alias = queryBuilder.alias; + + const isForward = direction === CursorDirection.FORWARD; + const isDesc = order === SortOrder.DESC; + + if (options.cursor) { + const { id: cursorId, sortValue } = decodeCursor(options.cursor); + + // Determine the comparison operator for WHERE clause: + // Forward + DESC → get items older than cursor → primary field uses '<' + // Forward + ASC → get items newer than cursor → primary field uses '>' + // Backward + DESC → get items newer than cursor → primary field uses '>' + // Backward + ASC → get items older than cursor → primary field uses '<' + const useGreaterThan = (isForward && !isDesc) || (!isForward && isDesc); + const op = useGreaterThan ? '>' : '<'; + + queryBuilder.andWhere( + `(${alias}.${sortBy} ${op} :sortValue` + + ` OR (${alias}.${sortBy} = :sortValue AND ${alias}.id ${op} :cursorId))`, + { sortValue, cursorId }, + ); + } + + // For backward pagination the sort is inverted so we retrieve the nearest items; + // results are reversed after fetch to restore natural reading order. + const effectiveOrder: SortOrder = isForward ? order : isDesc ? SortOrder.ASC : SortOrder.DESC; + const effectiveIdOrder: SortOrder = isForward + ? isDesc + ? SortOrder.DESC + : SortOrder.ASC + : isDesc + ? SortOrder.ASC + : SortOrder.DESC; + + queryBuilder + .orderBy(`${alias}.${sortBy}`, effectiveOrder) + .addOrderBy(`${alias}.id`, effectiveIdOrder); + + // Fetch one extra item to determine whether another page exists in this direction + const rawItems = await queryBuilder.take(limit + 1).getMany(); + const hasMore = rawItems.length > limit; + const pageData = rawItems.slice(0, limit); + + if (!isForward) { + pageData.reverse(); + } + + // nextCursor points to the last item on this page (used to paginate forward) + const nextCursor = + pageData.length > 0 && (isForward ? hasMore : !!options.cursor) + ? generateCursor(pageData[pageData.length - 1], sortBy) + : null; + + // prevCursor points to the first item on this page (used to paginate backward) + const prevCursor = + pageData.length > 0 && (!isForward ? hasMore : !!options.cursor) + ? generateCursor(pageData[0], sortBy) + : null; + + return { + data: pageData, + meta: { + nextCursor, + prevCursor, + hasNextPage: isForward ? hasMore : !!options.cursor, + hasPrevPage: !isForward ? hasMore : !!options.cursor, + limit, + }, + }; +} diff --git a/src/courses/courses.controller.ts b/src/courses/courses.controller.ts index 41a16f9..3febebd 100644 --- a/src/courses/courses.controller.ts +++ b/src/courses/courses.controller.ts @@ -13,7 +13,7 @@ import { import { CoursesService } from './courses.service'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; -import { CourseSearchDto } from './dto/course-search.dto'; +import { CourseSearchDto, CursorCourseSearchDto } from './dto/course-search.dto'; import { ModulesService } from './modules/modules.service'; import { CreateModuleDto } from './dto/create-module.dto'; import { LessonsService } from './lessons/lessons.service'; @@ -49,6 +49,11 @@ export class CoursesController { return this.coursesService.findAll(searchDto); } + @Get('cursor') + findAllWithCursor(@Query() searchDto: CursorCourseSearchDto) { + return this.coursesService.findAllWithCursor(searchDto); + } + @Get('analytics') @UseGuards(JwtAuthGuard) getAnalytics(@Request() _req) { diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 0aec6b8..97f73d0 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -3,8 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Course } from './entities/course.entity'; import { UpdateCourseDto } from './dto/update-course.dto'; -import { paginate, PaginatedResponse } from '../common/utils/pagination.util'; -import { CourseSearchDto } from './dto/course-search.dto'; +import { paginate, paginateWithCursor, PaginatedResponse, CursorPaginatedResponse } from '../common/utils/pagination.util'; +import { CourseSearchDto, CursorCourseSearchDto } from './dto/course-search.dto'; import { CachingService } from '../caching/caching.service'; import { CacheInvalidationService } from '../caching/invalidation/invalidation.service'; import { CACHE_TTL, CACHE_PREFIXES, CACHE_EVENTS } from '../caching/caching.constants'; @@ -63,6 +63,46 @@ export class CoursesService { ); } + async findAllWithCursor(filter?: CursorCourseSearchDto): Promise> { + const cacheKey = `${CACHE_PREFIXES.COURSES_LIST}:cursor:${JSON.stringify(filter || {})}`; + + return this.cachingService.getOrSet( + cacheKey, + async () => { + const query = this.coursesRepository.createQueryBuilder('course'); + + query.leftJoinAndSelect('course.instructor', 'instructor'); + + if (filter?.search) { + query.andWhere('(course.title ILIKE :search OR course.description ILIKE :search)', { + search: `%${filter.search}%`, + }); + } + + if (filter?.status) { + query.andWhere('course.status = :status', { status: filter.status }); + } + + if (filter?.instructorId) { + query.andWhere('course.instructorId = :instructorId', { + instructorId: filter.instructorId, + }); + } + + if (filter?.minPrice !== undefined) { + query.andWhere('course.price >= :minPrice', { minPrice: filter.minPrice }); + } + + if (filter?.maxPrice !== undefined) { + query.andWhere('course.price <= :maxPrice', { maxPrice: filter.maxPrice }); + } + + return await paginateWithCursor(query, filter ?? {}); + }, + CACHE_TTL.COURSE_METADATA, + ); + } + async findByIds(ids: string[]): Promise { if (ids.length === 0) return []; return await this.coursesRepository.findByIds(ids); diff --git a/src/courses/dto/course-search.dto.ts b/src/courses/dto/course-search.dto.ts index 8304031..7d10d15 100644 --- a/src/courses/dto/course-search.dto.ts +++ b/src/courses/dto/course-search.dto.ts @@ -1,6 +1,6 @@ import { IsString, IsOptional, IsNumber, IsUUID } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; -import { PaginationQueryDto } from '../../common/dto/pagination.dto'; +import { PaginationQueryDto, CursorPaginationQueryDto } from '../../common/dto/pagination.dto'; export class CourseSearchDto extends PaginationQueryDto { @ApiPropertyOptional() @@ -22,3 +22,24 @@ export class CourseSearchDto extends PaginationQueryDto { @IsUUID() instructorId?: string; } + +export class CursorCourseSearchDto extends CursorPaginationQueryDto { + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + minPrice?: number; + + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + maxPrice?: number; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + @IsUUID() + instructorId?: string; +}