Merge branch 'rafi/taskmanager'
Build and Deploy Docker Images / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-15 12:11:03 +03:30
12 changed files with 412 additions and 19 deletions
@@ -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`,
);
}
}
+7
View File
@@ -956,3 +956,10 @@ export const enum checkListItemMessage {
export const enum attachmentMessage {
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 { TMAttachment } from "./attachment.entity";
import { TMCheckListItem } from "./check-list-item.entity";
import { TMTime } from "./time.entity";
import { User } from "../../users/entities/user.entity";
@Entity("tm_tasks")
@@ -53,6 +54,11 @@ export class TMTask extends BaseEntity {
})
checkListItems: TMCheckListItem[];
@OneToMany(() => TMTime, (time) => time.task, {
cascade: ["insert", "update"],
})
times: TMTime[];
@ManyToMany(() => User, (user) => user.tasks)
@JoinTable({
name: "tm_task_users",
@@ -65,4 +71,5 @@ export class TMTask extends BaseEntity {
attachmentCount?: number;
checkListItemCount?: 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 { Repository } from "typeorm";
import { TMTask } from "../entities/task.entity";
import { TMTime } from "../entities/time.entity";
import { CursorDirection, CursorPaginationDto } from "../../../common/DTO/cursor-pagination.dto";
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> {
return this.createQueryBuilder("task")
const task = await this.createQueryBuilder("task")
.leftJoinAndSelect("task.remarks", "remark")
.leftJoinAndSelect("task.attachments", "attachment")
.leftJoinAndSelect("task.checkListItems", "checkListItem")
.leftJoinAndSelect("task.users", "user")
.leftJoinAndSelect("task.times", "time")
.leftJoinAndSelect("time.admin", "timeAdmin")
.loadRelationCountAndMap("task.checkListItemCount", "task.checkListItems")
@@ -41,34 +44,45 @@ export class TaskRepository extends Repository<TMTask> {
"user.id",
"user.firstName",
"user.lastName",
"time.id",
"time.startedAt",
"time.endedAt",
"time.duration",
"time.adminId",
"timeAdmin.id",
"timeAdmin.firstName",
"timeAdmin.lastName",
])
.where("task.id = :taskId", { taskId })
.orderBy("checkListItem.createdAt", "ASC")
.addOrderBy("time.createdAt", "ASC")
.getOne();
if (task) {
task.time = task.times?.reduce((sum, entry) => sum + (entry.duration ?? 0), 0) ?? 0;
}
return task;
}
async findTasksByTaskPhase(
taskPhaseId: string,
pagination: CursorPaginationDto,
): Promise<{ items: TMTask[]; hasMore: boolean }> {
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")
const seekQuery = this.createQueryBuilder("task")
.select(["task.id", "task.priority", "task.createdAt"])
.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");
seekQuery.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC");
} 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) {
@@ -76,7 +90,7 @@ export class TaskRepository extends Repository<TMTask> {
const priorityOp = isBackward ? ">" : "<";
const seekOp = isBackward ? "<" : ">";
queryBuilder.andWhere(
seekQuery.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))`,
@@ -84,13 +98,47 @@ export class TaskRepository extends Repository<TMTask> {
);
}
const rows = await queryBuilder.getMany();
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const seekRows = await seekQuery.getMany();
const hasMore = seekRows.length > limit;
if (hasMore) seekRows.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;
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 };
}
@@ -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 { TMAttachment } from "./entities/attachment.entity";
import { TMCheckListItem } from "./entities/check-list-item.entity";
import { TMTime } from "./entities/time.entity";
import { WorkspaceRepository } from "./repositories/workspace.repository";
import { WorkspaceController } from "./controllers/workspace.controller";
import { WorkspaceService } from "./providers/workspace.service";
@@ -30,10 +31,13 @@ import { RemarkService } from "./providers/remark.service";
import { AttachmentController } from "./controllers/attachment.controller";
import { AttachmentRepository } from "./repositories/attachment.repository";
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({
imports: [
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, User]),
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, TMTime, User]),
],
controllers: [
WorkspaceController,
@@ -42,7 +46,8 @@ import { AttachmentService } from "./providers/attachment.service";
TaskController,
CheckListItemController,
RemarkController,
AttachmentController
AttachmentController,
TimeController,
],
providers: [
WorkspaceRepository,
@@ -58,7 +63,9 @@ import { AttachmentService } from "./providers/attachment.service";
RemarkRepository,
RemarkService,
AttachmentRepository,
AttachmentService
AttachmentService,
TimeRepository,
TimeService,
]
})
export class TaskManagerModule {}