From 06ec73be9249fa1cf94131a62d39726907432d74 Mon Sep 17 00:00:00 2001 From: realrafi Date: Sat, 4 Jul 2026 10:14:33 +0330 Subject: [PATCH] feat: added cursor-based pagination to checklistitems --- src/common/DTO/cursor-pagination.dto.ts | 18 +++++ src/common/helpers/cursor-paginated.helper.ts | 25 +++++++ .../cursor-paginated-result.interface.ts | 9 +++ .../controllers/check-list-item.controller.ts | 75 ++++++++++--------- .../providers/check-list-item.service.ts | 43 +++++++---- .../check-list-item.repository.ts | 27 +++++-- 6 files changed, 142 insertions(+), 55 deletions(-) create mode 100644 src/common/DTO/cursor-pagination.dto.ts create mode 100644 src/common/helpers/cursor-paginated.helper.ts create mode 100644 src/common/interfaces/cursor-paginated-result.interface.ts diff --git a/src/common/DTO/cursor-pagination.dto.ts b/src/common/DTO/cursor-pagination.dto.ts new file mode 100644 index 0000000..2b2faf4 --- /dev/null +++ b/src/common/DTO/cursor-pagination.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsInt, IsISO8601, IsOptional, Max, Min } from "class-validator"; + +export class CursorPaginationDto { + @IsOptional() + @IsISO8601() + @ApiPropertyOptional({ type: "string", description: "Cursor (createdAt of last seen item)", example: "2026-07-01T10:00:00.000Z" }) + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + @Type(() => Number) + @ApiPropertyOptional({ type: "number", required: false, default: 10 }) + limit?: number; +} diff --git a/src/common/helpers/cursor-paginated.helper.ts b/src/common/helpers/cursor-paginated.helper.ts new file mode 100644 index 0000000..ec0a8b3 --- /dev/null +++ b/src/common/helpers/cursor-paginated.helper.ts @@ -0,0 +1,25 @@ +import { CursorPaginatedResult } from "../interfaces/cursor-paginated-result.interface"; + +export function buildCursorPageFormat( + data: T[], + limit: number, + previousCursor?: Date, +): CursorPaginatedResult { + const hasMore = data.length > limit; + + if (hasMore) data.pop(); + + const nextCursor = hasMore ? data[data.length - 1].createdAt.toISOString() : null; + + const prevCursor = previousCursor ? previousCursor.toISOString() : null; + + return { + data, + meta: { + nextCursor, + prevCursor, + hasMore, + limit, + }, + }; +} diff --git a/src/common/interfaces/cursor-paginated-result.interface.ts b/src/common/interfaces/cursor-paginated-result.interface.ts new file mode 100644 index 0000000..6605514 --- /dev/null +++ b/src/common/interfaces/cursor-paginated-result.interface.ts @@ -0,0 +1,9 @@ +export interface CursorPaginatedResult { + data: T[]; + meta: { + nextCursor: string | null; + prevCursor: string | null; + hasMore: boolean; + limit: number; + }; +} diff --git a/src/modules/task-manager/controllers/check-list-item.controller.ts b/src/modules/task-manager/controllers/check-list-item.controller.ts index c7529d3..5d4cb29 100644 --- a/src/modules/task-manager/controllers/check-list-item.controller.ts +++ b/src/modules/task-manager/controllers/check-list-item.controller.ts @@ -1,56 +1,59 @@ -import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; -import { CheckListItemService } from '../providers/check-list-item.service'; -import { CreateCheckListItemDto } from '../dto/check-list-item/create-check-list-item.dto'; -import { UpdateCheckListItemDto } from '../dto/check-list-item/update-check-list-item.dto'; -import { TMCheckListItem } from '../entities/check-list-item.entity'; +import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from "@nestjs/swagger"; +import { CheckListItemService } from "../providers/check-list-item.service"; +import { CreateCheckListItemDto } from "../dto/check-list-item/create-check-list-item.dto"; +import { UpdateCheckListItemDto } from "../dto/check-list-item/update-check-list-item.dto"; +import { CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto"; +import { TMCheckListItem } from "../entities/check-list-item.entity"; -@ApiTags('Task Manager - Check List Items') -@Controller('task-manager/check-list-items') +@ApiTags("Task Manager - Check List Items") +@Controller("task-manager/check-list-items") export class CheckListItemController { constructor(private readonly checkListItemService: CheckListItemService) {} @Get() - @ApiOperation({ summary: 'Get all check list items, optionally filtered by task' }) - @ApiQuery({ name: 'taskId', required: false, description: 'Filter check list items by task ID' }) - @ApiResponse({ status: 200, description: 'List of check list items', type: [TMCheckListItem] }) - findAll(@Query('taskId') taskId?: string): Promise { - if (taskId) return this.checkListItemService.findByTask(taskId); - return this.checkListItemService.findAll(); + @ApiOperation({ summary: "Get all check list items, optionally filtered by task" }) + @ApiQuery({ name: "taskId", required: false, description: "Filter check list items by task ID" }) + @ApiQuery({ name: "cursor", required: false, description: "Cursor for pagination (createdAt ISO string)" }) + @ApiQuery({ name: "limit", required: false, description: "Number of records to return (max 50)" }) + @ApiResponse({ status: 200, description: "Cursor-paginated list of check list items" }) + findAll(@Query() pagination: CursorPaginationDto, @Query("taskId") taskId?: string) { + if (taskId) return this.checkListItemService.findByTask(taskId, pagination); + return this.checkListItemService.findAll(pagination); } - @Get(':id') - @ApiOperation({ summary: 'Get a check list item by ID' }) - @ApiParam({ name: 'id', description: 'CheckListItem ID' }) - @ApiResponse({ status: 200, description: 'CheckListItem found', type: TMCheckListItem }) - @ApiResponse({ status: 404, description: 'CheckListItem not found' }) - findOne(@Param('id') id: string): Promise { + @Get(":id") + @ApiOperation({ summary: "Get a check list item by ID" }) + @ApiParam({ name: "id", description: "CheckListItem ID" }) + @ApiResponse({ status: 200, description: "CheckListItem found", type: TMCheckListItem }) + @ApiResponse({ status: 404, description: "CheckListItem not found" }) + findOne(@Param("id") id: string): Promise { return this.checkListItemService.findOne(id); } @Post() - @ApiOperation({ summary: 'Create a new check list item' }) - @ApiResponse({ status: 201, description: 'CheckListItem created', type: TMCheckListItem }) - @ApiResponse({ status: 404, description: 'Task not found' }) + @ApiOperation({ summary: "Create a new check list item" }) + @ApiResponse({ status: 201, description: "CheckListItem created", type: TMCheckListItem }) + @ApiResponse({ status: 404, description: "Task not found" }) create(@Body() body: CreateCheckListItemDto): Promise { return this.checkListItemService.create(body); } - @Patch(':id') - @ApiOperation({ summary: 'Update a check list item' }) - @ApiParam({ name: 'id', description: 'CheckListItem ID' }) - @ApiResponse({ status: 200, description: 'CheckListItem updated', type: TMCheckListItem }) - @ApiResponse({ status: 404, description: 'CheckListItem or Task not found' }) - update(@Param('id') id: string, @Body() body: UpdateCheckListItemDto): Promise { + @Patch(":id") + @ApiOperation({ summary: "Update a check list item" }) + @ApiParam({ name: "id", description: "CheckListItem ID" }) + @ApiResponse({ status: 200, description: "CheckListItem updated", type: TMCheckListItem }) + @ApiResponse({ status: 404, description: "CheckListItem or Task not found" }) + update(@Param("id") id: string, @Body() body: UpdateCheckListItemDto): Promise { return this.checkListItemService.update(id, body); } - @Delete(':id') - @ApiOperation({ summary: 'Delete a check list item' }) - @ApiParam({ name: 'id', description: 'CheckListItem ID' }) - @ApiResponse({ status: 200, description: 'CheckListItem deleted' }) - @ApiResponse({ status: 404, description: 'CheckListItem not found' }) - remove(@Param('id') id: string): Promise { + @Delete(":id") + @ApiOperation({ summary: "Delete a check list item" }) + @ApiParam({ name: "id", description: "CheckListItem ID" }) + @ApiResponse({ status: 200, description: "CheckListItem deleted", type: TMCheckListItem }) + @ApiResponse({ status: 404, description: "CheckListItem not found" }) + remove(@Param("id") id: string): Promise { return this.checkListItemService.remove(id); } -} \ No newline at end of file +} diff --git a/src/modules/task-manager/providers/check-list-item.service.ts b/src/modules/task-manager/providers/check-list-item.service.ts index f421db4..d02a78f 100644 --- a/src/modules/task-manager/providers/check-list-item.service.ts +++ b/src/modules/task-manager/providers/check-list-item.service.ts @@ -1,9 +1,12 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { CheckListItemRepository } from "../repositories/check-list-item.repository"; -import { TaskRepository } from "../repositories/task.repository"; -import { TMCheckListItem } from "../entities/check-list-item.entity"; -import { CreateCheckListItemDto } from "../dto/check-list-item/create-check-list-item.dto"; -import { UpdateCheckListItemDto } from "../dto/check-list-item/update-check-list-item.dto"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CheckListItemRepository } from '../repositories/check-list-item.repository'; +import { TaskRepository } from '../repositories/task.repository'; +import { TMCheckListItem } from '../entities/check-list-item.entity'; +import { CreateCheckListItemDto } from '../dto/check-list-item/create-check-list-item.dto'; +import { UpdateCheckListItemDto } from '../dto/check-list-item/update-check-list-item.dto'; +import { CursorPaginationDto } from '../../../common/DTO/cursor-pagination.dto'; +import { CursorPaginatedResult } from '../../../common/interfaces/cursor-paginated-result.interface'; +import { buildCursorPageFormat } from '../../../common/helpers/cursor-paginated.helper'; @Injectable() export class CheckListItemService { @@ -12,12 +15,25 @@ export class CheckListItemService { private readonly taskRepository: TaskRepository, ) {} - findAll(): Promise { - return this.checkListItemRepository.findAll(); + async findAll(pagination: CursorPaginationDto): Promise> { + const limit = pagination.limit ?? 10; + const cursorDate = pagination.cursor ? new Date(pagination.cursor) : undefined; + + const [data] = await this.checkListItemRepository.findAll(limit, cursorDate); + + return buildCursorPageFormat(data, limit, cursorDate); } - findByTask(taskId: string): Promise { - return this.checkListItemRepository.findByTask(taskId); + async findByTask( + taskId: string, + pagination: CursorPaginationDto, + ): Promise> { + const limit = pagination.limit ?? 10; + const cursorDate = pagination.cursor ? new Date(pagination.cursor) : undefined; + + const [data] = await this.checkListItemRepository.findByTask(taskId, limit, cursorDate); + + return buildCursorPageFormat(data, limit, cursorDate); } async findOne(id: string): Promise { @@ -46,8 +62,9 @@ export class CheckListItemService { return this.checkListItemRepository.save(checkListItem); } - async remove(id: string): Promise { - await this.findOne(id); + async remove(id: string): Promise { + const checkListItem = await this.findOne(id); await this.checkListItemRepository.delete(id); + return checkListItem; } -} +} \ No newline at end of file diff --git a/src/modules/task-manager/repositories/check-list-item.repository.ts b/src/modules/task-manager/repositories/check-list-item.repository.ts index 0d8c3ee..2b5d931 100644 --- a/src/modules/task-manager/repositories/check-list-item.repository.ts +++ b/src/modules/task-manager/repositories/check-list-item.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, MoreThan } from 'typeorm'; import { TMCheckListItem } from '../entities/check-list-item.entity'; @Injectable() @@ -10,15 +10,23 @@ export class CheckListItemRepository { private readonly repository: Repository, ) {} - findAll() { - return this.repository.find({ + findAll(limit: number, cursor?: Date) { + return this.repository.findAndCount({ + where: cursor ? { createdAt: MoreThan(cursor) } : {}, + order: { createdAt: 'ASC' }, + take: limit + 1, relations: ['task'], }); } - findByTask(taskId: string) { - return this.repository.find({ - where: { taskId }, + findByTask(taskId: string, limit: number, cursor?: Date) { + return this.repository.findAndCount({ + where: cursor + ? { taskId, createdAt: MoreThan(cursor) } + : { taskId }, + order: { createdAt: 'ASC' }, + take: limit + 1, + relations: ['task'], }); } @@ -29,6 +37,13 @@ export class CheckListItemRepository { }); } + findOneByCreatedAt(createdAt: Date) { + return this.repository.findOne({ + where: { createdAt }, + order: { createdAt: 'ASC' }, + }); + } + create(data: Partial) { return this.repository.create(data); }