fix&feature: fixxing some logical bogs and adding cursor pagination

This commit is contained in:
2026-07-14 13:51:16 +03:30
parent 8e23a06793
commit 008bddb97a
15 changed files with 332 additions and 95 deletions
@@ -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"`);
}
}
+18 -3
View File
@@ -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:
"Base64encoded 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: "Base64encoded 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,
}),
);
}
+2 -1
View File
@@ -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 (pagebased) 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,
};
} }
// ---------- Cursorbased 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 base64encoded 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");
}