diff --git a/backend/src/project/dto/task/TaskCreateNotify.dto.ts b/backend/src/project/dto/task/TaskCreateNotify.dto.ts index 81e3ca80..8a68be06 100644 --- a/backend/src/project/dto/task/TaskCreateNotify.dto.ts +++ b/backend/src/project/dto/task/TaskCreateNotify.dto.ts @@ -8,6 +8,8 @@ class TaskDto { status: TaskStatus; assignedMemberId: number|null; storyId: number; + rankValue: string; + static of(task: Task) { const dto = new TaskDto(); dto.id = task.id; @@ -18,6 +20,7 @@ class TaskDto { dto.status = task.status; dto.assignedMemberId = task.assignedMemberId; dto.storyId = task.storyId; + dto.rankValue = task.rankValue; return dto; } } diff --git a/backend/src/project/dto/task/TaskCreateRequest.dto.ts b/backend/src/project/dto/task/TaskCreateRequest.dto.ts index e2486c5c..f0e8669c 100644 --- a/backend/src/project/dto/task/TaskCreateRequest.dto.ts +++ b/backend/src/project/dto/task/TaskCreateRequest.dto.ts @@ -16,6 +16,7 @@ import { ValidationOptions, ValidationArguments, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; export function IsOneDecimalPlace(validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { @@ -61,6 +62,11 @@ class Task { @IsInt() storyId: number; + + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue: string; } export class TaskCreateRequestDto { diff --git a/backend/src/project/dto/task/TaskUpdateNotify.dto.ts b/backend/src/project/dto/task/TaskUpdateNotify.dto.ts index befe961c..ed709922 100644 --- a/backend/src/project/dto/task/TaskUpdateNotify.dto.ts +++ b/backend/src/project/dto/task/TaskUpdateNotify.dto.ts @@ -8,6 +8,7 @@ class Task { actualTime?: number; status?: TaskStatus; assignedMemberId?: number; + rankValue?: string; static of( id: number, @@ -17,6 +18,7 @@ class Task { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ) { const dto = new Task(); dto.id = id; @@ -26,6 +28,7 @@ class Task { if (actualTime !== undefined) dto.actualTime = actualTime; if (status !== undefined) dto.status = status; if (assignedMemberId !== undefined) dto.assignedMemberId = assignedMemberId; + if (rankValue !== undefined) dto.rankValue = rankValue; return dto; } } @@ -43,6 +46,7 @@ export class TaskUpdateNotifyDto { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ) { const dto = new TaskUpdateNotifyDto(); dto.domain = 'task'; @@ -55,6 +59,7 @@ export class TaskUpdateNotifyDto { actualTime, status, assignedMemberId, + rankValue, ); return dto; } diff --git a/backend/src/project/dto/task/TaskUpdateRequest.dto.ts b/backend/src/project/dto/task/TaskUpdateRequest.dto.ts index ec8adba6..93d0724d 100644 --- a/backend/src/project/dto/task/TaskUpdateRequest.dto.ts +++ b/backend/src/project/dto/task/TaskUpdateRequest.dto.ts @@ -9,6 +9,7 @@ import { Matches, ValidateNested, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; import { TaskStatus } from 'src/project/entity/task.entity'; import { AtLeastOneProperty } from 'src/project/util/validation.util'; import { IsOneDecimalPlace } from './TaskCreateRequest.dto'; @@ -22,6 +23,7 @@ class Task { 'actualTime', 'assignedMemberId', 'status', + 'rankValue', ]) id: number; @@ -49,6 +51,12 @@ class Task { @IsOptional() @IsEnum(TaskStatus) status?: TaskStatus; + + @IsOptional() + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue?: string; } export class TaskUpdateRequestDto { diff --git a/backend/src/project/entity/task.entity.ts b/backend/src/project/entity/task.entity.ts index 8a1efe4d..4238d240 100644 --- a/backend/src/project/entity/task.entity.ts +++ b/backend/src/project/entity/task.entity.ts @@ -5,6 +5,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, + Unique, } from 'typeorm'; import { Project } from './project.entity'; import { Story } from './story.entity'; @@ -16,6 +17,7 @@ export enum TaskStatus { } @Entity() +@Unique(['rankValue', 'projectId']) export class Task { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; @@ -62,6 +64,9 @@ export class Task { @JoinColumn({ name: 'member_id' }) member: Member; + @Column({ type: 'varchar', length: 255, nullable: false, name: 'rank_value' }) + rankValue: string; + static of( project: Project, storyId: number, @@ -71,17 +76,18 @@ export class Task { actualTime: number, memberId: number, status: TaskStatus, + rankValue: string, ) { 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; + newTask.rankValue = rankValue; return newTask; } } diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 1697d2c9..64e56458 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -245,6 +245,7 @@ export class ProjectRepository { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ): Promise { const updateData: any = {}; @@ -266,6 +267,9 @@ export class ProjectRepository { if (assignedMemberId !== undefined) { updateData.assignedMemberId = assignedMemberId; } + if (rankValue !== undefined) { + updateData.rankValue = rankValue; + } const result = await this.taskRepository.update( { id, project: { id: project.id } }, diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index 9271aa97..c7c59c39 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -177,6 +177,7 @@ export class ProjectService { status: TaskStatus, assignedMemberId: number, storyId: number, + rankValue: string, ) { const story = await this.projectRepository.getStoryById(project, storyId); if (!story) throw new Error('Story id not found'); @@ -192,6 +193,7 @@ export class ProjectService { actualTime, assignedMemberId, status, + rankValue, ); return this.projectRepository.createTask(newTask); } @@ -210,6 +212,7 @@ export class ProjectService { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ): Promise { if (storyId !== undefined) { const story = await this.projectRepository.getStoryById(project, storyId); @@ -224,6 +227,7 @@ export class ProjectService { actualTime, status, assignedMemberId, + rankValue, ); } diff --git a/backend/src/project/ws-controller/ws-project-task.controller.ts b/backend/src/project/ws-controller/ws-project-task.controller.ts index 9563286c..36bff540 100644 --- a/backend/src/project/ws-controller/ws-project-task.controller.ts +++ b/backend/src/project/ws-controller/ws-project-task.controller.ts @@ -31,6 +31,7 @@ export class WsProjectTaskController { content.status, content.assignedMemberId, content.storyId, + content.rankValue, ); client.nsp .to('backlog') @@ -73,6 +74,7 @@ export class WsProjectTaskController { content.actualTime, content.status, content.assignedMemberId, + content.rankValue, ); if (isUpdated) { @@ -88,6 +90,7 @@ export class WsProjectTaskController { content.actualTime, content.status, content.assignedMemberId, + content.rankValue, ), ); } diff --git a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts index 3a52584d..7eaeb3ee 100644 --- a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts @@ -56,6 +56,7 @@ describe('WS epic', () => { status: taskStatus, assignedMemberId: taskAssignedMemberId, storyId, + rankValue: middleRankValue, }, }); 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 index e1d4bafa..139934b8 100644 --- a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts @@ -61,6 +61,7 @@ describe('WS task', () => { null, null, null, + middleRankValue, ); await testCreateTask( @@ -72,6 +73,7 @@ describe('WS task', () => { 2.1, 3.3, null, + LexoRank.parse(middleRankValue).genNext().toString(), ); await testCreateTask( @@ -83,6 +85,7 @@ describe('WS task', () => { null, null, null, + LexoRank.parse(middleRankValue).genNext().genNext().toString(), ); await testCreateTask( @@ -94,6 +97,11 @@ describe('WS task', () => { null, null, null, + LexoRank.parse(middleRankValue) + .genNext() + .genNext() + .genNext() + .toString(), ); socket1.close(); @@ -109,6 +117,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ) => { const requestData = { action: 'create', @@ -119,6 +128,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, }, }; socket1.emit('task', requestData); @@ -132,6 +142,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ), expectCreateTask( socket2, @@ -141,6 +152,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ), ]); }; @@ -153,6 +165,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ) => { return new Promise((resolve) => { socket.once('backlog', async (data) => { @@ -167,6 +180,7 @@ describe('WS task', () => { expect(content?.actualTime).toBe(actualTime); expect(content?.expectedTime).toBe(expectedTime); expect(content?.assignedMemberId).toBe(assignedMemberId); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); @@ -214,6 +228,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -282,6 +297,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -391,6 +407,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -422,6 +439,160 @@ describe('WS task', () => { socket.close(); }); + + it('should return updated task data when update rankValue within same story', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + 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, rankValue: middleRankValue }, + }; + 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, + rankValue: middleRankValue, + }, + }; + socket.emit('task', requestData); + const taskId = await getTaskId(socket); + + const newRankValue = LexoRank.parse(middleRankValue).genNext().toString(); + requestData = { + action: 'update', + content: { + id: taskId, + rankValue: newRankValue, + }, + }; + socket.emit('task', requestData); + + await 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(taskId); + expect(content?.rankValue).toBe(newRankValue); + resolve(); + }); + }); + + socket.close(); + }); + + it('should return updated task data when update rankValue within same story', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData); + const epicId = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue: middleRankValue }, + }; + socket.emit('story', requestData); + const storyId1 = await getStoryId(socket); + + requestData = { + action: 'create', + content: { + title, + point, + status, + epicId, + rankValue: LexoRank.parse(middleRankValue).genNext().toString(), + }, + }; + socket.emit('story', requestData); + const storyId2 = 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: storyId1, + expectedTime, + actualTime, + assignedMemberId, + rankValue: middleRankValue, + }, + }; + socket.emit('task', requestData); + const taskId = await getTaskId(socket); + + const newRankValue = LexoRank.middle().toString(); + requestData = { + action: 'update', + content: { + id: taskId, + stroyId: storyId2, + rankValue: newRankValue, + }, + }; + socket.emit('task', requestData); + + await 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(taskId); + expect(content?.rankValue).toBe(newRankValue); + resolve(); + }); + }); + + socket.close(); + }); }); });