8 Commits

30 changed files with 873 additions and 145 deletions
+75
View File
@@ -0,0 +1,75 @@
NODE_ENV=production
PORT=3500
DB_NAME=dscDev
DB_USER=postgres
DB_PASS=1RWVdlUcZSf6PoFBQO8cprZk2DTkC89ttqeJN7aU2WQRDUEusK4z2FfNqNtV4I10
DB_HOST=78.157.34.13
DB_PORT=5432
SMS_API_URL=https://api.sms.ir/v1
SMS_API_KEY=QkkNxhyXZ6GtEf1soOTtikomO3mA4LaNQDH8mol8huDIwh00
SMS_PATTERN_OTP=842088
SMS_PATTERN_INVOICE=683883
SMS_PATTERN_LOGIN=149141
SMS_PATTERN_INVOICE_CREATED=829067
SMS_PATTERN_ANNOUNCEMENT=752330
SMS_PATTERN_WALLET_CHARGE=727726
SMS_PATTERN_WALLET_DEDUCTION=360785
SMS_PATTERN_TICKET_CREATED=853506
SMS_PATTERN_TICKET_ANSWERED=179464
SMS_PATTERN_TICKET_ASSIGNED_ADMIN=321927
SMS_PATTERN_INVOICE_APPROVED=303807
SMS_PATTERN_INVOICE_PAID=533817
SMS_PATTERN_RECURRING_INVOICE_DRAFT=219287
SMS_PATTERN_SUBSCRIPTION_CANCELLED=166142
SMS_PATTERN_INVOICE_REMINDER=370266
SMS_PATTERN_INVOICE_OVERDUE=160735
SMS_PATTERN_PAYMENT_REMINDER=629772
SMS_PATTERN_PAYMENT_CANCELLATION=197945
SMS_PATTERN_BLOG_NEW_COMMENT=378881
SMS_PATTERN_NEW_SERVICE_REVIEW=593446
SMS_PATTERN_NEW_CUSTOMER=966214
SMS_PATTERN_NEW_SUBSCRIPTION=757095
SMS_PATTERN_NEW_TICKET_GLOBAL=536596
SMS_PATTERN_NEW_CRITICISM=983724
SMS_PATTERN_USER_PASSWORD=486237
SMTP_HOST=smtps.danakcorp.com
SMTP_PORT=465
SMTP_USER=no-reply-danakcorp.com
SMTP_PASS=Mahyar.SR4@
MAIL_FROM=no-reply <no-reply@danakcorp.com>
EMAIL_SECRET=xDkdptbC2N7LoR2Aa4Q9U/aAGOi4DxG2748
REDIS_URI=redis://localhost:6379/1
CACHE_TTL=180000
THROTTLE_TTL=300000
THROTTLE_LIMIT=10
JWT_SECRET_KEY=2ygxIB1PJhK/L2PToqR3+52gRmUIdPfwTguYlB7ZRCdRyvA/N36jjkjbYJLoMxEL\n6xGmk5CQmCmn9c0FsBlN1g
JWT_ISSUER=console-danak
ACCESS_TOKEN_EXPIRE=80000
REFRESH_TOKEN_EXPIRE=6
BUCKET_NAME=danak-project
BUCKET_REGION=default
BUCKET_URL=https://s3.ir-thr-at1.arvanstorage.ir
BUCKET_ACCESS_KEY=4bd15218-60f1-4ce7-a7aa-3fd319b13372
BUCKET_SECRET_KEY=de772e5bd62fb04fb3669e67e09ddb101db60c65d77eea66b215ce58275e6a76
BUCKET_UPLOAD_URL=https://storage.danakcorp.com
ZARINPAL_MERCHANT_ID=fc0b7503-bba5-40d1-9f9d-dae52ac02eef
PARSIAN_LOGIN_ACCOUNT=uqXnB2q8rjkL6vN0xOh0
SITE_URL=https://console.danakcorp.com
API_URL=https://api.danakcorp.com
CALLBACK_URL=https://api.danakcorp.com/payments/verify
TELEGRAM_BOT_TOKEN=7839690719:AAGsyfwympR4rDLm2JCx43CQh52s1o865uY
TELEGRAM_BOT_NAME=danak_logs_bot
TELEGRAM_CHAT_ID=7801476800
EXTERNAL_API_KEYS=uqXssnB2q8rs2soa9almm1jkL6vN0xOh0
DMENU_BACKEND_URL=https://dmenu-api.danakcorp.com
DMENU_USERNAME=danak@dsc.com
DMENU_PASSWORD=DsCdAnAk?@ABC
DKALA_BACKEND_URL=https://dkala-api.danakcorp.com
DKALA_USERNAME=danak@dsc.com
DKALA_PASSWORD=DsCdAnAk?@ABC
DPAGE_BACKEND_URL=https://dpage-api.danakcorp.com
DPAGE_USERNAME=danak@dsc.com
DPAGE_PASSWORD=DsCdAnAk?@ABC
DMAIL_BACKEND_URL=https://dmail-api.danakcorp.com
DMAIL_USERNAME=danak@dsc.com
DMAIL_PASSWORD=DsCdAnAk?@ABC
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class TMChangingFewLastPermissions1783759715100 implements MigrationInterface {
name = 'TMChangingFewLastPermissions1783759715100'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "public"."permission_name_enum" RENAME TO "permission_name_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."permission_name_enum" AS ENUM('services', 'customers', 'agents', 'developers', 'invoices', 'transactions', 'discounts', 'admins', 'tickets', 'criticisms', 'contacts_us', 'advertisements', 'announcements', 'blogs', 'learnings', 'logs', 'settings', 'bank_accounts', 'payments', 'manage_sso_clients', 'support_plan', 'dmenu', 'dkala', 'dpage', 'dmail', 'reseller', 'workspace', 'view_all_workspaces', 'project', 'view_all_projects', 'task', 'task_phase')`);
await queryRunner.query(`ALTER TABLE "permission" ALTER COLUMN "name" TYPE "public"."permission_name_enum" USING "name"::"text"::"public"."permission_name_enum"`);
await queryRunner.query(`DROP TYPE "public"."permission_name_enum_old"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "public"."permission_name_enum_old" AS ENUM('admins', 'advertisements', 'agents', 'announcements', 'bank_accounts', 'blogs', 'contacts_us', 'criticisms', 'customers', 'developers', 'discounts', 'dkala', 'dmail', 'dmenu', 'dpage', 'invoices', 'learnings', 'logs', 'manage_sso_clients', 'payments', 'project', 'remark', 'reseller', 'services', 'settings', 'support_plan', 'task', 'task_phase', 'tickets', 'transactions', 'view_all_projects', 'view_all_workspaces', 'workspace')`);
await queryRunner.query(`ALTER TABLE "permission" ALTER COLUMN "name" TYPE "public"."permission_name_enum_old" USING "name"::"text"::"public"."permission_name_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."permission_name_enum"`);
await queryRunner.query(`ALTER TYPE "public"."permission_name_enum_old" RENAME TO "permission_name_enum"`);
}
}
@@ -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"`);
}
}
@@ -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`,
);
}
}
+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,
}),
);
}
+9 -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 {
@@ -955,3 +956,10 @@ 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 = "زمان در حال اجرایی برای این تسک پیدا نشد!"
}
@@ -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;
}), }),
); );
@@ -20,6 +20,15 @@ import { ITokenPayload } from "../../auth/interfaces/IToken-payload";
export class ProjectController { export class ProjectController {
constructor(private readonly projectService: ProjectService) {} constructor(private readonly projectService: ProjectService) {}
@Get(':id')
@ApiOperation({summary: 'Get one project by ID'})
@ApiResponse({status: 200, description: 'received the project successfully!'})
@ApiResponse({status: 404, description: 'Project Not Found!'})
@ApiResponse({status: 400, description: 'Bad Request!'})
findOne(@Param('id') id: string) {
return this.projectService.findOneOrFail(id);
}
@Post() @Post()
@PermissionsDec(PermissionEnum.PROJECT) @PermissionsDec(PermissionEnum.PROJECT)
@ApiOperation({ summary: "Create a new project" }) @ApiOperation({ summary: "Create a new project" })
@@ -63,7 +72,7 @@ export class ProjectController {
return this.projectService.findUserProjectsByWorkspace(user.id, user.permissions, paramDto.id, pagination); return this.projectService.findUserProjectsByWorkspace(user.id, user.permissions, paramDto.id, pagination);
} }
@Get("project/:id") @Get("detail/:id")
@ApiOperation({ summary: "Get project detail" }) @ApiOperation({ summary: "Get project detail" })
@ApiResponse({ status: 200, description: "Got Project Detail Successfully!" }) @ApiResponse({ status: 200, description: "Got Project Detail Successfully!" })
@ApiResponse({ status: 400, description: "Bad request!" }) @ApiResponse({ status: 400, description: "Bad request!" })
@@ -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);
}
} }
@@ -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);
}
}
@@ -18,6 +18,16 @@ import { ITokenPayload } from '../../auth/interfaces/IToken-payload';
export class WorkspaceController { export class WorkspaceController {
constructor(private readonly workspaceService: WorkspaceService) {} constructor(private readonly workspaceService: WorkspaceService) {}
@Get(':id')
@PermissionsDec(PermissionEnum.WORKSPACE)
@ApiOperation({summary: "Get one workspace by ID"})
@ApiResponse({status: 200, description: 'Received workspace', type: TMWorkspace})
@ApiResponse({status: 404, description: 'Workspace Not Found!'})
@ApiResponse({status: 400, description: 'Bad Request!'})
findOne(@Param('id') id: string): Promise<TMWorkspace> {
return this.workspaceService.findOneOrFail(id);
}
@Post() @Post()
@PermissionsDec(PermissionEnum.WORKSPACE) @PermissionsDec(PermissionEnum.WORKSPACE)
@ApiOperation({ summary: 'Create a new workspace' }) @ApiOperation({ summary: 'Create a new workspace' })
@@ -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")
@@ -23,6 +24,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, {
@@ -50,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",
@@ -62,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;
}
@@ -21,7 +21,24 @@ export class ProjectService {
) {} ) {}
async findOneOrFail(id: string): Promise<TMProject> { async findOneOrFail(id: string): Promise<TMProject> {
const project = await this.projectRepository.findOne({ where: { id } }); const project = await this.projectRepository.findOne({
where: { id },
relations: {
users: true,
taskPhases: true
},
select: {
users: {
id: true,
firstName: true,
lastName: true
},
taskPhases: {
id: true,
name: true,
}
}
});
if (!project) throw new NotFoundException(ProjectMessage.PROJECT_NOT_FOUND); if (!project) throw new NotFoundException(ProjectMessage.PROJECT_NOT_FOUND);
return project; return project;
} }
@@ -118,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;
}
} }
@@ -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);
}
}
@@ -17,10 +17,6 @@ export class WorkspaceService {
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
) {} ) {}
findAll(): Promise<TMWorkspace[]> {
return this.workspaceRepository.find({});
}
async findOneOrFail(id: string): Promise<TMWorkspace> { async findOneOrFail(id: string): Promise<TMWorkspace> {
const workspace = await this.workspaceRepository.findOneWorkSpace(id); const workspace = await this.workspaceRepository.findOneWorkSpace(id);
if (!workspace) throw new NotFoundException(WorkSpaceMessage.WORKSPACE_NOT_FOUND); if (!workspace) throw new NotFoundException(WorkSpaceMessage.WORKSPACE_NOT_FOUND);
@@ -5,18 +5,16 @@ import { TMProject } from "../entities/project.entity";
@Injectable() @Injectable()
export class ProjectRepository extends Repository<TMProject> { export class ProjectRepository extends Repository<TMProject> {
constructor( constructor(@InjectRepository(TMProject) projectRepository: Repository<TMProject>) {
@InjectRepository(TMProject) projectRepository: Repository<TMProject>,
) {
super(projectRepository.target, projectRepository.manager, projectRepository.queryRunner); super(projectRepository.target, projectRepository.manager, projectRepository.queryRunner);
} }
async findUserProjects(userId: string, wsId: string, skip?: number, take?: number): Promise<[TMProject[], number]> { async findUserProjects(userId: string, wsId: string, skip?: number, take?: number): Promise<[TMProject[], number]> {
return this.createQueryBuilder('project') return this.createQueryBuilder("project")
.innerJoin('project.workspace', 'workspace') .innerJoin("project.workspace", "workspace")
.innerJoin('project.users', 'user') .innerJoin("project.users", "user")
.where('workspace.id = :wsId', {wsId}) .where("workspace.id = :wsId", { wsId })
.andWhere('user.id = :userId', {userId}) .andWhere("user.id = :userId", { userId })
.skip(skip ?? 0) .skip(skip ?? 0)
.take(take ?? 10) .take(take ?? 10)
.getManyAndCount(); .getManyAndCount();
@@ -25,26 +23,6 @@ export class ProjectRepository extends Repository<TMProject> {
async getProjectDetail(projectId: string): Promise<TMProject | null> { async getProjectDetail(projectId: string): Promise<TMProject | null> {
return this.createQueryBuilder("project") return this.createQueryBuilder("project")
.leftJoinAndSelect("project.taskPhases", "taskPhase") .leftJoinAndSelect("project.taskPhases", "taskPhase")
.leftJoinAndSelect("taskPhase.tasks", "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 }),
)
.where("project.id = :projectId", { projectId }) .where("project.id = :projectId", { projectId })
.select([ .select([
@@ -52,14 +30,11 @@ export class ProjectRepository extends Repository<TMProject> {
"project.name", "project.name",
"taskPhase.id", "taskPhase.id",
"taskPhase.name", "taskPhase.name",
"task.id", "taskPhase.order",
"task.title",
"task.startDate",
"task.endDate",
"remark.title",
"remark.color",
]) ])
.orderBy("taskPhase.order", "ASC")
.getOne(); .getOne();
} }
} }
@@ -2,6 +2,9 @@ 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 { decodeCursor } from "../../utils/providers/cursor-pagination.utils";
@Injectable() @Injectable()
export class TaskRepository extends Repository<TMTask> { export class TaskRepository extends Repository<TMTask> {
@@ -10,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")
@@ -23,6 +28,8 @@ export class TaskRepository extends Repository<TMTask> {
) )
.select([ .select([
"task",
"remark.id", "remark.id",
"remark.title", "remark.title",
"remark.color", "remark.color",
@@ -37,8 +44,102 @@ 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")
.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 }> {
const limit = pagination.limit || 10;
const isBackward = pagination.direction === CursorDirection.PREV;
const createdAtExpr = `date_trunc('milliseconds', task.createdAt)`;
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) {
seekQuery.orderBy("task.priority", "ASC").addOrderBy(createdAtExpr, "DESC").addOrderBy("task.id", "DESC");
} else {
seekQuery.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 ? "<" : ">";
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))`,
{ cursorPriority, cursorCreatedAt, cursorId },
);
}
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 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 { 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 {}
+1 -1
View File
@@ -25,7 +25,7 @@ export enum PermissionEnum {
DPAGE = "dpage", DPAGE = "dpage",
DMAIL = 'dmail', DMAIL = 'dmail',
RESELLER="reseller", RESELLER="reseller",
WORKSPACE="workspace", // create, update, remove WORKSPACE="workspace", // create, update, remove, read-one(for update purposes)
VIEW_ALL_WORKSPACES="view_all_workspaces", VIEW_ALL_WORKSPACES="view_all_workspaces",
PROJECT="project", // create, update, remove PROJECT="project", // create, update, remove
VIEW_ALL_PROJECTS="view_all_projects", VIEW_ALL_PROJECTS="view_all_projects",
@@ -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");
}