From 008bddb97a9e25b8913f3d9b8fd4c8dbfa0a61d8 Mon Sep 17 00:00:00 2001 From: realrafi Date: Tue, 14 Jul 2026 13:51:16 +0330 Subject: [PATCH 1/2] fix&feature: fixxing some logical bogs and adding cursor pagination --- ...1784014433653-addedPriorityToTaskEntity.ts | 14 ++ src/common/DTO/cursor-pagination.dto.ts | 23 +++- .../decorators/cursor-pagination.decorator.ts | 29 ++++ src/common/enums/message.enum.ts | 3 +- src/common/helpers/cursor-paginated.helper.ts | 25 ---- src/common/interfaces/ICursor-pagination.ts | 7 + .../cursor-paginated-result.interface.ts | 9 -- .../interceptors/pagination.interceptor.ts | 42 +++++- .../controllers/task.controller.ts | 126 +++++++++++------- .../task-manager/entities/task.entity.ts | 3 + .../task-manager/providers/project.service.ts | 7 +- .../task-manager/providers/task.service.ts | 59 ++++++++ .../repositories/project.repository.ts | 1 + .../repositories/task.repository.ts | 51 +++++++ .../providers/cursor-pagination.utils.ts | 28 ++++ 15 files changed, 332 insertions(+), 95 deletions(-) create mode 100644 database/migrations/1784014433653-addedPriorityToTaskEntity.ts create mode 100644 src/common/decorators/cursor-pagination.decorator.ts delete mode 100644 src/common/helpers/cursor-paginated.helper.ts create mode 100644 src/common/interfaces/ICursor-pagination.ts delete mode 100644 src/common/interfaces/cursor-paginated-result.interface.ts create mode 100644 src/modules/utils/providers/cursor-pagination.utils.ts diff --git a/database/migrations/1784014433653-addedPriorityToTaskEntity.ts b/database/migrations/1784014433653-addedPriorityToTaskEntity.ts new file mode 100644 index 0000000..d050d6b --- /dev/null +++ b/database/migrations/1784014433653-addedPriorityToTaskEntity.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddedPriorityToTaskEntity1784014433653 implements MigrationInterface { + name = 'AddedPriorityToTaskEntity1784014433653' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tm_tasks" ADD "priority" integer NOT NULL DEFAULT '1'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tm_tasks" DROP COLUMN "priority"`); + } + +} diff --git a/src/common/DTO/cursor-pagination.dto.ts b/src/common/DTO/cursor-pagination.dto.ts index 2b2faf4..75ecfb9 100644 --- a/src/common/DTO/cursor-pagination.dto.ts +++ b/src/common/DTO/cursor-pagination.dto.ts @@ -1,11 +1,21 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsInt, IsISO8601, IsOptional, Max, Min } from "class-validator"; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +export enum CursorDirection { + NEXT = "next", + PREV = "prev", +} export class CursorPaginationDto { @IsOptional() - @IsISO8601() - @ApiPropertyOptional({ type: "string", description: "Cursor (createdAt of last seen item)", example: "2026-07-01T10:00:00.000Z" }) + @IsString() + @ApiPropertyOptional({ + type: "string", + description: + "Base64‑encoded cursor containing the priority, createdAt (ISO) and id of the last seen item. Leave empty for the first page.", + example: "MjAyNi0wNy0wMVQxMDowMDowMC4wMDBafDU=", + }) cursor?: string; @IsOptional() @@ -15,4 +25,9 @@ export class CursorPaginationDto { @Type(() => Number) @ApiPropertyOptional({ type: "number", required: false, default: 10 }) limit?: number; -} + + @IsOptional() + @IsEnum(CursorDirection) + @ApiPropertyOptional({ enum: CursorDirection, default: CursorDirection.NEXT, description: "Direction to page in relative to 'cursor'." }) + direction?: CursorDirection; +} \ No newline at end of file diff --git a/src/common/decorators/cursor-pagination.decorator.ts b/src/common/decorators/cursor-pagination.decorator.ts new file mode 100644 index 0000000..dce4923 --- /dev/null +++ b/src/common/decorators/cursor-pagination.decorator.ts @@ -0,0 +1,29 @@ +import { applyDecorators } from "@nestjs/common"; +import { ApiQuery } from "@nestjs/swagger"; +import { CursorDirection } from "../DTO/cursor-pagination.dto"; + +export function CursorPagination() { + return applyDecorators( + ApiQuery({ + name: "cursor", + required: false, + type: "string", + description: "Base64‑encoded cursor of the last item (contains priority, createdAt and id). Leave empty for the first page.", + example: "MjAyNi0wNy0wMVQxMDowMDowMC4wMDBafDU=", + }), + ApiQuery({ + name: "limit", + required: false, + type: "number", + description: "Number of items per page (max 50)", + example: 10, + }), + ApiQuery({ + name: "direction", + required: false, + enum: CursorDirection, + description: "Direction to page in relative to 'cursor': 'next' (default) or 'prev'.", + example: CursorDirection.NEXT, + }), + ); +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index a8cd15e..caa1c37 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -941,7 +941,8 @@ export const enum TaskPhaseMessage { export const enum TaskMessage { TASK_NOT_FOUND = "تسک مورد نظر پیدا نشد!", USERS_ASSIGNED_TO_TASK_NOT_PROJECT_MEMBER = "حداقل یکی از کاربران تعیین شده عضو پروژه نیستند!", - USER_NOT_TASK_MEMBER = "شما عضو این تسک نیستید!" + USER_NOT_TASK_MEMBER = "شما عضو این تسک نیستید!", + TASKS_NOT_IN_THE_SAME_TASK_PHASE = "تسک های مورد نظر داخل یک فاز تسک یکسان نیستند!" } export const enum remarkMessage { diff --git a/src/common/helpers/cursor-paginated.helper.ts b/src/common/helpers/cursor-paginated.helper.ts deleted file mode 100644 index ec0a8b3..0000000 --- a/src/common/helpers/cursor-paginated.helper.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/ICursor-pagination.ts b/src/common/interfaces/ICursor-pagination.ts new file mode 100644 index 0000000..7b79141 --- /dev/null +++ b/src/common/interfaces/ICursor-pagination.ts @@ -0,0 +1,7 @@ +export interface ICursorPageFormat { + limit: number; + nextCursor: string | null; + previousCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; +} \ No newline at end of file diff --git a/src/common/interfaces/cursor-paginated-result.interface.ts b/src/common/interfaces/cursor-paginated-result.interface.ts deleted file mode 100644 index 6605514..0000000 --- a/src/common/interfaces/cursor-paginated-result.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface CursorPaginatedResult { - data: T[]; - meta: { - nextCursor: string | null; - prevCursor: string | null; - hasMore: boolean; - limit: number; - }; -} diff --git a/src/core/interceptors/pagination.interceptor.ts b/src/core/interceptors/pagination.interceptor.ts index 74e3c54..f2ae9dc 100755 --- a/src/core/interceptors/pagination.interceptor.ts +++ b/src/core/interceptors/pagination.interceptor.ts @@ -3,6 +3,7 @@ import { FastifyRequest } from "fastify"; import { Observable, map } from "rxjs"; import { IPageFormat } from "../../common/interfaces/IPagination"; +import { ICursorPageFormat } from "../../common/interfaces/ICursor-pagination"; @Injectable() export class PaginationInterceptor implements NestInterceptor { @@ -10,7 +11,11 @@ export class PaginationInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); - const query = request.query as { page: string; limit: string }; + const query = request.query as { + page: string; + limit: string; + cursor?: string; // for cursor pagination + }; const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 10; @@ -20,17 +25,40 @@ export class PaginationInterceptor implements NestInterceptor { return next.handle().pipe( map((data) => { + // ---------- Offset (page‑based) pagination ---------- if (data && (data.paginate || data.count)) { const { count, paginate, ...response } = data; - this.logger.log(`paginate response from ${className}.${handlerName}`); + this.logger.log(`offset paginate response from ${className}.${handlerName}`); const pager = this.formatPage(page, limit, count, request); - - return { - pager, - ...response, - }; + return { pager, ...response }; } + // ---------- Cursor‑based pagination ---------- + if (data && data.cursorPaginate === true) { + const { + data: items, + nextCursor, + previousCursor, + hasNextPage, + hasPreviousPage, + cursorPaginate, + ...rest + } = data; + + this.logger.log(`cursor paginate response from ${className}.${handlerName}`); + + const pager: ICursorPageFormat = { + limit, + nextCursor, + previousCursor, + hasNextPage, + hasPreviousPage, + }; + + return { items, pager, ...rest }; + } + + // ---------- No pagination ---------- return data; }), ); diff --git a/src/modules/task-manager/controllers/task.controller.ts b/src/modules/task-manager/controllers/task.controller.ts index e4253b4..943a637 100644 --- a/src/modules/task-manager/controllers/task.controller.ts +++ b/src/modules/task-manager/controllers/task.controller.ts @@ -1,74 +1,104 @@ -import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; -import { TaskService } from '../providers/task.service'; -import { CreateTaskDto } from '../dto/task/create-task.dto'; -import { UpdateTaskDto } from '../dto/task/update-task.dto'; -import { ChangeTaskPhaseDto } from '../dto/task/change-task-phase.dto'; -import { TMTask } from '../entities/task.entity'; -import { AuthGuards } from '../../../common/decorators/auth-guard.decorator'; -import { AdminRoute } from '../../../common/decorators/admin.decorator'; -import { UserDec } from '../../../common/decorators/user.decorator'; -import { ParamDto } from '../../../common/DTO/param.dto'; -import { PermissionsDec } from '../../../common/decorators/permission.decorator'; -import { PermissionEnum } from '../../users/enums/permission.enum'; -import { ITokenPayload } from '../../auth/interfaces/IToken-payload'; +import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger"; +import { TaskService } from "../providers/task.service"; +import { CreateTaskDto } from "../dto/task/create-task.dto"; +import { UpdateTaskDto } from "../dto/task/update-task.dto"; +import { ChangeTaskPhaseDto } from "../dto/task/change-task-phase.dto"; +import { TMTask } from "../entities/task.entity"; +import { AuthGuards } from "../../../common/decorators/auth-guard.decorator"; +import { AdminRoute } from "../../../common/decorators/admin.decorator"; +import { UserDec } from "../../../common/decorators/user.decorator"; +import { ParamDto } from "../../../common/DTO/param.dto"; +import { PermissionsDec } from "../../../common/decorators/permission.decorator"; +import { PermissionEnum } from "../../users/enums/permission.enum"; +import { ITokenPayload } from "../../auth/interfaces/IToken-payload"; +import { CursorPagination } from "../../../common/decorators/cursor-pagination.decorator"; +import { CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto"; @AuthGuards() @AdminRoute() -@ApiTags('Task Manager - Tasks') -@Controller('task-manager/tasks') +@ApiTags("Task Manager - Tasks") +@Controller("task-manager/tasks") export class TaskController { constructor(private readonly taskService: TaskService) {} @Post() @PermissionsDec(PermissionEnum.TASK) - @ApiOperation({ summary: 'Create a new task' }) - @ApiResponse({ status: 201, description: 'Task created', type: TMTask }) - @ApiResponse({ status: 404, description: 'TaskPhase not found' }) + @ApiOperation({ summary: "Create a new task" }) + @ApiResponse({ status: 201, description: "Task created", type: TMTask }) + @ApiResponse({ status: 404, description: "TaskPhase not found" }) create(@Body() body: CreateTaskDto): Promise { return this.taskService.create(body); } - @Patch(':id') + @Patch(":id") @PermissionsDec(PermissionEnum.TASK) - @ApiOperation({ summary: 'Update a task' }) - @ApiParam({ name: 'id', description: 'Task ID' }) - @ApiResponse({ status: 200, description: 'Task updated', type: TMTask }) - @ApiResponse({ status: 404, description: 'Task or TaskPhase not found' }) - update(@Param('id') id: string, @Body() body: UpdateTaskDto): Promise { + @ApiOperation({ summary: "Update a task" }) + @ApiParam({ name: "id", description: "Task ID" }) + @ApiResponse({ status: 200, description: "Task updated", type: TMTask }) + @ApiResponse({ status: 404, description: "Task or TaskPhase not found" }) + update(@Param("id") id: string, @Body() body: UpdateTaskDto): Promise { return this.taskService.update(id, body); } - @Patch(':id/change-phase') + @Patch(":id/change-phase") @PermissionsDec(PermissionEnum.TASK) - @ApiOperation({ summary: 'Move a task to a different phase within the same project' }) - @ApiParam({ name: 'id', description: 'Task ID' }) - @ApiResponse({ status: 200, description: 'Task phase changed', type: TMTask }) - @ApiResponse({ status: 400, description: 'Target phase belongs to a different project' }) - @ApiResponse({ status: 404, description: 'Task or TaskPhase not found' }) - changePhase(@Param('id') id: string, @Body() body: ChangeTaskPhaseDto): Promise { + @ApiOperation({ summary: "Move a task to a different phase within the same project" }) + @ApiParam({ name: "id", description: "Task ID" }) + @ApiResponse({ status: 200, description: "Task phase changed", type: TMTask }) + @ApiResponse({ status: 400, description: "Target phase belongs to a different project" }) + @ApiResponse({ status: 404, description: "Task or TaskPhase not found" }) + changePhase(@Param("id") id: string, @Body() body: ChangeTaskPhaseDto): Promise { return this.taskService.changePhase(id, body); } - @Delete(':id') + @Delete(":id") @PermissionsDec(PermissionEnum.TASK) - @ApiOperation({ summary: 'Delete a task' }) - @ApiParam({ name: 'id', description: 'Task ID' }) - @ApiResponse({ status: 200, description: 'Task deleted' }) - @ApiResponse({ status: 404, description: 'Task not found' }) - remove(@Param('id') id: string): Promise { + @ApiOperation({ summary: "Delete a task" }) + @ApiParam({ name: "id", description: "Task ID" }) + @ApiResponse({ status: 200, description: "Task deleted" }) + @ApiResponse({ status: 404, description: "Task not found" }) + remove(@Param("id") id: string): Promise { return this.taskService.remove(id); } - @Get('detail/:id') - @ApiOperation({summary: 'Get Task Detail'}) - @ApiParam({ name: 'id', description: 'Task ID' }) - @ApiResponse({ status: 200, description: 'Task detail received!' }) - @ApiResponse({ status: 404, description: 'Task not found' }) - getTaskDetail( - @UserDec() user: ITokenPayload, - @Param() paramDto: ParamDto - ): Promise { + @Get("detail/:id") + @ApiOperation({ summary: "Get Task Detail" }) + @ApiParam({ name: "id", description: "Task ID" }) + @ApiResponse({ status: 200, description: "Task detail received!" }) + @ApiResponse({ status: 404, description: "Task not found" }) + getTaskDetail(@UserDec() user: ITokenPayload, @Param() paramDto: ParamDto): Promise { return this.taskService.getTaskDetail(user.id, user.permissions, paramDto.id); } -} \ No newline at end of file + + @Get("task-phase/:id") + @ApiOperation({ summary: "Get Tasks by task phase" }) + @ApiParam({ name: "id", description: "Task phase id" }) + @ApiResponse({ status: 200, description: "Got Tasks by task phase successfully!" }) + @ApiResponse({ status: 400, description: "Bad request from user." }) + @CursorPagination() + findByTaskPhase( + @Param("id") taskPhaseId: string, + @Query() pagination: CursorPaginationDto, + ): Promise<{ + data: TMTask[]; + nextCursor: string | null; + previousCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; + cursorPaginate: true; + }> { + return this.taskService.findTasksByTaskPhase(taskPhaseId, pagination); + } + + @Patch("change-priority/:srcid/:desid") + @ApiOperation({ summary: "changing the task priority" }) + @ApiParam({ name: "srcid", description: "ID of the source task" }) + @ApiParam({ name: "desid", description: "ID of the destination task" }) + @ApiResponse({ status: 200, description: "Changed the task priority successfully!" }) + @ApiResponse({ status: 400, description: "Bad Request from user." }) + @ApiResponse({ status: 404, description: "Task not found!" }) + changePriority(@Param("srcid") srcId: string, @Param("desid") desId: string): Promise { + return this.taskService.changePriority(srcId, desId); + } +} diff --git a/src/modules/task-manager/entities/task.entity.ts b/src/modules/task-manager/entities/task.entity.ts index 5461d83..2b1fecf 100644 --- a/src/modules/task-manager/entities/task.entity.ts +++ b/src/modules/task-manager/entities/task.entity.ts @@ -23,6 +23,9 @@ export class TMTask extends BaseEntity { @Column({ type: "timestamptz", nullable: true }) endDate: Date; + @Column({type: 'int', default: 1}) + priority: number; + // --- Relations --- @ManyToOne(() => TMTaskPhase, (taskPhase) => taskPhase.tasks, { diff --git a/src/modules/task-manager/providers/project.service.ts b/src/modules/task-manager/providers/project.service.ts index 9d33041..ea72b26 100644 --- a/src/modules/task-manager/providers/project.service.ts +++ b/src/modules/task-manager/providers/project.service.ts @@ -25,12 +25,17 @@ export class ProjectService { where: { id }, relations: { users: true, + taskPhases: true }, select: { users: { id: true, firstName: true, lastName: true + }, + taskPhases: { + id: true, + name: true, } } }); @@ -130,7 +135,7 @@ export class ProjectService { const isUserMemeberOfWorkspace = workspace.users.some((user) => user.id === userId); var res: [TMProject[], number]; - if (isUserMemeberOfWorkspace) { + if (isUserMemeberOfWorkspace && !isPermissionIncluded) { res = await this.projectRepository.findUserProjects(userId, wsId, skip, limit); } else if (isPermissionIncluded) { res = await this.projectRepository.findAndCount({ where: { workspaceId: wsId } }); diff --git a/src/modules/task-manager/providers/task.service.ts b/src/modules/task-manager/providers/task.service.ts index a5d840c..b6369b6 100644 --- a/src/modules/task-manager/providers/task.service.ts +++ b/src/modules/task-manager/providers/task.service.ts @@ -11,6 +11,8 @@ import { TaskMessage, TaskPhaseMessage } from "../../../common/enums/message.enu import { TaskPhaseRepository } from "../repositories/task-phase.repository"; import { TMTaskPhase } from "../entities/task-phase.entity"; import { PermissionEnum } from "../../users/enums/permission.enum"; +import { encodeCursor } from "../../utils/providers/cursor-pagination.utils"; +import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto"; @Injectable() export class TaskService { @@ -126,4 +128,61 @@ export class TaskService { const taskDetail = await this.taskRepository.getTaskDetail(taskId); return taskDetail; } + + async findTasksByTaskPhase( + taskPhaseId: string, + pagination: CursorPaginationDto, + ): Promise<{ + data: TMTask[]; + nextCursor: string | null; + previousCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; + cursorPaginate: true; + }> { + await this.findTaskPhaseOrFail(taskPhaseId); + + const isBackward = pagination.direction === CursorDirection.PREV; + const { items, hasMore } = await this.taskRepository.findTasksByTaskPhase(taskPhaseId, pagination); + + // paging backward always has more ahead (we came from there); paging forward relies on + // whether a cursor was even supplied to know whether anything precedes this page + const hasNextPage = items.length > 0 && (isBackward ? true : hasMore); + const hasPreviousPage = items.length > 0 && (isBackward ? hasMore : !!pagination.cursor); + + const nextCursor = hasNextPage ? encodeCursor(items[items.length - 1]) : null; + const previousCursor = hasPreviousPage ? encodeCursor(items[0]) : null; + + return { data: items, nextCursor, previousCursor, hasNextPage, hasPreviousPage, cursorPaginate: true }; + } + + async changePriority(srcTaskId: string, destTaskId: string): Promise { + const srcTask = await this.findOneOrFail(srcTaskId); + const desTask = await this.findOneOrFail(destTaskId); + + if (srcTask.taskPhaseId !== desTask.taskPhaseId) + throw new BadRequestException(TaskMessage.TASKS_NOT_IN_THE_SAME_TASK_PHASE); + + let movingUp: boolean = false; + + if(srcTask.priority < desTask.priority) { + movingUp = true; + } else if(srcTask.priority < desTask.priority) { + movingUp = false; + } else { + if(srcTask.createdAt > desTask.createdAt) { + movingUp = true; + }else { + movingUp = false; + } + } + + const priorityChange: number = movingUp ? 1 : -1; + + srcTask.priority = desTask.priority + priorityChange; + + await this.taskRepository.save(srcTask); + + return srcTask; + } } diff --git a/src/modules/task-manager/repositories/project.repository.ts b/src/modules/task-manager/repositories/project.repository.ts index cedb740..162e9cb 100644 --- a/src/modules/task-manager/repositories/project.repository.ts +++ b/src/modules/task-manager/repositories/project.repository.ts @@ -46,6 +46,7 @@ export class ProjectRepository extends Repository { "task.title", "task.startDate", "task.endDate", + "task.color", "remark.title", "remark.color", ]) diff --git a/src/modules/task-manager/repositories/task.repository.ts b/src/modules/task-manager/repositories/task.repository.ts index bff2359..0911473 100644 --- a/src/modules/task-manager/repositories/task.repository.ts +++ b/src/modules/task-manager/repositories/task.repository.ts @@ -2,6 +2,8 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { TMTask } from "../entities/task.entity"; +import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto"; +import { decodeCursor } from "../../utils/providers/cursor-pagination.utils"; @Injectable() export class TaskRepository extends Repository { @@ -41,6 +43,55 @@ export class TaskRepository extends Repository { "user.lastName", ]) .where("task.id = :taskId", { taskId }) + + .orderBy("checkListItem.createdAt", "ASC") + .getOne(); } + + async findTasksByTaskPhase( + taskPhaseId: string, + pagination: CursorPaginationDto, + ): Promise<{ items: TMTask[]; hasMore: boolean }> { + const limit = pagination.limit || 10; + const isBackward = pagination.direction === CursorDirection.PREV; + + // truncated to milliseconds because a JS Date (and therefore the encoded cursor) only + // has millisecond precision, while Postgres timestamptz stores microseconds - comparing + // the raw column against the cursor would otherwise re-match the boundary row itself. + const createdAtExpr = `date_trunc('milliseconds', task.createdAt)`; + + const queryBuilder = this.createQueryBuilder("task") + .where("task.taskPhaseId = :taskPhaseId", { taskPhaseId }) + .limit(limit + 1); // fetch one extra to check whether there's a further page + + if (isBackward) { + queryBuilder.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC"); + } else { + queryBuilder.orderBy("task.priority", "DESC").addOrderBy(createdAtExpr, "ASC").addOrderBy("task.id", "ASC"); + } + + if (pagination.cursor) { + const { priority: cursorPriority, createdAt: cursorCreatedAt, id: cursorId } = decodeCursor(pagination.cursor); + const priorityOp = isBackward ? ">" : "<"; + const seekOp = isBackward ? "<" : ">"; + + queryBuilder.andWhere( + `(task.priority ${priorityOp} :cursorPriority + OR (task.priority = :cursorPriority AND ${createdAtExpr} ${seekOp} :cursorCreatedAt) + OR (task.priority = :cursorPriority AND ${createdAtExpr} = :cursorCreatedAt AND task.id ${seekOp} :cursorId))`, + { cursorPriority, cursorCreatedAt, cursorId }, + ); + } + + const rows = await queryBuilder.getMany(); + const hasMore = rows.length > limit; + if (hasMore) rows.pop(); + + // backward queries fetch in reverse order to seek from the cursor, so flip back to + // the normal display order before returning + const items = isBackward ? rows.reverse() : rows; + + return { items, hasMore }; + } } diff --git a/src/modules/utils/providers/cursor-pagination.utils.ts b/src/modules/utils/providers/cursor-pagination.utils.ts new file mode 100644 index 0000000..cd2b525 --- /dev/null +++ b/src/modules/utils/providers/cursor-pagination.utils.ts @@ -0,0 +1,28 @@ +import { BadRequestException } from "@nestjs/common"; + +interface CursorPayload { + priority: number; + createdAt: Date; + id: string; +} + +export function decodeCursor(cursor: string): CursorPayload { + try { + const decoded = Buffer.from(cursor, "base64").toString("utf-8"); + const [priorityStr, timestamp, id] = decoded.split("|"); + if (!priorityStr || !timestamp || !id) throw new Error("Invalid format"); + const priority = parseInt(priorityStr, 10); + if (isNaN(priority)) throw new Error("Priority not a number"); + const createdAt = new Date(timestamp); + if (isNaN(createdAt.getTime())) throw new Error("Invalid date"); + return { priority, createdAt, id }; + } catch { + throw new BadRequestException( + "Invalid cursor. Expected base64‑encoded string containing priority, ISO date and id (separated by '|').", + ); + } +} + +export function encodeCursor(task: { priority: number; createdAt: Date; id: string }): string { + return Buffer.from(`${task.priority}|${task.createdAt.toISOString()}|${task.id}`).toString("base64"); +} \ No newline at end of file From f35df6492ccf5cbdedb443756dcd46928c4559fc Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Tue, 14 Jul 2026 15:07:53 +0330 Subject: [PATCH 2/2] remove unused joins --- .env.dev | 75 +++++++++++++++++++ .../repositories/project.repository.ts | 18 ----- 2 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 .env.dev diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..e423091 --- /dev/null +++ b/.env.dev @@ -0,0 +1,75 @@ +NODE_ENV=production +PORT=3500 +DB_NAME=dscDev +DB_USER=postgres +DB_PASS=1RWVdlUcZSf6PoFBQO8cprZk2DTkC89ttqeJN7aU2WQRDUEusK4z2FfNqNtV4I10 +DB_HOST=78.157.34.13 +DB_PORT=5432 +SMS_API_URL=https://api.sms.ir/v1 +SMS_API_KEY=QkkNxhyXZ6GtEf1soOTtikomO3mA4LaNQDH8mol8huDIwh00 +SMS_PATTERN_OTP=842088 +SMS_PATTERN_INVOICE=683883 +SMS_PATTERN_LOGIN=149141 +SMS_PATTERN_INVOICE_CREATED=829067 +SMS_PATTERN_ANNOUNCEMENT=752330 +SMS_PATTERN_WALLET_CHARGE=727726 +SMS_PATTERN_WALLET_DEDUCTION=360785 +SMS_PATTERN_TICKET_CREATED=853506 +SMS_PATTERN_TICKET_ANSWERED=179464 +SMS_PATTERN_TICKET_ASSIGNED_ADMIN=321927 +SMS_PATTERN_INVOICE_APPROVED=303807 +SMS_PATTERN_INVOICE_PAID=533817 +SMS_PATTERN_RECURRING_INVOICE_DRAFT=219287 +SMS_PATTERN_SUBSCRIPTION_CANCELLED=166142 +SMS_PATTERN_INVOICE_REMINDER=370266 +SMS_PATTERN_INVOICE_OVERDUE=160735 +SMS_PATTERN_PAYMENT_REMINDER=629772 +SMS_PATTERN_PAYMENT_CANCELLATION=197945 +SMS_PATTERN_BLOG_NEW_COMMENT=378881 +SMS_PATTERN_NEW_SERVICE_REVIEW=593446 +SMS_PATTERN_NEW_CUSTOMER=966214 +SMS_PATTERN_NEW_SUBSCRIPTION=757095 +SMS_PATTERN_NEW_TICKET_GLOBAL=536596 +SMS_PATTERN_NEW_CRITICISM=983724 +SMS_PATTERN_USER_PASSWORD=486237 +SMTP_HOST=smtps.danakcorp.com +SMTP_PORT=465 +SMTP_USER=no-reply-danakcorp.com +SMTP_PASS=Mahyar.SR4@ +MAIL_FROM=no-reply +EMAIL_SECRET=xDkdptbC2N7LoR2Aa4Q9U/aAGOi4DxG2748 +REDIS_URI=redis://localhost:6379/1 +CACHE_TTL=180000 +THROTTLE_TTL=300000 +THROTTLE_LIMIT=10 +JWT_SECRET_KEY=2ygxIB1PJhK/L2PToqR3+52gRmUIdPfwTguYlB7ZRCdRyvA/N36jjkjbYJLoMxEL\n6xGmk5CQmCmn9c0FsBlN1g +JWT_ISSUER=console-danak +ACCESS_TOKEN_EXPIRE=80000 +REFRESH_TOKEN_EXPIRE=6 +BUCKET_NAME=danak-project +BUCKET_REGION=default +BUCKET_URL=https://s3.ir-thr-at1.arvanstorage.ir +BUCKET_ACCESS_KEY=4bd15218-60f1-4ce7-a7aa-3fd319b13372 +BUCKET_SECRET_KEY=de772e5bd62fb04fb3669e67e09ddb101db60c65d77eea66b215ce58275e6a76 +BUCKET_UPLOAD_URL=https://storage.danakcorp.com +ZARINPAL_MERCHANT_ID=fc0b7503-bba5-40d1-9f9d-dae52ac02eef +PARSIAN_LOGIN_ACCOUNT=uqXnB2q8rjkL6vN0xOh0 +SITE_URL=https://console.danakcorp.com +API_URL=https://api.danakcorp.com +CALLBACK_URL=https://api.danakcorp.com/payments/verify +TELEGRAM_BOT_TOKEN=7839690719:AAGsyfwympR4rDLm2JCx43CQh52s1o865uY +TELEGRAM_BOT_NAME=danak_logs_bot +TELEGRAM_CHAT_ID=7801476800 +EXTERNAL_API_KEYS=uqXssnB2q8rs2soa9almm1jkL6vN0xOh0 +DMENU_BACKEND_URL=https://dmenu-api.danakcorp.com +DMENU_USERNAME=danak@dsc.com +DMENU_PASSWORD=DsCdAnAk?@ABC +DKALA_BACKEND_URL=https://dkala-api.danakcorp.com +DKALA_USERNAME=danak@dsc.com +DKALA_PASSWORD=DsCdAnAk?@ABC +DPAGE_BACKEND_URL=https://dpage-api.danakcorp.com +DPAGE_USERNAME=danak@dsc.com +DPAGE_PASSWORD=DsCdAnAk?@ABC +DMAIL_BACKEND_URL=https://dmail-api.danakcorp.com +DMAIL_USERNAME=danak@dsc.com +DMAIL_PASSWORD=DsCdAnAk?@ABC \ No newline at end of file diff --git a/src/modules/task-manager/repositories/project.repository.ts b/src/modules/task-manager/repositories/project.repository.ts index 162e9cb..637cb9f 100644 --- a/src/modules/task-manager/repositories/project.repository.ts +++ b/src/modules/task-manager/repositories/project.repository.ts @@ -23,17 +23,6 @@ export class ProjectRepository extends Repository { async getProjectDetail(projectId: string): Promise { return this.createQueryBuilder("project") .leftJoinAndSelect("project.taskPhases", "taskPhase") - .leftJoinAndSelect("taskPhase.tasks", "task") - .leftJoinAndSelect("task.remarks", "remark") - - .loadRelationCountAndMap("task.attachmentCount", "task.attachments") - - .loadRelationCountAndMap("task.checkListItemCount", "task.checkListItems") - - .loadRelationCountAndMap("task.completedCheckListItemCount", "task.checkListItems", "completedCheckListItems", (qb) => - qb.where("completedCheckListItems.isDone = :isDone", { isDone: true }), - ) - .where("project.id = :projectId", { projectId }) .select([ @@ -42,13 +31,6 @@ export class ProjectRepository extends Repository { "taskPhase.id", "taskPhase.name", "taskPhase.order", - "task.id", - "task.title", - "task.startDate", - "task.endDate", - "task.color", - "remark.title", - "remark.color", ]) .orderBy("taskPhase.order", "ASC")