Merge branch 'rafi/taskmanager'
Build and Deploy Docker Images / build_and_deploy (push) Has been cancelled
Build and Deploy Docker Images / build_and_deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddTaskTimeTable1784100000000 implements MigrationInterface {
|
||||||
|
name = "AddTaskTimeTable1784100000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "tm_times" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
"startedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
"endedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
"duration" integer NOT NULL,
|
||||||
|
"adminId" uuid NOT NULL,
|
||||||
|
"taskId" uuid NOT NULL,
|
||||||
|
CONSTRAINT "PK_tm_times" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "tm_times"
|
||||||
|
ADD CONSTRAINT "FK_tm_times_admin"
|
||||||
|
FOREIGN KEY ("adminId") REFERENCES "user"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "tm_times"
|
||||||
|
ADD CONSTRAINT "FK_tm_times_task"
|
||||||
|
FOREIGN KEY ("taskId") REFERENCES "tm_tasks"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "tm_times" DROP CONSTRAINT "FK_tm_times_task"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "tm_times" DROP CONSTRAINT "FK_tm_times_admin"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "tm_times"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class MakeTaskTimeEndedAtAndDurationNullable1784200000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "MakeTaskTimeEndedAtAndDurationNullable1784200000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "tm_times" ALTER COLUMN "endedAt" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "tm_times" ALTER COLUMN "duration" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "tm_times" ALTER COLUMN "endedAt" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "tm_times" ALTER COLUMN "duration" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -955,4 +955,11 @@ export const enum checkListItemMessage {
|
|||||||
|
|
||||||
export const enum attachmentMessage {
|
export const enum attachmentMessage {
|
||||||
ATTACHMENT_NOT_FOUND = "ضمیمه پیدا نشد!"
|
ATTACHMENT_NOT_FOUND = "ضمیمه پیدا نشد!"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum timeMessage {
|
||||||
|
TIME_NOT_FOUND = "زمان ثبت شده پیدا نشد!",
|
||||||
|
INVALID_TIME_RANGE = "زمان پایان باید بعد از زمان شروع باشد!",
|
||||||
|
TIME_ALREADY_STARTED = "برای این تسک یک زمان در حال اجرا وجود دارد!",
|
||||||
|
NO_RUNNING_TIME = "زمان در حال اجرایی برای این تسک پیدا نشد!"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Post, Patch, Delete, Param, Body, Get } from "@nestjs/common";
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
|
||||||
|
import { TimeService } from "../providers/time.service";
|
||||||
|
import { CreateTimeDto } from "../dto/time/create-time.dto";
|
||||||
|
import { UpdateTimeDto } from "../dto/time/update-time.dto";
|
||||||
|
import { TMTime } from "../entities/time.entity";
|
||||||
|
import { AuthGuards } from "../../../common/decorators/auth-guard.decorator";
|
||||||
|
import { AdminRoute } from "../../../common/decorators/admin.decorator";
|
||||||
|
import { UserDec } from "../../../common/decorators/user.decorator";
|
||||||
|
|
||||||
|
@AuthGuards()
|
||||||
|
@AdminRoute()
|
||||||
|
@ApiTags("Task Manager - Times")
|
||||||
|
@Controller("task-manager/times")
|
||||||
|
export class TimeController {
|
||||||
|
constructor(private readonly timeService: TimeService) { }
|
||||||
|
@Get("task/:taskId")
|
||||||
|
@ApiOperation({ summary: "Get all time entries for a task" })
|
||||||
|
@ApiParam({ name: "taskId", description: "Task ID" })
|
||||||
|
getTaskTimes(@UserDec("id") adminId: string, @Param("taskId") taskId: string): Promise<TMTime[]> {
|
||||||
|
return this.timeService.getTaskTimes(adminId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: "Create a new time entry for a task" })
|
||||||
|
@ApiResponse({ status: 201, description: "Time entry created", type: TMTime })
|
||||||
|
@ApiResponse({ status: 404, description: "Task not found" })
|
||||||
|
create(@UserDec("id") adminId: string, @Body() body: CreateTimeDto): Promise<TMTime> {
|
||||||
|
return this.timeService.create(adminId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("start/:taskId")
|
||||||
|
@ApiOperation({ summary: "Start tracking time for a task" })
|
||||||
|
@ApiParam({ name: "taskId", description: "Task ID" })
|
||||||
|
@ApiResponse({ status: 201, description: "Time tracking started", type: TMTime })
|
||||||
|
@ApiResponse({ status: 400, description: "A running time entry already exists" })
|
||||||
|
@ApiResponse({ status: 404, description: "Task not found" })
|
||||||
|
start(@UserDec("id") adminId: string, @Param("taskId") taskId: string): Promise<TMTime> {
|
||||||
|
return this.timeService.start(adminId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("end/:taskId")
|
||||||
|
@ApiOperation({ summary: "Stop the current running time entry for a task" })
|
||||||
|
@ApiParam({ name: "taskId", description: "Task ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "Time tracking stopped", type: TMTime })
|
||||||
|
@ApiResponse({ status: 400, description: "No running time entry found" })
|
||||||
|
@ApiResponse({ status: 404, description: "Task not found" })
|
||||||
|
end(@UserDec("id") adminId: string, @Param("taskId") taskId: string): Promise<TMTime> {
|
||||||
|
return this.timeService.end(adminId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@ApiOperation({ summary: "Update a time entry" })
|
||||||
|
@ApiParam({ name: "id", description: "Time entry ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "Time entry updated", type: TMTime })
|
||||||
|
@ApiResponse({ status: 404, description: "Time entry or Task not found" })
|
||||||
|
update(@Param("id") id: string, @Body() body: UpdateTimeDto): Promise<TMTime> {
|
||||||
|
return this.timeService.update(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@ApiOperation({ summary: "Delete a time entry" })
|
||||||
|
@ApiParam({ name: "id", description: "Time entry ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "Time entry deleted" })
|
||||||
|
@ApiResponse({ status: 404, description: "Time entry not found" })
|
||||||
|
remove(@Param("id") id: string): Promise<void> {
|
||||||
|
return this.timeService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsDateString, IsNotEmpty, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateTimeDto {
|
||||||
|
@ApiProperty({ description: "Start time of the entry", example: "2026-07-15T08:00:00.000Z" })
|
||||||
|
@IsDateString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
startedAt: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "End time of the entry", example: "2026-07-15T10:30:00.000Z" })
|
||||||
|
@IsDateString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
endedAt: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ID of the task this time entry belongs to", example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" })
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
import { CreateTimeDto } from "./create-time.dto";
|
||||||
|
|
||||||
|
export class UpdateTimeDto extends PartialType(CreateTimeDto) {}
|
||||||
@@ -4,6 +4,7 @@ import { TMTaskPhase } from "./task-phase.entity";
|
|||||||
import { TMRemark } from "./remark.entity";
|
import { TMRemark } from "./remark.entity";
|
||||||
import { TMAttachment } from "./attachment.entity";
|
import { TMAttachment } from "./attachment.entity";
|
||||||
import { TMCheckListItem } from "./check-list-item.entity";
|
import { TMCheckListItem } from "./check-list-item.entity";
|
||||||
|
import { TMTime } from "./time.entity";
|
||||||
import { User } from "../../users/entities/user.entity";
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
|
||||||
@Entity("tm_tasks")
|
@Entity("tm_tasks")
|
||||||
@@ -53,6 +54,11 @@ export class TMTask extends BaseEntity {
|
|||||||
})
|
})
|
||||||
checkListItems: TMCheckListItem[];
|
checkListItems: TMCheckListItem[];
|
||||||
|
|
||||||
|
@OneToMany(() => TMTime, (time) => time.task, {
|
||||||
|
cascade: ["insert", "update"],
|
||||||
|
})
|
||||||
|
times: TMTime[];
|
||||||
|
|
||||||
@ManyToMany(() => User, (user) => user.tasks)
|
@ManyToMany(() => User, (user) => user.tasks)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
name: "tm_task_users",
|
name: "tm_task_users",
|
||||||
@@ -65,4 +71,5 @@ export class TMTask extends BaseEntity {
|
|||||||
attachmentCount?: number;
|
attachmentCount?: number;
|
||||||
checkListItemCount?: number;
|
checkListItemCount?: number;
|
||||||
completedCheckListItemCount?: number;
|
completedCheckListItemCount?: number;
|
||||||
|
time?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { TMTask } from "./task.entity";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
|
||||||
|
@Entity("tm_times")
|
||||||
|
export class TMTime extends BaseEntity {
|
||||||
|
@Column({ type: "timestamptz" })
|
||||||
|
startedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: "timestamptz", nullable: true })
|
||||||
|
endedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: "int", nullable: true })
|
||||||
|
duration: number | null;
|
||||||
|
|
||||||
|
// --- Relations ---
|
||||||
|
|
||||||
|
@ManyToOne(() => User, {
|
||||||
|
nullable: false,
|
||||||
|
onDelete: "RESTRICT",
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: "adminId" })
|
||||||
|
admin: User;
|
||||||
|
|
||||||
|
@Column({ type: "uuid" })
|
||||||
|
adminId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TMTask, (task) => task.times, {
|
||||||
|
nullable: false,
|
||||||
|
onDelete: "CASCADE",
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: "taskId" })
|
||||||
|
task: TMTask;
|
||||||
|
|
||||||
|
@Column({ type: "uuid" })
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { IsNull } from "typeorm";
|
||||||
|
import { TimeRepository } from "../repositories/time.repository";
|
||||||
|
import { TMTime } from "../entities/time.entity";
|
||||||
|
import { CreateTimeDto } from "../dto/time/create-time.dto";
|
||||||
|
import { UpdateTimeDto } from "../dto/time/update-time.dto";
|
||||||
|
import { timeMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { TaskService } from "./task.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimeService {
|
||||||
|
constructor(
|
||||||
|
private readonly timeRepository: TimeRepository,
|
||||||
|
private readonly taskService: TaskService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
private calculateDuration(startedAt: Date, endedAt: Date): number {
|
||||||
|
if (endedAt.getTime() < startedAt.getTime()) {
|
||||||
|
throw new BadRequestException(timeMessage.INVALID_TIME_RANGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findRunningTime(adminId: string, taskId: string): Promise<TMTime | null> {
|
||||||
|
return this.timeRepository.findOne({
|
||||||
|
where: { adminId, taskId, endedAt: IsNull() },
|
||||||
|
order: { startedAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneOrFail(id: string): Promise<TMTime> {
|
||||||
|
const time = await this.timeRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { admin: true, task: true },
|
||||||
|
});
|
||||||
|
if (!time) throw new NotFoundException(timeMessage.TIME_NOT_FOUND);
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(adminId: string, taskId: string): Promise<TMTime> {
|
||||||
|
await this.taskService.findOneOrFail(taskId);
|
||||||
|
|
||||||
|
const runningTime = await this.findRunningTime(adminId, taskId);
|
||||||
|
if (runningTime) throw new BadRequestException(timeMessage.TIME_ALREADY_STARTED);
|
||||||
|
|
||||||
|
const time = this.timeRepository.create({
|
||||||
|
taskId,
|
||||||
|
adminId,
|
||||||
|
startedAt: new Date(),
|
||||||
|
endedAt: null,
|
||||||
|
duration: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.timeRepository.save(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTaskTimes(adminId: string, taskId: string): Promise<TMTime[]> {
|
||||||
|
return this.timeRepository.find({
|
||||||
|
where: { adminId, taskId },
|
||||||
|
order: { startedAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(adminId: string, taskId: string): Promise<TMTime> {
|
||||||
|
await this.taskService.findOneOrFail(taskId);
|
||||||
|
|
||||||
|
const runningTime = await this.findRunningTime(adminId, taskId);
|
||||||
|
if (!runningTime) throw new BadRequestException(timeMessage.NO_RUNNING_TIME);
|
||||||
|
|
||||||
|
const endedAt = new Date();
|
||||||
|
runningTime.endedAt = endedAt;
|
||||||
|
runningTime.duration = this.calculateDuration(runningTime.startedAt, endedAt);
|
||||||
|
|
||||||
|
return this.timeRepository.save(runningTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(adminId: string, dto: CreateTimeDto): Promise<TMTime> {
|
||||||
|
await this.taskService.findOneOrFail(dto.taskId);
|
||||||
|
|
||||||
|
const startedAt = new Date(dto.startedAt);
|
||||||
|
const endedAt = new Date(dto.endedAt);
|
||||||
|
|
||||||
|
const time = this.timeRepository.create({
|
||||||
|
taskId: dto.taskId,
|
||||||
|
adminId,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
duration: this.calculateDuration(startedAt, endedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.timeRepository.save(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTimeDto): Promise<TMTime> {
|
||||||
|
const time = await this.findOneOrFail(id);
|
||||||
|
|
||||||
|
if (dto.taskId) {
|
||||||
|
await this.taskService.findOneOrFail(dto.taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = dto.startedAt ? new Date(dto.startedAt) : time.startedAt;
|
||||||
|
const endedAt = dto.endedAt ? new Date(dto.endedAt) : time.endedAt;
|
||||||
|
|
||||||
|
Object.assign(time, {
|
||||||
|
...dto,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
duration: endedAt ? this.calculateDuration(startedAt, endedAt) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.timeRepository.save(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findOneOrFail(id);
|
||||||
|
await this.timeRepository.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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 { TMTime } from "../entities/time.entity";
|
||||||
import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto";
|
import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto";
|
||||||
import { decodeCursor } from "../../utils/providers/cursor-pagination.utils";
|
import { decodeCursor } from "../../utils/providers/cursor-pagination.utils";
|
||||||
|
|
||||||
@@ -12,11 +13,13 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTaskDetail(taskId: string): Promise<TMTask | null> {
|
async getTaskDetail(taskId: string): Promise<TMTask | null> {
|
||||||
return this.createQueryBuilder("task")
|
const task = await this.createQueryBuilder("task")
|
||||||
.leftJoinAndSelect("task.remarks", "remark")
|
.leftJoinAndSelect("task.remarks", "remark")
|
||||||
.leftJoinAndSelect("task.attachments", "attachment")
|
.leftJoinAndSelect("task.attachments", "attachment")
|
||||||
.leftJoinAndSelect("task.checkListItems", "checkListItem")
|
.leftJoinAndSelect("task.checkListItems", "checkListItem")
|
||||||
.leftJoinAndSelect("task.users", "user")
|
.leftJoinAndSelect("task.users", "user")
|
||||||
|
.leftJoinAndSelect("task.times", "time")
|
||||||
|
.leftJoinAndSelect("time.admin", "timeAdmin")
|
||||||
|
|
||||||
.loadRelationCountAndMap("task.checkListItemCount", "task.checkListItems")
|
.loadRelationCountAndMap("task.checkListItemCount", "task.checkListItems")
|
||||||
|
|
||||||
@@ -41,34 +44,45 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
"user.id",
|
"user.id",
|
||||||
"user.firstName",
|
"user.firstName",
|
||||||
"user.lastName",
|
"user.lastName",
|
||||||
|
|
||||||
|
"time.id",
|
||||||
|
"time.startedAt",
|
||||||
|
"time.endedAt",
|
||||||
|
"time.duration",
|
||||||
|
"time.adminId",
|
||||||
|
"timeAdmin.id",
|
||||||
|
"timeAdmin.firstName",
|
||||||
|
"timeAdmin.lastName",
|
||||||
])
|
])
|
||||||
.where("task.id = :taskId", { taskId })
|
.where("task.id = :taskId", { taskId })
|
||||||
|
|
||||||
.orderBy("checkListItem.createdAt", "ASC")
|
.orderBy("checkListItem.createdAt", "ASC")
|
||||||
|
.addOrderBy("time.createdAt", "ASC")
|
||||||
|
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
task.time = task.times?.reduce((sum, entry) => sum + (entry.duration ?? 0), 0) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTasksByTaskPhase(
|
async findTasksByTaskPhase(taskPhaseId: string, pagination: CursorPaginationDto): Promise<{ items: TMTask[]; hasMore: boolean }> {
|
||||||
taskPhaseId: string,
|
|
||||||
pagination: CursorPaginationDto,
|
|
||||||
): Promise<{ items: TMTask[]; hasMore: boolean }> {
|
|
||||||
const limit = pagination.limit || 10;
|
const limit = pagination.limit || 10;
|
||||||
const isBackward = pagination.direction === CursorDirection.PREV;
|
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 createdAtExpr = `date_trunc('milliseconds', task.createdAt)`;
|
||||||
|
|
||||||
const queryBuilder = this.createQueryBuilder("task")
|
const seekQuery = this.createQueryBuilder("task")
|
||||||
|
.select(["task.id", "task.priority", "task.createdAt"])
|
||||||
.where("task.taskPhaseId = :taskPhaseId", { taskPhaseId })
|
.where("task.taskPhaseId = :taskPhaseId", { taskPhaseId })
|
||||||
.limit(limit + 1); // fetch one extra to check whether there's a further page
|
.limit(limit + 1); // fetch one extra to check whether there's a further page
|
||||||
|
|
||||||
if (isBackward) {
|
if (isBackward) {
|
||||||
queryBuilder.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC");
|
seekQuery.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC");
|
||||||
} else {
|
} else {
|
||||||
queryBuilder.orderBy("task.priority", "DESC").addOrderBy(createdAtExpr, "ASC").addOrderBy("task.id", "ASC");
|
seekQuery.orderBy("task.priority", "DESC").addOrderBy(createdAtExpr, "ASC").addOrderBy("task.id", "ASC");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pagination.cursor) {
|
if (pagination.cursor) {
|
||||||
@@ -76,7 +90,7 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
const priorityOp = isBackward ? ">" : "<";
|
const priorityOp = isBackward ? ">" : "<";
|
||||||
const seekOp = isBackward ? "<" : ">";
|
const seekOp = isBackward ? "<" : ">";
|
||||||
|
|
||||||
queryBuilder.andWhere(
|
seekQuery.andWhere(
|
||||||
`(task.priority ${priorityOp} :cursorPriority
|
`(task.priority ${priorityOp} :cursorPriority
|
||||||
OR (task.priority = :cursorPriority AND ${createdAtExpr} ${seekOp} :cursorCreatedAt)
|
OR (task.priority = :cursorPriority AND ${createdAtExpr} ${seekOp} :cursorCreatedAt)
|
||||||
OR (task.priority = :cursorPriority AND ${createdAtExpr} = :cursorCreatedAt AND task.id ${seekOp} :cursorId))`,
|
OR (task.priority = :cursorPriority AND ${createdAtExpr} = :cursorCreatedAt AND task.id ${seekOp} :cursorId))`,
|
||||||
@@ -84,13 +98,47 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await queryBuilder.getMany();
|
const seekRows = await seekQuery.getMany();
|
||||||
const hasMore = rows.length > limit;
|
const hasMore = seekRows.length > limit;
|
||||||
if (hasMore) rows.pop();
|
if (hasMore) seekRows.pop();
|
||||||
|
|
||||||
// backward queries fetch in reverse order to seek from the cursor, so flip back to
|
// backward queries fetch in reverse order to seek from the cursor, so flip back to
|
||||||
// the normal display order before returning
|
// the normal display order before returning
|
||||||
const items = isBackward ? rows.reverse() : rows;
|
const orderedRows = isBackward ? seekRows.reverse() : seekRows;
|
||||||
|
const ids = orderedRows.map((row) => row.id);
|
||||||
|
|
||||||
|
if (!ids.length) return { items: [], hasMore };
|
||||||
|
|
||||||
|
// Detail query: hydrates exactly the ids picked above with the relations/counts the
|
||||||
|
// response needs. IN (:...ids) doesn't preserve order, so re-order to match seekQuery.
|
||||||
|
const detailRows = await this.createQueryBuilder("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 }),
|
||||||
|
)
|
||||||
|
.select(["task", "remark.id", "remark.title", "remark.color"])
|
||||||
|
.where("task.id IN (:...ids)", { ids })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const timeSums = await this.manager
|
||||||
|
.createQueryBuilder(TMTime, "time")
|
||||||
|
.select("time.taskId", "taskId")
|
||||||
|
.addSelect("COALESCE(SUM(time.duration), 0)", "totalTime")
|
||||||
|
.where("time.taskId IN (:...ids)", { ids })
|
||||||
|
.groupBy("time.taskId")
|
||||||
|
.getRawMany<{ taskId: string; totalTime: string }>();
|
||||||
|
|
||||||
|
const timeByTaskId = new Map(timeSums.map((row) => [row.taskId, Number(row.totalTime)]));
|
||||||
|
|
||||||
|
const detailById = new Map(
|
||||||
|
detailRows.map((task) => {
|
||||||
|
task.time = timeByTaskId.get(task.id) ?? 0;
|
||||||
|
return [task.id, task];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const items = ids.map((id) => detailById.get(id)).filter((task): task is TMTask => task !== undefined);
|
||||||
|
|
||||||
return { items, hasMore };
|
return { items, hasMore };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { TMTime } from "../entities/time.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimeRepository extends Repository<TMTime> {
|
||||||
|
constructor(@InjectRepository(TMTime) timeRepository: Repository<TMTime>) {
|
||||||
|
super(timeRepository.target, timeRepository.manager, timeRepository.queryRunner);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { TMTask } from "./entities/task.entity";
|
|||||||
import { TMRemark } from "./entities/remark.entity";
|
import { TMRemark } from "./entities/remark.entity";
|
||||||
import { TMAttachment } from "./entities/attachment.entity";
|
import { TMAttachment } from "./entities/attachment.entity";
|
||||||
import { TMCheckListItem } from "./entities/check-list-item.entity";
|
import { TMCheckListItem } from "./entities/check-list-item.entity";
|
||||||
|
import { TMTime } from "./entities/time.entity";
|
||||||
import { WorkspaceRepository } from "./repositories/workspace.repository";
|
import { WorkspaceRepository } from "./repositories/workspace.repository";
|
||||||
import { WorkspaceController } from "./controllers/workspace.controller";
|
import { WorkspaceController } from "./controllers/workspace.controller";
|
||||||
import { WorkspaceService } from "./providers/workspace.service";
|
import { WorkspaceService } from "./providers/workspace.service";
|
||||||
@@ -30,10 +31,13 @@ import { RemarkService } from "./providers/remark.service";
|
|||||||
import { AttachmentController } from "./controllers/attachment.controller";
|
import { AttachmentController } from "./controllers/attachment.controller";
|
||||||
import { AttachmentRepository } from "./repositories/attachment.repository";
|
import { AttachmentRepository } from "./repositories/attachment.repository";
|
||||||
import { AttachmentService } from "./providers/attachment.service";
|
import { AttachmentService } from "./providers/attachment.service";
|
||||||
|
import { TimeController } from "./controllers/time.controller";
|
||||||
|
import { TimeRepository } from "./repositories/time.repository";
|
||||||
|
import { TimeService } from "./providers/time.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, User]),
|
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, TMTime, User]),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
WorkspaceController,
|
WorkspaceController,
|
||||||
@@ -42,7 +46,8 @@ import { AttachmentService } from "./providers/attachment.service";
|
|||||||
TaskController,
|
TaskController,
|
||||||
CheckListItemController,
|
CheckListItemController,
|
||||||
RemarkController,
|
RemarkController,
|
||||||
AttachmentController
|
AttachmentController,
|
||||||
|
TimeController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WorkspaceRepository,
|
WorkspaceRepository,
|
||||||
@@ -58,7 +63,9 @@ import { AttachmentService } from "./providers/attachment.service";
|
|||||||
RemarkRepository,
|
RemarkRepository,
|
||||||
RemarkService,
|
RemarkService,
|
||||||
AttachmentRepository,
|
AttachmentRepository,
|
||||||
AttachmentService
|
AttachmentService,
|
||||||
|
TimeRepository,
|
||||||
|
TimeService,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TaskManagerModule {}
|
export class TaskManagerModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user