Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e8e0cba2e | |||
| 3a84740d9a | |||
| cb77a55281 | |||
| 11445f0a07 | |||
| c2a861041f | |||
| 50d9fb2274 | |||
| 2768611a7c | |||
| b1a02d6b0d | |||
| 8b901e4d02 | |||
| 43b4f05894 | |||
| 7bef7d414a | |||
| 30dba5ae37 |
+11
-7
@@ -2,10 +2,14 @@
|
|||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
ENV TZ=Asia/Tehran
|
ENV TZ=Asia/Tehran
|
||||||
|
# Use the public npm registry to avoid paid/unstable mirror outages.
|
||||||
|
ENV NPM_CONFIG_REGISTRY=https://package-mirror.liara.ir/repository/npm
|
||||||
|
|
||||||
# Runflare build hosts cannot reach registry.npmjs.org; use their npm mirror.
|
# Change Alpine repo
|
||||||
# RUN npm install -g pnpm@9.15.9 --registry=https://mirror-npm.runflare.com
|
RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirror.de.velop.ir/alpine|g' /etc/apk/repositories
|
||||||
RUN npm install -g pnpm@9.15.9
|
|
||||||
|
# Install pnpm globally (uses NPM_CONFIG_REGISTRY mirror above).
|
||||||
|
RUN npm install -g pnpm@9.15.9 --registry=https://package-mirror.liara.ir/repository/npm
|
||||||
|
|
||||||
# Stage 2: Install all dependencies (including dev)
|
# Stage 2: Install all dependencies (including dev)
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
@@ -15,9 +19,9 @@ COPY package.json pnpm-lock.yaml ./
|
|||||||
# @nestjs/cli and typescript are required for the build step.
|
# @nestjs/cli and typescript are required for the build step.
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
# RUN pnpm install --frozen-lockfile --loglevel info \
|
# RUN pnpm install --frozen-lockfile --loglevel info \
|
||||||
# --registry=https://mirror-npm.runflare.com
|
# --registry=https://package-mirror.liara.ir/repository/npm
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --loglevel info
|
RUN pnpm install --frozen-lockfile --loglevel info --registry=https://package-mirror.liara.ir/repository/npm
|
||||||
|
|
||||||
|
|
||||||
# Stage 3: Build
|
# Stage 3: Build
|
||||||
@@ -32,8 +36,8 @@ FROM base AS prod-deps
|
|||||||
WORKDIR /temp-deps
|
WORKDIR /temp-deps
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
# RUN pnpm install --prod --frozen-lockfile --loglevel info \
|
# RUN pnpm install --prod --frozen-lockfile --loglevel info \
|
||||||
# --registry=https://mirror-npm.runflare.com
|
# --registry=https://package-mirror.liara.ir/repository/npm
|
||||||
RUN pnpm install --prod --frozen-lockfile --loglevel info
|
RUN pnpm install --prod --frozen-lockfile --loglevel info --registry=https://package-mirror.liara.ir/repository/npm
|
||||||
|
|
||||||
|
|
||||||
# Stage 5: Runner
|
# Stage 5: Runner
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -956,10 +956,3 @@ 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 = "زمان در حال اجرایی برای این تسک پیدا نشد!"
|
|
||||||
}
|
|
||||||
@@ -444,6 +444,46 @@ export class RestaurantService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async finishChargeRequest(invoiceId: string) {
|
||||||
|
this.logger.log(`Finishing charge request for invoice ${invoiceId}`);
|
||||||
|
try {
|
||||||
|
const url = `${this.config.baseUrl}/super-admin/restaurants/charge-request/finish`;
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService
|
||||||
|
.post(url, { invoiceId }, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
catchError((err: AxiosError) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to finish charge request for invoice ${invoiceId}: ${err.message}`,
|
||||||
|
err.stack,
|
||||||
|
);
|
||||||
|
return throwError(() => err);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.logger.log(`Successfully finished charge request for invoice ${invoiceId}`);
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof AxiosError && error.response) {
|
||||||
|
this.logger.error(
|
||||||
|
`External API error finishing charge request for invoice ${invoiceId} (status ${error.response.status}):`,
|
||||||
|
error.response.data,
|
||||||
|
);
|
||||||
|
const errorResponse = {
|
||||||
|
...error.response.data,
|
||||||
|
message: error.response.data.error?.message || error.message,
|
||||||
|
};
|
||||||
|
throw new HttpException(errorResponse, error.response.status);
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
`Error finishing charge request for invoice ${invoiceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async directLogin(userSubscriptionId: string, userId: string) {
|
async directLogin(userSubscriptionId: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
// 1. Get userSubscription by id
|
// 1. Get userSubscription by id
|
||||||
|
|||||||
@@ -948,9 +948,13 @@ export class InvoicesService {
|
|||||||
await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUPPORT_PLAN_WALLET_TRANSFER);
|
await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUPPORT_PLAN_WALLET_TRANSFER);
|
||||||
//
|
//
|
||||||
} else if (invoice.isExternal) {
|
} else if (invoice.isExternal) {
|
||||||
if (invoice.items[0]?.name == 'purchase_design') {
|
if (invoice.items[0]?.name == 'dmenu_wallet_charge') {
|
||||||
|
this.restaurantService.finishChargeRequest(invoice.id);
|
||||||
|
} else if (invoice.items[0]?.name == 'purchase_plan') {
|
||||||
this.dpageService.finishPurchaseDesign(invoice.externalBusinessId!, invoice.id)
|
this.dpageService.finishPurchaseDesign(invoice.externalBusinessId!, invoice.id)
|
||||||
} else if (invoice.items[0]?.name == 'purchase_new_catalogue') {
|
}
|
||||||
|
|
||||||
|
else if (invoice.items[0]?.name == 'purchase_new_catalogue') {
|
||||||
// call dpage api
|
// call dpage api
|
||||||
const businessId = invoice.externalBusinessId
|
const businessId = invoice.externalBusinessId
|
||||||
if (!businessId) {
|
if (!businessId) {
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from "@nestjs/swagger";
|
|
||||||
import { CreateTimeDto } from "./create-time.dto";
|
|
||||||
|
|
||||||
export class UpdateTimeDto extends PartialType(CreateTimeDto) {}
|
|
||||||
@@ -4,7 +4,6 @@ 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")
|
||||||
@@ -54,11 +53,6 @@ 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",
|
||||||
@@ -71,5 +65,4 @@ export class TMTask extends BaseEntity {
|
|||||||
attachmentCount?: number;
|
attachmentCount?: number;
|
||||||
checkListItemCount?: number;
|
checkListItemCount?: number;
|
||||||
completedCheckListItemCount?: number;
|
completedCheckListItemCount?: number;
|
||||||
time?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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,7 +2,6 @@ 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";
|
||||||
|
|
||||||
@@ -13,13 +12,11 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTaskDetail(taskId: string): Promise<TMTask | null> {
|
async getTaskDetail(taskId: string): Promise<TMTask | null> {
|
||||||
const task = await this.createQueryBuilder("task")
|
return 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")
|
||||||
|
|
||||||
@@ -44,45 +41,34 @@ 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(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 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 seekQuery = this.createQueryBuilder("task")
|
const queryBuilder = 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) {
|
||||||
seekQuery.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC");
|
queryBuilder.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC");
|
||||||
} else {
|
} else {
|
||||||
seekQuery.orderBy("task.priority", "DESC").addOrderBy(createdAtExpr, "ASC").addOrderBy("task.id", "ASC");
|
queryBuilder.orderBy("task.priority", "DESC").addOrderBy(createdAtExpr, "ASC").addOrderBy("task.id", "ASC");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pagination.cursor) {
|
if (pagination.cursor) {
|
||||||
@@ -90,7 +76,7 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
const priorityOp = isBackward ? ">" : "<";
|
const priorityOp = isBackward ? ">" : "<";
|
||||||
const seekOp = isBackward ? "<" : ">";
|
const seekOp = isBackward ? "<" : ">";
|
||||||
|
|
||||||
seekQuery.andWhere(
|
queryBuilder.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))`,
|
||||||
@@ -98,47 +84,13 @@ export class TaskRepository extends Repository<TMTask> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekRows = await seekQuery.getMany();
|
const rows = await queryBuilder.getMany();
|
||||||
const hasMore = seekRows.length > limit;
|
const hasMore = rows.length > limit;
|
||||||
if (hasMore) seekRows.pop();
|
if (hasMore) rows.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 orderedRows = isBackward ? seekRows.reverse() : seekRows;
|
const items = isBackward ? rows.reverse() : rows;
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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,7 +8,6 @@ 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";
|
||||||
@@ -31,13 +30,10 @@ 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, TMTime, User]),
|
TypeOrmModule.forFeature([TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, User]),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
WorkspaceController,
|
WorkspaceController,
|
||||||
@@ -46,8 +42,7 @@ import { TimeService } from "./providers/time.service";
|
|||||||
TaskController,
|
TaskController,
|
||||||
CheckListItemController,
|
CheckListItemController,
|
||||||
RemarkController,
|
RemarkController,
|
||||||
AttachmentController,
|
AttachmentController
|
||||||
TimeController,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WorkspaceRepository,
|
WorkspaceRepository,
|
||||||
@@ -63,9 +58,7 @@ import { TimeService } from "./providers/time.service";
|
|||||||
RemarkRepository,
|
RemarkRepository,
|
||||||
RemarkService,
|
RemarkService,
|
||||||
AttachmentRepository,
|
AttachmentRepository,
|
||||||
AttachmentService,
|
AttachmentService
|
||||||
TimeRepository,
|
|
||||||
TimeService,
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TaskManagerModule {}
|
export class TaskManagerModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user