From 6131e8fd27ce11de2e680337e5029baa9e816ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 11 Jul 2024 08:08:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=83=9C=EC=8A=A4=ED=81=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=82=AD=EC=A0=9C,=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC,=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태스크 엔티티 작성 - 프로젝트 별 고유 태스크 ID를 위한 displayIdCount를 프로젝트 엔티티에 추가 - 태스크를 모듈에 추가 - 태스크 create, delete, update 레포지토리, 서비스 메서드 작성 --- backend/src/app.module.ts | 2 + backend/src/project/entity/project.entity.ts | 4 + backend/src/project/entity/task.entity.ts | 81 +++++++++++++++++++ backend/src/project/project.module.ts | 6 +- backend/src/project/project.repository.ts | 70 +++++++++++++++- .../src/project/service/project.service.ts | 71 ++++++++++++++-- 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 backend/src/project/entity/task.entity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f4549969..af33328a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -31,6 +31,7 @@ import { Memo } from './project/entity/memo.entity'; import { Link } from './project/entity/link.entity.'; import { Epic } from './project/entity/epic.entity'; import { Story } from './project/entity/story.entity'; +import { Task } from './project/entity/task.entity'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { Story } from './project/entity/story.entity'; Link, Epic, Story, + Task ], synchronize: ConfigService.get('NODE_ENV') == 'PROD' ? false : true, }), diff --git a/backend/src/project/entity/project.entity.ts b/backend/src/project/entity/project.entity.ts index c6698cbe..54cecd7f 100644 --- a/backend/src/project/entity/project.entity.ts +++ b/backend/src/project/entity/project.entity.ts @@ -45,10 +45,14 @@ export class Project { @OneToMany(() => Link, (link) => link.id) linkList: Link[]; + @Column({type: 'int', nullable: false}) + displayIdCount: number; + static of(title: string, subject: string) { const newProject = new Project(); newProject.title = title; newProject.subject = subject; + newProject.displayIdCount = 0; return newProject; } } diff --git a/backend/src/project/entity/task.entity.ts b/backend/src/project/entity/task.entity.ts new file mode 100644 index 00000000..cdc4a4aa --- /dev/null +++ b/backend/src/project/entity/task.entity.ts @@ -0,0 +1,81 @@ +import { Member } from 'src/member/entity/member.entity'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Project } from './project.entity'; +import { Story } from './story.entity'; + +export enum TaskStatus { + NotStarted = '시작전', + InProgress = '진행중', + Completed = '완료', +} + +@Entity() +export class Task { + @PrimaryGeneratedColumn('increment', { type: 'int' }) + id: number; + + @Column({ type: 'int', name: 'project_id', nullable: false }) + projectId: number; + + @ManyToOne(() => Project, (project) => project.id, { nullable: false }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @Column({ type: 'int', name: 'story_id', nullable: false }) + storyId: number; + + @ManyToOne(() => Story, (story) => story.id, { nullable: false }) + @JoinColumn({ name: 'story_id' }) + story: Story; + + @Column({ type: 'varchar', length: 99, nullable: false }) + title: string; + + @Column({ type: 'int', nullable: false }) + displayId: number; + + @Column({ type: 'double', nullable: true }) + expectedTime: number; + + @Column({ type: 'double', nullable: true }) + actualTime: number; + + @Column({ type: 'varchar', length: 255, nullable: false }) + status: TaskStatus; + + @Column({ type: 'int', name: 'member_id', nullable: true }) + assignedMemberId: number; + + @ManyToOne(() => Member, (member) => member.id, { nullable: true }) + @JoinColumn({ name: 'member_id' }) + member: Member; + + static of( + project: Project, + storyId: number, + title: string, + displayId: number, + expectedTime: number, + actualTime: number, + memberId: number, + status: TaskStatus, + ) { + const newTask = new Task(); + newTask.project = project; + newTask.storyId = storyId; + newTask.title = title; + newTask.displayId = displayId; + + newTask.expectedTime = expectedTime; + newTask.actualTime = actualTime; + newTask.assignedMemberId = memberId; + newTask.status = status; + return newTask; + } +} diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index ec03a550..88fbe6c7 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -20,6 +20,8 @@ import { WsProjectEpicController } from './ws-controller/ws-project-epic.control import { Epic } from './entity/epic.entity'; import { Story } from './entity/story.entity'; import { WsProjectStoryController } from './ws-controller/ws-project-story.controller'; +import { Task } from './entity/task.entity'; +import { WsProjectTaskController } from './ws-controller/ws-project-task.controller'; @Module({ imports: [ @@ -31,7 +33,8 @@ import { WsProjectStoryController } from './ws-controller/ws-project-story.contr Memo, Link, Epic, - Story + Story, + Task ]), ], controllers: [ProjectController], @@ -47,6 +50,7 @@ import { WsProjectStoryController } from './ws-controller/ws-project-story.contr WsProjectController, WsProjectEpicController, WsProjectStoryController, + WsProjectTaskController, ], }) export class ProjectModule {} diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 56b80d49..ed383ae4 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -5,10 +5,10 @@ import { Project } from './entity/project.entity'; import { ProjectToMember } from './entity/project-member.entity'; import { Member } from 'src/member/entity/member.entity'; import { Memo, memoColor } from './entity/memo.entity'; -import { MemberRepository } from 'src/member/repository/member.repository'; import { Link } from './entity/link.entity.'; import { Epic, EpicColor } from './entity/epic.entity'; import { Story, StoryStatus } from './entity/story.entity'; +import { Task, TaskStatus } from './entity/task.entity'; @Injectable() export class ProjectRepository { @@ -27,6 +27,8 @@ export class ProjectRepository { private readonly epicRepository: Repository, @InjectRepository(Story) private readonly storyRepository: Repository, + @InjectRepository(Task) + private readonly taskRepository: Repository, ) {} create(project: Project): Promise { @@ -196,4 +198,70 @@ export class ProjectRepository { ); return !!result.affected; } + + getStoryById(project: Project, id: number) { + return this.storyRepository.findOne({ + where: { id: id, projectId: project.id }, + }); + } + + async getAndIncrementDisplayIdCount(project: Project) { + const targetProject = await this.projectRepository.findOne({ + where: { id: project.id }, + }); + await this.projectRepository.update(project.id, { + displayIdCount: targetProject.displayIdCount + 1, + }); + return targetProject.displayIdCount; + } + + async createTask(task: Task) { + return this.taskRepository.save(task); + } + + async deleteTask(project: Project, taskId: number): Promise { + const result = await this.taskRepository.delete({ + project: { id: project.id }, + id: taskId, + }); + return result.affected ? result.affected : 0; + } + + async updateTask( + project: Project, + id: number, + storyId: number | undefined, + title: string | undefined, + expectedTime: number | undefined, + actualTime: number | undefined, + status: TaskStatus | undefined, + assignedMemberId: number | undefined, + ): Promise { + const updateData: any = {}; + + if (storyId !== undefined) { + updateData.storyId = storyId; + } + if (title !== undefined) { + updateData.title = title; + } + if (expectedTime !== undefined) { + updateData.expectedTime = expectedTime; + } + if (actualTime !== undefined) { + updateData.actualTime = actualTime; + } + if (status !== undefined) { + updateData.status = status; + } + if (assignedMemberId !== undefined) { + updateData.assignedMemberId = assignedMemberId; + } + + const result = await this.taskRepository.update( + { id, project: { id: project.id } }, + updateData, + ); + return !!result.affected; + } } diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index eed2b176..c5481d68 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -7,6 +7,7 @@ import { Memo, memoColor } from '../entity/memo.entity'; import { Link } from '../entity/link.entity.'; import { Epic, EpicColor } from '../entity/epic.entity'; import { Story, StoryStatus } from '../entity/story.entity'; +import { Task, TaskStatus } from '../entity/task.entity'; @Injectable() export class ProjectService { @@ -113,15 +114,15 @@ export class ProjectService { return this.projectRepository.updateEpic(project, id, name, color); } - createStory( + async createStory( project: Project, epicId: number, title: string, point: number, status: StoryStatus, ) { - const epic = this.projectRepository.getEpicById(project, epicId); - if (!epic) throw new Error('epic id is not found'); + const epic = await this.projectRepository.getEpicById(project, epicId); + if (!epic) throw new Error('epic id not found'); const newStory = Story.of(project, epicId, title, point, status); return this.projectRepository.createStory(newStory); } @@ -131,7 +132,7 @@ export class ProjectService { return result ? true : false; } - updateStory( + async updateStory( project: Project, id: number, epicId: number | undefined, @@ -140,8 +141,8 @@ export class ProjectService { status: StoryStatus | undefined, ): Promise { if (epicId !== undefined) { - const epic = this.projectRepository.getEpicById(project, epicId); - if (!epic) throw new Error('epic id is not found'); + const epic = await this.projectRepository.getEpicById(project, epicId); + if (!epic) throw new Error('epic id not found'); } return this.projectRepository.updateStory( project, @@ -152,4 +153,62 @@ export class ProjectService { status, ); } + + async createTask( + project: Project, + title: string, + expectedTime: number, + actualTime: number, + status: TaskStatus, + assignedMemberId: number, + storyId: number, + ) { + const story = await this.projectRepository.getStoryById(project, storyId); + if (!story) throw new Error('Story id not found'); + const displayIdCount = + await this.projectRepository.getAndIncrementDisplayIdCount(project); + + const newTask = Task.of( + project, + storyId, + title, + displayIdCount, + expectedTime, + actualTime, + assignedMemberId, + status, + ); + return this.projectRepository.createTask(newTask); + } + + async deleteTask(project: Project, taskId: number) { + const result = await this.projectRepository.deleteTask(project, taskId); + return result ? true : false; + } + + async updateTask( + project: Project, + id: number, + storyId: number | undefined, + title: string | undefined, + expectedTime: number | undefined, + actualTime: number | undefined, + status: TaskStatus | undefined, + assignedMemberId: number | undefined, + ): Promise { + if (storyId !== undefined) { + const story = await this.projectRepository.getStoryById(project, storyId); + if (!story) throw new Error('story id not found'); + } + return this.projectRepository.updateTask( + project, + id, + storyId, + title, + expectedTime, + actualTime, + status, + assignedMemberId, + ); + } } From 1d4ae8f60c9812e5d0fe2feaf0e0456441d7e9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 11 Jul 2024 08:09:04 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=83=9C=EC=8A=A4=ED=81=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=82=AD=EC=A0=9C,=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EC=9B=A8?= =?UTF-8?q?=EC=9D=B4,=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC,=20DTO?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태스크 이벤트를 처리하는 게이트웨이 로직 추가 - 태스크 생성, 삭제, 업데이트를 처리하는 컨트롤러 추가 - 태스크 생성, 삭제, 업데이트에 대해 요청/응답을 확인/생성하는 request, notify DTO 추가 --- .../project/dto/task/TaskCreateNotify.dto.ts | 37 ++++++++ .../project/dto/task/TaskCreateRequest.dto.ts | 74 +++++++++++++++ .../project/dto/task/TaskDeleteNotify.dto.ts | 22 +++++ .../project/dto/task/TaskDeleteRequest.dto.ts | 17 ++++ .../project/dto/task/TaskUpdateNotify.dto.ts | 61 ++++++++++++ .../project/dto/task/TaskUpdateRequest.dto.ts | 51 ++++++++++ backend/src/project/websocket.gateway.ts | 19 +++- .../ws-project-task.controller.ts | 95 +++++++++++++++++++ 8 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 backend/src/project/dto/task/TaskCreateNotify.dto.ts create mode 100644 backend/src/project/dto/task/TaskCreateRequest.dto.ts create mode 100644 backend/src/project/dto/task/TaskDeleteNotify.dto.ts create mode 100644 backend/src/project/dto/task/TaskDeleteRequest.dto.ts create mode 100644 backend/src/project/dto/task/TaskUpdateNotify.dto.ts create mode 100644 backend/src/project/dto/task/TaskUpdateRequest.dto.ts create mode 100644 backend/src/project/ws-controller/ws-project-task.controller.ts diff --git a/backend/src/project/dto/task/TaskCreateNotify.dto.ts b/backend/src/project/dto/task/TaskCreateNotify.dto.ts new file mode 100644 index 00000000..81e3ca80 --- /dev/null +++ b/backend/src/project/dto/task/TaskCreateNotify.dto.ts @@ -0,0 +1,37 @@ +import { Task, TaskStatus } from 'src/project/entity/task.entity'; +class TaskDto { + id: number; + displayId: number; + title: string; + expectedTime: number|null; + actualTime: number|null; + status: TaskStatus; + assignedMemberId: number|null; + storyId: number; + static of(task: Task) { + const dto = new TaskDto(); + dto.id = task.id; + dto.displayId = task.displayId; + dto.title = task.title; + dto.expectedTime = task.expectedTime; + dto.actualTime = task.actualTime; + dto.status = task.status; + dto.assignedMemberId = task.assignedMemberId; + dto.storyId = task.storyId; + return dto; + } +} + +export class TaskCreateNotifyDto { + domain: string; + action: string; + content: TaskDto; + + static of(task: Task) { + const dto = new TaskCreateNotifyDto(); + dto.domain = 'task'; + dto.action = 'create'; + dto.content = TaskDto.of(task); + return dto; + } +} diff --git a/backend/src/project/dto/task/TaskCreateRequest.dto.ts b/backend/src/project/dto/task/TaskCreateRequest.dto.ts new file mode 100644 index 00000000..40b1625b --- /dev/null +++ b/backend/src/project/dto/task/TaskCreateRequest.dto.ts @@ -0,0 +1,74 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Length, + Matches, + ValidateNested, +} from 'class-validator'; +import { TaskStatus } from 'src/project/entity/task.entity'; + +import { + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; + +export function IsOneDecimalPlace(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'IsOneDecimalPlace', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const decimalPattern = /^\d+(\.\d{1})?$/; + return ( + typeof value === 'number' && decimalPattern.test(value.toString()) + ); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a number with exactly one decimal place`; + }, + }, + }); + }; +} + +class Task { + @IsString() + @Length(0, 100, { message: 'Title must be 100 characters or less' }) + title: string; + + @IsOptional() + @IsOneDecimalPlace() + expectedTime: number; + + @IsOptional() + @IsOneDecimalPlace() + actualTime: number; + + @IsEnum(TaskStatus) + status: TaskStatus; + + @IsOptional() + @IsInt() + assignedMemberId: number; + + @IsInt() + storyId: number; +} + +export class TaskCreateRequestDto { + @Matches(/^create$/) + action: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => Task) + content: Task; +} diff --git a/backend/src/project/dto/task/TaskDeleteNotify.dto.ts b/backend/src/project/dto/task/TaskDeleteNotify.dto.ts new file mode 100644 index 00000000..fa4ab22c --- /dev/null +++ b/backend/src/project/dto/task/TaskDeleteNotify.dto.ts @@ -0,0 +1,22 @@ +class Task { + id: number; + static of(id: number) { + const dto = new Task(); + dto.id = id; + return dto; + } +} + +export class TaskDeleteNotifyDto { + domain: string; + action: string; + content: Task; + + static of(id: number) { + const dto = new TaskDeleteNotifyDto(); + dto.domain = 'task'; + dto.action = 'delete'; + dto.content = Task.of(id); + return dto; + } +} diff --git a/backend/src/project/dto/task/TaskDeleteRequest.dto.ts b/backend/src/project/dto/task/TaskDeleteRequest.dto.ts new file mode 100644 index 00000000..bf37af2b --- /dev/null +++ b/backend/src/project/dto/task/TaskDeleteRequest.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsNotEmpty, Matches, ValidateNested } from 'class-validator'; + +class Task { + @IsInt() + id: number; +} + +export class TaskDeleteRequestDto { + @Matches(/^delete$/) + action: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => Task) + content: Task; +} diff --git a/backend/src/project/dto/task/TaskUpdateNotify.dto.ts b/backend/src/project/dto/task/TaskUpdateNotify.dto.ts new file mode 100644 index 00000000..e93c84f3 --- /dev/null +++ b/backend/src/project/dto/task/TaskUpdateNotify.dto.ts @@ -0,0 +1,61 @@ +import { TaskStatus } from 'src/project/entity/task.entity'; + +class Task { + id: number; + storyId?: number; + title?: string; + expectedTime?: number; + actualTime?: number; + status?: TaskStatus; + assignedMemberId?: number; + + static of( + id: number, + storyId: number | undefined, + title: string | undefined, + expectedTime: number | undefined, + actualTime: number | undefined, + status: TaskStatus | undefined, + assignedMemberId: number | undefined, + ) { + const dto = new Task(); + dto.id = id; + if (storyId) dto.storyId = storyId; + if (title) dto.title = title; + if (expectedTime) dto.expectedTime = expectedTime; + if (actualTime) dto.actualTime = actualTime; + if (status) dto.status = status; + if (assignedMemberId) dto.assignedMemberId = assignedMemberId; + return dto; + } +} + +export class TaskUpdateNotifyDto { + domain: string; + action: string; + content: Task; + + static of( + id: number, + storyId: number | undefined, + title: string | undefined, + expectedTime: number | undefined, + actualTime: number | undefined, + status: TaskStatus | undefined, + assignedMemberId: number | undefined, + ) { + const dto = new TaskUpdateNotifyDto(); + dto.domain = 'task'; + dto.action = 'update'; + dto.content = Task.of( + id, + storyId, + title, + expectedTime, + actualTime, + status, + assignedMemberId, + ); + return dto; + } +} diff --git a/backend/src/project/dto/task/TaskUpdateRequest.dto.ts b/backend/src/project/dto/task/TaskUpdateRequest.dto.ts new file mode 100644 index 00000000..ce444aa9 --- /dev/null +++ b/backend/src/project/dto/task/TaskUpdateRequest.dto.ts @@ -0,0 +1,51 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { TaskStatus } from 'src/project/entity/task.entity'; +import { IsOneDecimalPlace } from './TaskCreateRequest.dto'; + +class Task { + @IsInt() + id: number; + + @IsOptional() + @IsInt() + storyId?: number; + + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsOneDecimalPlace() + expectedTime?: number; + + @IsOptional() + @IsOneDecimalPlace() + actualTime?: number; + + @IsOptional() + @IsInt() + assignedMemberId?: number; + + @IsOptional() + @IsEnum(TaskStatus) + status?: TaskStatus; +} + +export class TaskUpdateRequestDto { + @Matches(/^update$/) + action: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => Task) + content: Task; +} diff --git a/backend/src/project/websocket.gateway.ts b/backend/src/project/websocket.gateway.ts index bd0c0877..3f324682 100644 --- a/backend/src/project/websocket.gateway.ts +++ b/backend/src/project/websocket.gateway.ts @@ -20,6 +20,7 @@ import { WsProjectController } from './ws-controller/ws-project.controller'; import { ClientSocket } from './type/ClientSocket.type'; import { WsProjectEpicController } from './ws-controller/ws-project-epic.controller'; import { WsProjectStoryController } from './ws-controller/ws-project-story.controller'; +import { WsProjectTaskController } from './ws-controller/ws-project-task.controller'; @WebSocketGateway({ namespace: /project-\d+/, @@ -38,6 +39,7 @@ export class ProjectWebsocketGateway private readonly wsProjectController: WsProjectController, private readonly wsProjectEpicController: WsProjectEpicController, private readonly wsProjectStoryController: WsProjectStoryController, + private readonly wsProjectTaskController: WsProjectTaskController, ) { this.namespaceMap = new Map(); } @@ -156,12 +158,25 @@ export class ProjectWebsocketGateway this.wsProjectStoryController.createStory(client, data); } else if (data.action === 'delete') { this.wsProjectStoryController.deleteStory(client, data); - } - else if (data.action === 'update') { + } else if (data.action === 'update') { this.wsProjectStoryController.updateStory(client, data); } } + @SubscribeMessage('task') + async handleTaskEvent( + @ConnectedSocket() client: ClientSocket, + @MessageBody() data: any, + ) { + if (data.action === 'create') { + this.wsProjectTaskController.createTask(client, data); + } else if (data.action === 'delete') { + this.wsProjectTaskController.deleteTask(client, data); + } else if (data.action === 'update') { + this.wsProjectTaskController.updateTask(client, data); + } + } + notifyJoinToConnectedMembers(projectId: number, member: Member) { const projectNamespace = this.namespaceMap.get(projectId); if (!projectNamespace) return; diff --git a/backend/src/project/ws-controller/ws-project-task.controller.ts b/backend/src/project/ws-controller/ws-project-task.controller.ts new file mode 100644 index 00000000..9563286c --- /dev/null +++ b/backend/src/project/ws-controller/ws-project-task.controller.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; +import { validate } from 'class-validator'; +import { TaskCreateNotifyDto } from '../dto/task/TaskCreateNotify.dto'; +import { TaskCreateRequestDto } from '../dto/task/TaskCreateRequest.dto'; +import { TaskDeleteNotifyDto } from '../dto/task/TaskDeleteNotify.dto'; +import { TaskDeleteRequestDto } from '../dto/task/TaskDeleteRequest.dto'; +import { TaskUpdateNotifyDto } from '../dto/task/TaskUpdateNotify.dto'; +import { TaskUpdateRequestDto } from '../dto/task/TaskUpdateRequest.dto'; +import { ProjectService } from '../service/project.service'; +import { ClientSocket } from '../type/ClientSocket.type'; +import { getRecursiveErrorMsgList } from '../util/validation.util'; + +@Injectable() +export class WsProjectTaskController { + constructor(private readonly projectService: ProjectService) {} + async createTask(client: ClientSocket, data: any) { + const errors = await validate(plainToClass(TaskCreateRequestDto, data)); + if (errors.length > 0) { + const errorList = getRecursiveErrorMsgList(errors); + client.emit('error', { errorList }); + return; + } + + const { content } = data as TaskCreateRequestDto; + const createdTask = await this.projectService.createTask( + client.project, + content.title, + content.expectedTime, + content.actualTime, + content.status, + content.assignedMemberId, + content.storyId, + ); + client.nsp + .to('backlog') + .emit('backlog', TaskCreateNotifyDto.of(createdTask)); + } + + async deleteTask(client: ClientSocket, data: any) { + const errors = await validate(plainToClass(TaskDeleteRequestDto, data)); + if (errors.length > 0) { + const errorList = getRecursiveErrorMsgList(errors); + client.emit('error', { errorList }); + return; + } + const { content } = data as TaskDeleteRequestDto; + const isDeleted = await this.projectService.deleteTask( + client.project, + content.id, + ); + if (isDeleted) { + client.nsp + .to('backlog') + .emit('backlog', TaskDeleteNotifyDto.of(content.id)); + } + } + + async updateTask(client: ClientSocket, data: any) { + const errors = await validate(plainToClass(TaskUpdateRequestDto, data)); + if (errors.length > 0) { + const errorList = getRecursiveErrorMsgList(errors); + client.emit('error', { errorList }); + return; + } + const { content } = data as TaskUpdateRequestDto; + const isUpdated = await this.projectService.updateTask( + client.project, + content.id, + content.storyId, + content.title, + content.expectedTime, + content.actualTime, + content.status, + content.assignedMemberId, + ); + + if (isUpdated) { + client.nsp + .to('backlog') + .emit( + 'backlog', + TaskUpdateNotifyDto.of( + content.id, + content.storyId, + content.title, + content.expectedTime, + content.actualTime, + content.status, + content.assignedMemberId, + ), + ); + } + } +} From 6257f424f63f5ba1fcfca8657da7d2e00f9fa09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 11 Jul 2024 08:09:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=ED=83=9C=EC=8A=A4=ED=81=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=82=AD=EC=A0=9C,=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ws-backlog-page/ws-story.e2e-spec.ts | 1 - .../ws-backlog-page/ws-task.e2e-spec.ts | 394 ++++++++++++++++++ 2 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts diff --git a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts index a4a560c2..02cf54e5 100644 --- a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts @@ -150,7 +150,6 @@ describe('WS story', () => { const expectTitleUpdateStory = (socket, id, title) => { return new Promise((resolve) => { socket.once('backlog', async (data) => { - console.log(data); const { content, action, domain } = data; expect(domain).toBe('story'); expect(action).toBe('update'); diff --git a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts new file mode 100644 index 00000000..787411cc --- /dev/null +++ b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts @@ -0,0 +1,394 @@ +import { Socket } from 'socket.io-client'; +import { app, appInit } from 'test/setup'; +import { + getMemberJoinedLandingPage, + getTwoMemberJoinedLandingPage, +} from '../ws-common'; + +describe('WS task', () => { + beforeEach(async () => { + await app.close(); + await appInit(); + await app.listen(3000); + }); + + describe('task create', () => { + it('should return created task data', async () => { + const [socket1, socket2] = await getTwoMemberJoinedLandingPage(); + socket1.emit('joinBacklog'); + socket2.emit('joinBacklog'); + await Promise.all([initBacklog(socket1), initBacklog(socket2)]); + + const name = '회원'; + const color = 'yellow'; + let requestData: any = { + action: 'create', + content: { name, color }, + }; + socket1.emit('epic', requestData); + const [epicId] = await Promise.all([ + getEpicId(socket1), + getEpicId(socket2), + ]); + + const storyTitle = '타이틀'; + const storyPoint = 2; + const storyStatus = '시작전'; + requestData = { + action: 'create', + content: { + title: storyTitle, + point: storyPoint, + status: storyStatus, + epicId, + }, + }; + socket1.emit('story', requestData); + const [storyId] = await Promise.all([ + getStoryId(socket1), + getStoryId(socket2), + ]); + + await testCreateTask( + socket1, + socket2, + '타이틀', + '시작전', + storyId, + null, + null, + null, + ); + + await testCreateTask( + socket1, + socket2, + '타이틀', + '시작전', + storyId, + 2.1, + 3.3, + null, + ); + + await testCreateTask( + socket1, + socket2, + '타이틀', + '진행중', + storyId, + null, + null, + null, + ); + + await testCreateTask( + socket1, + socket2, + '타이틀', + '완료', + storyId, + null, + null, + null, + ); + + socket1.close(); + socket2.close(); + }); + + const testCreateTask = async ( + socket1, + socket2, + title, + status, + storyId, + expectedTime, + actualTime, + assignedMemberId, + ) => { + const requestData = { + action: 'create', + content: { + title, + status, + storyId, + expectedTime, + actualTime, + assignedMemberId, + }, + }; + socket1.emit('task', requestData); + + await Promise.all([ + expectCreateTask( + socket1, + title, + status, + storyId, + expectedTime, + actualTime, + assignedMemberId, + ), + expectCreateTask( + socket2, + title, + status, + storyId, + expectedTime, + actualTime, + assignedMemberId, + ), + ]); + }; + + const expectCreateTask = ( + socket, + title, + status, + storyId, + expectedTime, + actualTime, + assignedMemberId, + ) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('task'); + expect(action).toBe('create'); + expect(content?.id).toBeDefined(); + expect(content?.displayId).toBeDefined(); + expect(content?.title).toBe(title); + expect(content?.status).toBe(status); + expect(content?.storyId).toBe(storyId); + expect(content?.actualTime).toBe(actualTime); + expect(content?.expectedTime).toBe(expectedTime); + expect(content?.assignedMemberId).toBe(assignedMemberId); + resolve(); + }); + }); + }; + }); + + describe('task delete', () => { + it('should return deleted task data', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + let requestData: any = { + action: 'create', + content: { name, color }, + }; + socket.emit('epic', requestData); + const [epicId] = await Promise.all([getEpicId(socket)]); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId }, + }; + socket.emit('story', requestData); + const storyId = await getStoryId(socket); + + let taskTitle = '타이틀'; + let taskStatus = '시작전'; + let expectedTime = null; + let actualTime = null; + let assignedMemberId = null; + requestData = { + action: 'create', + content: { + title: taskTitle, + status: taskStatus, + storyId, + expectedTime, + actualTime, + assignedMemberId, + }, + }; + socket.emit('task', requestData); + const taskId = await getTaskId(socket); + + requestData = { + action: 'delete', + content: { id: taskId }, + }; + socket.emit('task', requestData); + await expectDeleteTask(socket, taskId); + + socket.close(); + }); + + const expectDeleteTask = (socket, id) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('task'); + expect(action).toBe('delete'); + expect(content?.id).toBe(id); + resolve(); + }); + }); + }; + }); + + describe('task update', () => { + it('should return updated task data when update color', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + let requestData: any = { + action: 'create', + content: { name, color }, + }; + socket.emit('epic', requestData); + const [epicId] = await Promise.all([getEpicId(socket)]); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId }, + }; + socket.emit('story', requestData); + const storyId = await getStoryId(socket); + + let taskTitle = '타이틀'; + let taskStatus = '시작전'; + let expectedTime = null; + let actualTime = null; + let assignedMemberId = null; + requestData = { + action: 'create', + content: { + title: taskTitle, + status: taskStatus, + storyId, + expectedTime, + actualTime, + assignedMemberId, + }, + }; + socket.emit('task', requestData); + const taskId = await getTaskId(socket); + + const newTitle = 'newTitle'; + requestData = { + action: 'update', + content: { id: taskId, title: newTitle }, + }; + socket.emit('task', requestData); + await expectTitleUpdateTask(socket, taskId, newTitle); + + const newExpectedTime = 5; + const newActualTime = 3; + const newStatus = '완료'; + requestData = { + action: 'update', + content: { + id: taskId, + expectedTime: newExpectedTime, + actualTime: newActualTime, + status: newStatus, + }, + }; + socket.emit('task', requestData); + await expectTimeAndStatusWhenUpdateTask( + socket, + taskId, + newExpectedTime, + newActualTime, + newStatus, + ); + + socket.close(); + }); + + const expectTitleUpdateTask = (socket, id, title) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('task'); + expect(action).toBe('update'); + expect(content?.id).toBe(id); + expect(content?.title).toBe(title); + resolve(); + }); + }); + }; + + const expectTimeAndStatusWhenUpdateTask = ( + socket, + id, + expectedTime, + actualTime, + status, + ) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('task'); + expect(action).toBe('update'); + expect(content?.id).toBe(id); + expect(content?.expectedTime).toBe(expectedTime); + expect(content?.actualTime).toBe(actualTime); + expect(content?.status).toBe(status); + resolve(); + }); + }); + }; + }); +}); + +const initBacklog = async (socket: Socket) => { + return await new Promise((resolve, reject) => { + socket.once('backlog', (data) => { + const { action, domain } = data; + if (action === 'init' && domain === 'backlog') { + resolve(); + } else reject(); + }); + }); +}; + +const getEpicId = (socket) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'epic' && action === 'create') { + resolve(content.id); + } + }); + }); +}; + +const getStoryId = (socket) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'story' && action === 'create') { + resolve(content.id); + } + }); + }); +}; + +const getTaskId = (socket) => { + return new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'task' && action === 'create') { + resolve(content.id); + } + }); + }); +};