fix&feature: fixxing some logical bogs and adding cursor pagination
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddedPriorityToTaskEntity1784014433653 implements MigrationInterface {
|
||||||
|
name = 'AddedPriorityToTaskEntity1784014433653'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "tm_tasks" ADD "priority" integer NOT NULL DEFAULT '1'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "tm_tasks" DROP COLUMN "priority"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
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 {
|
export class CursorPaginationDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsString()
|
||||||
@ApiPropertyOptional({ type: "string", description: "Cursor (createdAt of last seen item)", example: "2026-07-01T10:00:00.000Z" })
|
@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;
|
cursor?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -15,4 +25,9 @@ export class CursorPaginationDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@ApiPropertyOptional({ type: "number", required: false, default: 10 })
|
@ApiPropertyOptional({ type: "number", required: false, default: 10 })
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CursorDirection)
|
||||||
|
@ApiPropertyOptional({ enum: CursorDirection, default: CursorDirection.NEXT, description: "Direction to page in relative to 'cursor'." })
|
||||||
|
direction?: CursorDirection;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -941,7 +941,8 @@ export const enum TaskPhaseMessage {
|
|||||||
export const enum TaskMessage {
|
export const enum TaskMessage {
|
||||||
TASK_NOT_FOUND = "تسک مورد نظر پیدا نشد!",
|
TASK_NOT_FOUND = "تسک مورد نظر پیدا نشد!",
|
||||||
USERS_ASSIGNED_TO_TASK_NOT_PROJECT_MEMBER = "حداقل یکی از کاربران تعیین شده عضو پروژه نیستند!",
|
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 {
|
export const enum remarkMessage {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { CursorPaginatedResult } from "../interfaces/cursor-paginated-result.interface";
|
|
||||||
|
|
||||||
export function buildCursorPageFormat<T extends { createdAt: Date }>(
|
|
||||||
data: T[],
|
|
||||||
limit: number,
|
|
||||||
previousCursor?: Date,
|
|
||||||
): CursorPaginatedResult<T> {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ICursorPageFormat {
|
||||||
|
limit: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
previousCursor: string | null;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export interface CursorPaginatedResult<T> {
|
|
||||||
data: T[];
|
|
||||||
meta: {
|
|
||||||
nextCursor: string | null;
|
|
||||||
prevCursor: string | null;
|
|
||||||
hasMore: boolean;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import { FastifyRequest } from "fastify";
|
|||||||
import { Observable, map } from "rxjs";
|
import { Observable, map } from "rxjs";
|
||||||
|
|
||||||
import { IPageFormat } from "../../common/interfaces/IPagination";
|
import { IPageFormat } from "../../common/interfaces/IPagination";
|
||||||
|
import { ICursorPageFormat } from "../../common/interfaces/ICursor-pagination";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaginationInterceptor implements NestInterceptor {
|
export class PaginationInterceptor implements NestInterceptor {
|
||||||
@@ -10,7 +11,11 @@ export class PaginationInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
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 page = parseInt(query.page, 10) || 1;
|
||||||
const limit = parseInt(query.limit, 10) || 10;
|
const limit = parseInt(query.limit, 10) || 10;
|
||||||
@@ -20,17 +25,40 @@ export class PaginationInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
|
// ---------- Offset (page‑based) pagination ----------
|
||||||
if (data && (data.paginate || data.count)) {
|
if (data && (data.paginate || data.count)) {
|
||||||
const { count, paginate, ...response } = data;
|
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);
|
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;
|
return data;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,74 +1,104 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from "@nestjs/common";
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
|
||||||
import { TaskService } from '../providers/task.service';
|
import { TaskService } from "../providers/task.service";
|
||||||
import { CreateTaskDto } from '../dto/task/create-task.dto';
|
import { CreateTaskDto } from "../dto/task/create-task.dto";
|
||||||
import { UpdateTaskDto } from '../dto/task/update-task.dto';
|
import { UpdateTaskDto } from "../dto/task/update-task.dto";
|
||||||
import { ChangeTaskPhaseDto } from '../dto/task/change-task-phase.dto';
|
import { ChangeTaskPhaseDto } from "../dto/task/change-task-phase.dto";
|
||||||
import { TMTask } from '../entities/task.entity';
|
import { TMTask } from "../entities/task.entity";
|
||||||
import { AuthGuards } from '../../../common/decorators/auth-guard.decorator';
|
import { AuthGuards } from "../../../common/decorators/auth-guard.decorator";
|
||||||
import { AdminRoute } from '../../../common/decorators/admin.decorator';
|
import { AdminRoute } from "../../../common/decorators/admin.decorator";
|
||||||
import { UserDec } from '../../../common/decorators/user.decorator';
|
import { UserDec } from "../../../common/decorators/user.decorator";
|
||||||
import { ParamDto } from '../../../common/DTO/param.dto';
|
import { ParamDto } from "../../../common/DTO/param.dto";
|
||||||
import { PermissionsDec } from '../../../common/decorators/permission.decorator';
|
import { PermissionsDec } from "../../../common/decorators/permission.decorator";
|
||||||
import { PermissionEnum } from '../../users/enums/permission.enum';
|
import { PermissionEnum } from "../../users/enums/permission.enum";
|
||||||
import { ITokenPayload } from '../../auth/interfaces/IToken-payload';
|
import { ITokenPayload } from "../../auth/interfaces/IToken-payload";
|
||||||
|
import { CursorPagination } from "../../../common/decorators/cursor-pagination.decorator";
|
||||||
|
import { CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto";
|
||||||
|
|
||||||
@AuthGuards()
|
@AuthGuards()
|
||||||
@AdminRoute()
|
@AdminRoute()
|
||||||
@ApiTags('Task Manager - Tasks')
|
@ApiTags("Task Manager - Tasks")
|
||||||
@Controller('task-manager/tasks')
|
@Controller("task-manager/tasks")
|
||||||
export class TaskController {
|
export class TaskController {
|
||||||
constructor(private readonly taskService: TaskService) {}
|
constructor(private readonly taskService: TaskService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PermissionsDec(PermissionEnum.TASK)
|
@PermissionsDec(PermissionEnum.TASK)
|
||||||
@ApiOperation({ summary: 'Create a new task' })
|
@ApiOperation({ summary: "Create a new task" })
|
||||||
@ApiResponse({ status: 201, description: 'Task created', type: TMTask })
|
@ApiResponse({ status: 201, description: "Task created", type: TMTask })
|
||||||
@ApiResponse({ status: 404, description: 'TaskPhase not found' })
|
@ApiResponse({ status: 404, description: "TaskPhase not found" })
|
||||||
create(@Body() body: CreateTaskDto): Promise<TMTask> {
|
create(@Body() body: CreateTaskDto): Promise<TMTask> {
|
||||||
return this.taskService.create(body);
|
return this.taskService.create(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(":id")
|
||||||
@PermissionsDec(PermissionEnum.TASK)
|
@PermissionsDec(PermissionEnum.TASK)
|
||||||
@ApiOperation({ summary: 'Update a task' })
|
@ApiOperation({ summary: "Update a task" })
|
||||||
@ApiParam({ name: 'id', description: 'Task ID' })
|
@ApiParam({ name: "id", description: "Task ID" })
|
||||||
@ApiResponse({ status: 200, description: 'Task updated', type: TMTask })
|
@ApiResponse({ status: 200, description: "Task updated", type: TMTask })
|
||||||
@ApiResponse({ status: 404, description: 'Task or TaskPhase not found' })
|
@ApiResponse({ status: 404, description: "Task or TaskPhase not found" })
|
||||||
update(@Param('id') id: string, @Body() body: UpdateTaskDto): Promise<TMTask> {
|
update(@Param("id") id: string, @Body() body: UpdateTaskDto): Promise<TMTask> {
|
||||||
return this.taskService.update(id, body);
|
return this.taskService.update(id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/change-phase')
|
@Patch(":id/change-phase")
|
||||||
@PermissionsDec(PermissionEnum.TASK)
|
@PermissionsDec(PermissionEnum.TASK)
|
||||||
@ApiOperation({ summary: 'Move a task to a different phase within the same project' })
|
@ApiOperation({ summary: "Move a task to a different phase within the same project" })
|
||||||
@ApiParam({ name: 'id', description: 'Task ID' })
|
@ApiParam({ name: "id", description: "Task ID" })
|
||||||
@ApiResponse({ status: 200, description: 'Task phase changed', type: TMTask })
|
@ApiResponse({ status: 200, description: "Task phase changed", type: TMTask })
|
||||||
@ApiResponse({ status: 400, description: 'Target phase belongs to a different project' })
|
@ApiResponse({ status: 400, description: "Target phase belongs to a different project" })
|
||||||
@ApiResponse({ status: 404, description: 'Task or TaskPhase not found' })
|
@ApiResponse({ status: 404, description: "Task or TaskPhase not found" })
|
||||||
changePhase(@Param('id') id: string, @Body() body: ChangeTaskPhaseDto): Promise<TMTask> {
|
changePhase(@Param("id") id: string, @Body() body: ChangeTaskPhaseDto): Promise<TMTask> {
|
||||||
return this.taskService.changePhase(id, body);
|
return this.taskService.changePhase(id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(":id")
|
||||||
@PermissionsDec(PermissionEnum.TASK)
|
@PermissionsDec(PermissionEnum.TASK)
|
||||||
@ApiOperation({ summary: 'Delete a task' })
|
@ApiOperation({ summary: "Delete a task" })
|
||||||
@ApiParam({ name: 'id', description: 'Task ID' })
|
@ApiParam({ name: "id", description: "Task ID" })
|
||||||
@ApiResponse({ status: 200, description: 'Task deleted' })
|
@ApiResponse({ status: 200, description: "Task deleted" })
|
||||||
@ApiResponse({ status: 404, description: 'Task not found' })
|
@ApiResponse({ status: 404, description: "Task not found" })
|
||||||
remove(@Param('id') id: string): Promise<void> {
|
remove(@Param("id") id: string): Promise<void> {
|
||||||
return this.taskService.remove(id);
|
return this.taskService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('detail/:id')
|
@Get("detail/:id")
|
||||||
@ApiOperation({summary: 'Get Task Detail'})
|
@ApiOperation({ summary: "Get Task Detail" })
|
||||||
@ApiParam({ name: 'id', description: 'Task ID' })
|
@ApiParam({ name: "id", description: "Task ID" })
|
||||||
@ApiResponse({ status: 200, description: 'Task detail received!' })
|
@ApiResponse({ status: 200, description: "Task detail received!" })
|
||||||
@ApiResponse({ status: 404, description: 'Task not found' })
|
@ApiResponse({ status: 404, description: "Task not found" })
|
||||||
getTaskDetail(
|
getTaskDetail(@UserDec() user: ITokenPayload, @Param() paramDto: ParamDto): Promise<TMTask | null> {
|
||||||
@UserDec() user: ITokenPayload,
|
|
||||||
@Param() paramDto: ParamDto
|
|
||||||
): Promise<TMTask | null> {
|
|
||||||
return this.taskService.getTaskDetail(user.id, user.permissions, paramDto.id);
|
return this.taskService.getTaskDetail(user.id, user.permissions, paramDto.id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@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<TMTask> {
|
||||||
|
return this.taskService.changePriority(srcId, desId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class TMTask extends BaseEntity {
|
|||||||
@Column({ type: "timestamptz", nullable: true })
|
@Column({ type: "timestamptz", nullable: true })
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
|
||||||
|
@Column({type: 'int', default: 1})
|
||||||
|
priority: number;
|
||||||
|
|
||||||
// --- Relations ---
|
// --- Relations ---
|
||||||
|
|
||||||
@ManyToOne(() => TMTaskPhase, (taskPhase) => taskPhase.tasks, {
|
@ManyToOne(() => TMTaskPhase, (taskPhase) => taskPhase.tasks, {
|
||||||
|
|||||||
@@ -25,12 +25,17 @@ export class ProjectService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
relations: {
|
relations: {
|
||||||
users: true,
|
users: true,
|
||||||
|
taskPhases: true
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
users: {
|
users: {
|
||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true
|
lastName: true
|
||||||
|
},
|
||||||
|
taskPhases: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -130,7 +135,7 @@ export class ProjectService {
|
|||||||
const isUserMemeberOfWorkspace = workspace.users.some((user) => user.id === userId);
|
const isUserMemeberOfWorkspace = workspace.users.some((user) => user.id === userId);
|
||||||
|
|
||||||
var res: [TMProject[], number];
|
var res: [TMProject[], number];
|
||||||
if (isUserMemeberOfWorkspace) {
|
if (isUserMemeberOfWorkspace && !isPermissionIncluded) {
|
||||||
res = await this.projectRepository.findUserProjects(userId, wsId, skip, limit);
|
res = await this.projectRepository.findUserProjects(userId, wsId, skip, limit);
|
||||||
} else if (isPermissionIncluded) {
|
} else if (isPermissionIncluded) {
|
||||||
res = await this.projectRepository.findAndCount({ where: { workspaceId: wsId } });
|
res = await this.projectRepository.findAndCount({ where: { workspaceId: wsId } });
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { TaskMessage, TaskPhaseMessage } from "../../../common/enums/message.enu
|
|||||||
import { TaskPhaseRepository } from "../repositories/task-phase.repository";
|
import { TaskPhaseRepository } from "../repositories/task-phase.repository";
|
||||||
import { TMTaskPhase } from "../entities/task-phase.entity";
|
import { TMTaskPhase } from "../entities/task-phase.entity";
|
||||||
import { PermissionEnum } from "../../users/enums/permission.enum";
|
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()
|
@Injectable()
|
||||||
export class TaskService {
|
export class TaskService {
|
||||||
@@ -126,4 +128,61 @@ export class TaskService {
|
|||||||
const taskDetail = await this.taskRepository.getTaskDetail(taskId);
|
const taskDetail = await this.taskRepository.getTaskDetail(taskId);
|
||||||
return taskDetail;
|
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<TMTask> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class ProjectRepository extends Repository<TMProject> {
|
|||||||
"task.title",
|
"task.title",
|
||||||
"task.startDate",
|
"task.startDate",
|
||||||
"task.endDate",
|
"task.endDate",
|
||||||
|
"task.color",
|
||||||
"remark.title",
|
"remark.title",
|
||||||
"remark.color",
|
"remark.color",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
import { TMTask } from "../entities/task.entity";
|
import { TMTask } from "../entities/task.entity";
|
||||||
|
import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto";
|
||||||
|
import { decodeCursor } from "../../utils/providers/cursor-pagination.utils";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskRepository extends Repository<TMTask> {
|
export class TaskRepository extends Repository<TMTask> {
|
||||||
@@ -41,6 +43,55 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
"user.lastName",
|
"user.lastName",
|
||||||
])
|
])
|
||||||
.where("task.id = :taskId", { taskId })
|
.where("task.id = :taskId", { taskId })
|
||||||
|
|
||||||
|
.orderBy("checkListItem.createdAt", "ASC")
|
||||||
|
|
||||||
.getOne();
|
.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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user