diff --git a/database/migrations/1782889623829-taskmanager-project.ts b/database/migrations/1782889623829-taskmanager-project.ts new file mode 100644 index 0000000..95869e2 --- /dev/null +++ b/database/migrations/1782889623829-taskmanager-project.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TaskmanagerProject1782889623829 implements MigrationInterface { + name = 'TaskmanagerProject1782889623829' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "tm_project_users" ("projectId" uuid NOT NULL, "userId" uuid NOT NULL, CONSTRAINT "PK_5969064e19a1efa76b91de6a065" PRIMARY KEY ("projectId", "userId"))`); + await queryRunner.query(`CREATE INDEX "IDX_fcc8e135e4891925d288dbb14e" ON "tm_project_users" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_8e2d2c9d639d5fd806159f8698" ON "tm_project_users" ("userId") `); + await queryRunner.query(`ALTER TABLE "tm_projects" ADD "backgroundPicture" text`); + await queryRunner.query(`ALTER TABLE "tm_projects" ADD "backgroundColor" character varying(20)`); + await queryRunner.query(`ALTER TABLE "tm_projects" ADD "isActive" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "tm_project_users" ADD CONSTRAINT "FK_fcc8e135e4891925d288dbb14e5" FOREIGN KEY ("projectId") REFERENCES "tm_projects"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tm_project_users" ADD CONSTRAINT "FK_8e2d2c9d639d5fd806159f86980" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tm_project_users" DROP CONSTRAINT "FK_8e2d2c9d639d5fd806159f86980"`); + await queryRunner.query(`ALTER TABLE "tm_project_users" DROP CONSTRAINT "FK_fcc8e135e4891925d288dbb14e5"`); + await queryRunner.query(`ALTER TABLE "tm_projects" DROP COLUMN "isActive"`); + await queryRunner.query(`ALTER TABLE "tm_projects" DROP COLUMN "backgroundColor"`); + await queryRunner.query(`ALTER TABLE "tm_projects" DROP COLUMN "backgroundPicture"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8e2d2c9d639d5fd806159f8698"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fcc8e135e4891925d288dbb14e"`); + await queryRunner.query(`DROP TABLE "tm_project_users"`); + } + +} diff --git a/src/modules/task-manager/controllers/project.controller.ts b/src/modules/task-manager/controllers/project.controller.ts new file mode 100644 index 0000000..5f59e4d --- /dev/null +++ b/src/modules/task-manager/controllers/project.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from "@nestjs/swagger"; +import { ProjectService } from "../providers/project.service"; +import { CreateProjectDto } from "../dto/project/create-project.dto"; +import { UpdateProjectDto } from "../dto/project/update-project.dto"; +import { TMProject } from "../entities/project.entity"; + +@ApiTags("Task Manager - Projects") +@Controller("task-manager/projects") +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + @ApiOperation({ summary: "Get all projects, optionally filtered by workspace" }) + @ApiQuery({ name: "workspaceId", required: false, description: "Filter projects by workspace ID" }) + @ApiResponse({ status: 200, description: "List of projects", type: [TMProject] }) + findAll(@Query("workspaceId") workspaceId?: string): Promise { + if (workspaceId) return this.projectService.findByWorkspace(workspaceId); + return this.projectService.findAll(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a project by ID" }) + @ApiParam({ name: "id", description: "Project ID" }) + @ApiResponse({ status: 200, description: "Project found", type: TMProject }) + @ApiResponse({ status: 404, description: "Project not found" }) + findOne(@Param("id") id: string): Promise { + return this.projectService.findOne(id); + } + + @Post() + @ApiOperation({ summary: "Create a new project" }) + @ApiResponse({ status: 201, description: "Project created", type: TMProject }) + @ApiResponse({ status: 400, description: "One or more users are not members of the workspace" }) + @ApiResponse({ status: 404, description: "Workspace not found" }) + create(@Body() body: CreateProjectDto): Promise { + return this.projectService.create(body); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a project" }) + @ApiParam({ name: "id", description: "Project ID" }) + @ApiResponse({ status: 200, description: "Project updated", type: TMProject }) + @ApiResponse({ status: 400, description: "One or more users are not members of the workspace" }) + @ApiResponse({ status: 404, description: "Project not found" }) + update(@Param("id") id: string, @Body() body: UpdateProjectDto): Promise { + return this.projectService.update(id, body); + } + + @Delete(":id") + @ApiOperation({ summary: "Delete a project" }) + @ApiParam({ name: "id", description: "Project ID" }) + @ApiResponse({ status: 200, description: "Project deleted" }) + @ApiResponse({ status: 404, description: "Project not found" }) + remove(@Param("id") id: string): Promise { + return this.projectService.remove(id); + } +} diff --git a/src/modules/task-manager/dto/project/create-project.dto.ts b/src/modules/task-manager/dto/project/create-project.dto.ts index 7c9c2c1..588fcb9 100644 --- a/src/modules/task-manager/dto/project/create-project.dto.ts +++ b/src/modules/task-manager/dto/project/create-project.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsString, IsOptional, IsNotEmpty, IsUUID, IsDateString, MaxLength } from "class-validator"; +import { IsString, IsOptional, IsNotEmpty, IsUUID, IsDateString, IsBoolean, MaxLength } from "class-validator"; export class CreateProjectDto { @ApiProperty({ description: "Name of the project", example: "Mobile App Redesign" }) @@ -33,8 +33,29 @@ export class CreateProjectDto { @IsOptional() backgroundPicture?: string; + @ApiProperty({ description: "Background color of the project (hex or named)", required: false, example: "#4F46E5" }) + @IsString() + @IsOptional() + @MaxLength(20) + backgroundColor?: string; + + @ApiProperty({ description: "Whether the project is active", required: false, default: true, example: true }) + @IsBoolean() + @IsOptional() + isActive?: boolean; + @ApiProperty({ description: "ID of the workspace this project belongs to", example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }) @IsUUID() @IsNotEmpty() workspaceId: string; + + @ApiProperty({ + description: "IDs of users to assign to the project (must be members of the workspace)", + required: false, + type: [String], + example: ["b2c3d4e5-f6a7-8901-bcde-f12345678901"], + }) + @IsUUID("4", { each: true }) + @IsOptional() + userIds?: string[]; } diff --git a/src/modules/task-manager/dto/project/update-project.dto.ts b/src/modules/task-manager/dto/project/update-project.dto.ts index df437f1..2b845b1 100644 --- a/src/modules/task-manager/dto/project/update-project.dto.ts +++ b/src/modules/task-manager/dto/project/update-project.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateProjectDto } from './create-project.dto'; +import { PartialType } from "@nestjs/swagger"; +import { CreateProjectDto } from "./create-project.dto"; -export class UpdateProjectDto extends PartialType(CreateProjectDto) {} \ No newline at end of file +export class UpdateProjectDto extends PartialType(CreateProjectDto) {} diff --git a/src/modules/task-manager/entities/project.entity.ts b/src/modules/task-manager/entities/project.entity.ts index 890870c..0166a5c 100644 --- a/src/modules/task-manager/entities/project.entity.ts +++ b/src/modules/task-manager/entities/project.entity.ts @@ -1,7 +1,8 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from "typeorm"; +import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from "typeorm"; import { BaseEntity } from "../../../common/entities/base.entity"; import { TMWorkspace } from "./workspace.entity"; import { TMTaskPhase } from "./task-phase.entity"; +import { User } from "../../users/entities/user.entity"; @Entity("tm_projects") export class TMProject extends BaseEntity { @@ -20,6 +21,12 @@ export class TMProject extends BaseEntity { @Column({ type: "text", nullable: true }) backgroundPicture: string; + @Column({ type: "varchar", length: 20, nullable: true }) + backgroundColor: string; + + @Column({ type: "boolean", default: true }) + isActive: boolean; + // --- Relations --- @ManyToOne(() => TMWorkspace, (workspace) => workspace.projects, { @@ -36,4 +43,12 @@ export class TMProject extends BaseEntity { cascade: ["insert", "update"], }) taskPhases: TMTaskPhase[]; + + @ManyToMany(() => User, (user) => user.projects) + @JoinTable({ + name: "tm_project_users", + joinColumn: { name: "projectId", referencedColumnName: "id" }, + inverseJoinColumn: { name: "userId", referencedColumnName: "id" }, + }) + users: User[]; } diff --git a/src/modules/task-manager/providers/project.service.ts b/src/modules/task-manager/providers/project.service.ts new file mode 100644 index 0000000..752aede --- /dev/null +++ b/src/modules/task-manager/providers/project.service.ts @@ -0,0 +1,92 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; +import { ProjectRepository } from "../repositories/project.repository"; +import { TMProject } from "../entities/project.entity"; +import { CreateProjectDto } from "../dto/project/create-project.dto"; +import { UpdateProjectDto } from "../dto/project/update-project.dto"; +import { User } from "../../users/entities/user.entity"; +import { WorkspaceRepository } from "../repositories/workspace.repository"; + +@Injectable() +export class ProjectService { + constructor( + private readonly projectRepository: ProjectRepository, + private readonly workspaceRepository: WorkspaceRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + findAll(): Promise { + return this.projectRepository.findAll(); + } + + findByWorkspace(workspaceId: string): Promise { + return this.projectRepository.findByWorkspace(workspaceId); + } + + async findOne(id: string): Promise { + const project = await this.projectRepository.findOneById(id); + if (!project) throw new NotFoundException(`Project #${id} not found`); + return project; + } + + async create(dto: CreateProjectDto): Promise { + const { userIds, startDate, ...rest } = dto; + + const workspace = await this.workspaceRepository.findOneById(dto.workspaceId); + if (!workspace) throw new NotFoundException(`Workspace #${dto.workspaceId} not found`); + + const project = this.projectRepository.create({ + ...rest, + startDate: new Date(startDate), + }); + + if (userIds?.length) { + const workspaceUserIds = workspace.users.map((u) => u.id); + const invalidUsers = userIds.filter((id) => !workspaceUserIds.includes(id)); + if (invalidUsers.length) { + throw new BadRequestException(`Users [${invalidUsers.join(", ")}] are not members of this workspace`); + } + project.users = await this.userRepository.findBy({ id: In(userIds) }); + } + + return this.projectRepository.save(project); + } + + async update(id: string, dto: UpdateProjectDto): Promise { + const project = await this.findOne(id); + const { userIds, startDate, ...rest } = dto; + + Object.assign(project, { + ...rest, + startDate: startDate ? new Date(startDate) : project.startDate, + }); + + if (userIds) { + if (userIds.length) { + const workspaceId = dto.workspaceId ?? project.workspaceId; + const workspace = await this.workspaceRepository.findOneById(workspaceId); + if (!workspace) throw new NotFoundException(`Workspace #${workspaceId} not found`); + + const workspaceUserIds = workspace.users.map((u) => u.id); + const invalidUsers = userIds.filter((uid) => !workspaceUserIds.includes(uid)); + if (invalidUsers.length) { + throw new BadRequestException(`Users [${invalidUsers.join(", ")}] are not members of this workspace`); + } + project.users = await this.userRepository.findBy({ id: In(userIds) }); + } else { + project.users = []; + } + } + + return this.projectRepository.save(project); + } + + async remove(id: string): Promise { + const project = await this.findOne(id); + await this.projectRepository.delete(id); + + return project; + } +} diff --git a/src/modules/task-manager/repositories/project.repository.ts b/src/modules/task-manager/repositories/project.repository.ts new file mode 100644 index 0000000..0a4a1c4 --- /dev/null +++ b/src/modules/task-manager/repositories/project.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { TMProject } from "../entities/project.entity"; + +@Injectable() +export class ProjectRepository { + constructor( + @InjectRepository(TMProject) + private readonly repository: Repository, + ) {} + + findAll() { + return this.repository.find({ + relations: ["workspace", "taskPhases", "users"], + }); + } + + findByWorkspace(workspaceId: string) { + return this.repository.find({ + where: { workspaceId }, + relations: ["taskPhases", "users"], + }); + } + + findOneById(id: string) { + return this.repository.findOne({ + where: { id }, + relations: ["workspace", "taskPhases", "users"], + }); + } + + create(data: Partial) { + return this.repository.create(data); + } + + save(project: TMProject) { + return this.repository.save(project); + } + + update(id: string, data: Partial) { + return this.repository.update(id, data); + } + + delete(id: string) { + return this.repository.delete(id); + } +} diff --git a/src/modules/task-manager/task-manager.module.ts b/src/modules/task-manager/task-manager.module.ts index 35a1c5c..d92c7c2 100644 --- a/src/modules/task-manager/task-manager.module.ts +++ b/src/modules/task-manager/task-manager.module.ts @@ -16,29 +16,25 @@ import { User } from "../users/entities/user.entity"; import { WorkspaceTypeService } from "./providers/workspace-type.service"; import { WorkspaceTypeRepository } from "./repositories/workspace-type.repository"; import { WorkspaceTypeController } from "./controllers/workspace-type.controller"; - -// import { WorkspaceTypeService } from './providers/workspace-type.service'; -// import { WorkspaceService } from './providers/workspace.service'; -// import { ProjectService } from './providers/project.service'; -// import { TaskPhaseService } from './providers/task-phase.service'; -// import { TaskService } from './providers/task.service'; - -// import { TaskManagerController } from './task-manager.controller'; +import { ProjectController } from "./controllers/project.controller"; +import { ProjectRepository } from "./repositories/project.repository"; +import { ProjectService } from "./providers/project.service"; @Module({ imports: [ TypeOrmModule.forFeature([TMWorkspaceType, TMWorkspace, TMProject, TMTaskPhase, TMTask, TMRemark, TMAttachment, TMCheckListItem, User]), ], - controllers: [WorkspaceController, WorkspaceTypeController], + controllers: [WorkspaceController, WorkspaceTypeController, ProjectController], providers: [ WorkspaceRepository, WorkspaceService, WorkspaceTypeRepository, WorkspaceTypeService, - // ProjectService, + ProjectRepository, + ProjectService, // TaskPhaseService, // TaskService, ], - exports: [WorkspaceService, WorkspaceRepository, WorkspaceTypeService, WorkspaceTypeRepository], + exports: [WorkspaceService, WorkspaceRepository, WorkspaceTypeService, WorkspaceTypeRepository, ProjectService, ProjectRepository], }) export class TaskManagerModule {} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index abcd099..5b85278 100755 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -34,6 +34,7 @@ import { Reseller } from "../../reseller/entities/reseller.entity"; import { UserBankAccount } from "../../reseller/entities/user-bank-account.entity"; import { TMWorkspace } from "../../task-manager/entities/workspace.entity"; import { TMTask } from "../../task-manager/entities/task.entity"; +import { TMProject } from "../../task-manager/entities/project.entity"; @Entity() export class User extends BaseEntity { @@ -185,4 +186,7 @@ export class User extends BaseEntity { @ManyToMany(() => TMTask, (task) => task.users) tasks: TMTask[]; + + @ManyToMany(() => TMProject, (project) => project.users) + projects: TMProject[]; }