diff --git a/apps/server/.eslintrc.js b/apps/server/.eslintrc.js index 3530697..89dc867 100644 --- a/apps/server/.eslintrc.js +++ b/apps/server/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { 'class-methods-use-this': 'off', // nest not use this in class 'import/no-extraneous-dependencies': 'off', 'import/extensions': 'off', + 'no-await-in-loop': 'off', 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index cfb67fc..9c1ae1c 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -44,6 +44,7 @@ import { ProjectModule } from '@/project/project.module'; provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, + transform: true, }), }, ], diff --git a/apps/server/src/project/controller/project.controller.ts b/apps/server/src/project/controller/project.controller.ts index d8f7d74..66587f9 100644 --- a/apps/server/src/project/controller/project.controller.ts +++ b/apps/server/src/project/controller/project.controller.ts @@ -100,13 +100,13 @@ export class ProjectController { let response; switch (event) { case EventType.CREATE_TASK: - // response = await this.taskService.create(user.id, taskEvent); + response = await this.taskService.create(user.id, projectId, taskEvent); break; case EventType.DELETE_TASK: - // response = await this.taskService.delete(user.id, taskEvent); + response = await this.taskService.delete(user.id, projectId, taskEvent); break; case EventType.UPDATE_POSITION: - // response = await this.taskService.move(user.id, taskEvent); + response = await this.taskService.move(user.id, projectId, taskEvent); break; case EventType.INSERT_TITLE: case EventType.DELETE_TITLE: @@ -115,6 +115,6 @@ export class ProjectController { default: throw new BadRequestException('올바르지 않은 이벤트 타입입니다.'); } - return response; + return new BaseResponse(200, '이벤트 처리 완료했습니다.', response); } } diff --git a/apps/server/src/task/controller/snapshot.controller.ts b/apps/server/src/task/controller/event.controller.ts similarity index 69% rename from apps/server/src/task/controller/snapshot.controller.ts rename to apps/server/src/task/controller/event.controller.ts index fabdd2a..553b301 100644 --- a/apps/server/src/task/controller/snapshot.controller.ts +++ b/apps/server/src/task/controller/event.controller.ts @@ -1,15 +1,15 @@ import { Controller, Get, Query, Req, Res, UseGuards } from '@nestjs/common'; import { Request, Response } from 'express'; import { AccessTokenGuard } from '@/account/guard/accessToken.guard'; -import { TaskService } from '@/task/service/task.service'; import { AuthUser } from '@/account/decorator/authUser.decorator'; import { Account } from '@/account/entity/account.entity'; import { CustomResponse } from '@/task/domain/custom-response.interface'; +import { BroadcastService } from '@/task/service/broadcast.service'; @UseGuards(AccessTokenGuard) -@Controller('snapshot') -export class SnapshotController { - constructor(private taskService: TaskService) {} +@Controller('event') +export class EventController { + constructor(private broadcastService: BroadcastService) {} @Get() polling( @@ -21,10 +21,10 @@ export class SnapshotController { const customResponse = res as CustomResponse; customResponse.userId = user.id; - this.taskService.addConnection(projectId, customResponse); + this.broadcastService.addConnection(projectId, customResponse); req.socket.on('close', () => { - this.taskService.removeConnection(projectId, customResponse); + this.broadcastService.removeConnection(projectId, customResponse); }); } } diff --git a/apps/server/src/task/controller/task.controller.ts b/apps/server/src/task/controller/task.controller.ts index cdbe9c4..c6ab3d7 100644 --- a/apps/server/src/task/controller/task.controller.ts +++ b/apps/server/src/task/controller/task.controller.ts @@ -1,18 +1,5 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import { TaskService } from '@/task/service/task.service'; -import { UpdateTaskRequest } from '@/task/dto/update-task-request.dto'; -import { MoveTaskRequest } from '@/task/dto/move-task-request.dto'; -import { CreateTaskRequest } from '@/task/dto/create-task-request.dto'; import { AuthUser } from '@/account/decorator/authUser.decorator'; import { Account } from '@/account/entity/account.entity'; import { AccessTokenGuard } from '@/account/guard/accessToken.guard'; @@ -23,48 +10,21 @@ import { BaseResponse } from '@/common/BaseResponse'; export class TaskController { constructor(private taskService: TaskService) {} - @Post() - async create(@AuthUser() user: Account, @Body() body: CreateTaskRequest) { - return new BaseResponse( - 200, - '태스크가 정상적으로 생성되었습니다.', - await this.taskService.create(body) - ); - } - @Get() - async getAll(@Query('projectId') projectId: number) { + async getAll(@AuthUser() user: Account, @Query('projectId') projectId: number) { return new BaseResponse( 200, '태스크 목록이 정상적으로 조회되었습니다.', - await this.taskService.getAll(projectId) - ); - } - - @Patch(':id/position') - async move(@Param('id') id: number, @Body() moveTaskRequest: MoveTaskRequest) { - return new BaseResponse( - 200, - '태스크가 정상적으로 이동되었습니다.', - await this.taskService.move(id, moveTaskRequest) + await this.taskService.getAll(user.id, projectId) ); } @Get(':id') - async get(@Param('id') id: number) { + async get(@AuthUser() user: Account, @Param('id') id: number) { return new BaseResponse( 200, '태스크가 정상적으로 조회되었습니다.', - await this.taskService.get(id) - ); - } - - @Delete(':id') - async delete(@Param('id') id: number) { - return new BaseResponse( - 200, - '태스크가 정상적으로 삭제되었습니다.', - await this.taskService.delete(id) + await this.taskService.get(user.id, id) ); } } diff --git a/apps/server/src/task/domain/snapshot.ts b/apps/server/src/task/domain/snapshot.ts deleted file mode 100644 index 9b6a978..0000000 --- a/apps/server/src/task/domain/snapshot.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Task } from './task.entity'; - -export class Snapshot { - constructor(project) { - this.version = new Date(); - this.project = project; - } - - version: Date; - - project: { - id: number; - name: string; - tasks: { - id: number; - title: string; - description: string; - sectionName: string; - position: string; - }[]; - }[]; - - update(prevSectionId: number, task: Task) { - const section = this.project.find((s) => s.id === prevSectionId); - if (!section) { - return; - } - - const target = section.tasks.find((t) => t.id === task.id); - if (!target) { - return; - } - - target.title = task.title; - target.description = task.description; - target.sectionName = task.section.name; - target.position = task.position; - - this.project.find((s) => s.id === task.section.id).tasks.push(target); - section.tasks = section.tasks.filter((t) => t.id !== task.id); - } -} diff --git a/apps/server/src/task/domain/update-information.type.ts b/apps/server/src/task/domain/update-information.type.ts index 5efa744..bbb643c 100644 --- a/apps/server/src/task/domain/update-information.type.ts +++ b/apps/server/src/task/domain/update-information.type.ts @@ -1,4 +1,4 @@ -type UpdateInformation = { +export type UpdateInformation = { position: number; content: string; diff --git a/apps/server/src/task/dto/create-task-request.dto.ts b/apps/server/src/task/dto/create-task-request.dto.ts deleted file mode 100644 index 548508b..0000000 --- a/apps/server/src/task/dto/create-task-request.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; - -export class CreateTaskRequest { - @IsNumber() - @IsNotEmpty() - projectId: number; - - @IsString() - @IsNotEmpty() - lastTaskPosition: string; -} diff --git a/apps/server/src/task/dto/move-task-request.dto.ts b/apps/server/src/task/dto/move-task-request.dto.ts deleted file mode 100644 index fabc534..0000000 --- a/apps/server/src/task/dto/move-task-request.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; - -export class MoveTaskRequest { - @IsNumber() - @IsNotEmpty() - sectionId: number; - - @IsString() - @IsNotEmpty() - beforePosition: string; - - @IsString() - @IsNotEmpty() - afterPosition: string; -} diff --git a/apps/server/src/task/dto/task-event-response.dto.ts b/apps/server/src/task/dto/task-event-response.dto.ts new file mode 100644 index 0000000..8f65b7d --- /dev/null +++ b/apps/server/src/task/dto/task-event-response.dto.ts @@ -0,0 +1,13 @@ +import { EventType } from '@/task/domain/eventType.enum'; +import { TaskEvent } from '@/task/dto/task-event.dto'; + +export class TaskEventResponse { + taskId: number; + + event: EventType; + + constructor(taskEvent: TaskEvent) { + this.taskId = taskEvent.taskId; + this.event = taskEvent.event; + } +} diff --git a/apps/server/src/task/dto/task-event.dto.ts b/apps/server/src/task/dto/task-event.dto.ts index 20752d3..bc0829c 100644 --- a/apps/server/src/task/dto/task-event.dto.ts +++ b/apps/server/src/task/dto/task-event.dto.ts @@ -1,5 +1,6 @@ import { IsNumber, IsNotEmpty, IsString, IsEnum } from 'class-validator'; -import { EventType } from '../domain/eventType.enum'; +import { EventType } from '@/task/domain/eventType.enum'; +import { UpdateInformation } from '@/task/domain/update-information.type'; export class TaskEvent { @IsNumber() diff --git a/apps/server/src/task/dto/update-task-request.dto.ts b/apps/server/src/task/dto/update-task-request.dto.ts deleted file mode 100644 index 76862fe..0000000 --- a/apps/server/src/task/dto/update-task-request.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsNumber, IsOptional, IsString } from 'class-validator'; - -export class UpdateTaskRequest { - @IsString() - @IsOptional() - title: string; - - @IsString() - @IsOptional() - description: string; - - @IsNumber() - @IsOptional() - sectionId: number; -} diff --git a/apps/server/src/task/service/broadcast.service.ts b/apps/server/src/task/service/broadcast.service.ts new file mode 100644 index 0000000..f1a5091 --- /dev/null +++ b/apps/server/src/task/service/broadcast.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { CustomResponse } from '@/task/domain/custom-response.interface'; +import { BaseResponse } from '@/common/BaseResponse'; + +@Injectable() +export class BroadcastService { + private connections: Map = new Map(); + + addConnection(projectId: number, res: CustomResponse) { + if (!this.connections.has(projectId)) { + this.connections.set(projectId, [res]); + } else { + this.connections.get(projectId).push(res); + } + } + + removeConnection(projectId: number, res: CustomResponse) { + const filteredConnections = this.connections + .get(projectId) + .filter((r) => r.userId !== res.userId); + + this.connections.set(projectId, filteredConnections); + } + + sendConnection(userId: number, projectId: number, event: any) { + const connections = this.connections.get(projectId); + if (!connections) { + return; + } + + connections.forEach((res) => { + if (res.userId !== userId) { + res.json(new BaseResponse(200, '이벤트가 발생했습니다.', event)); + } + }); + this.connections.set( + projectId, + connections.filter((res) => res.userId === userId) + ); + } +} diff --git a/apps/server/src/task/service/task.service.ts b/apps/server/src/task/service/task.service.ts index 8a3685a..34672e3 100644 --- a/apps/server/src/task/service/task.service.ts +++ b/apps/server/src/task/service/task.service.ts @@ -1,29 +1,33 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { LexoRank } from 'lexorank'; import { EventEmitter2 } from '@nestjs/event-emitter'; import * as ShareDB from 'sharedb'; import { Task } from '@/task/domain/task.entity'; import { Section } from '@/task/domain/section.entity'; -import { MoveTaskRequest } from '@/task/dto/move-task-request.dto'; import { MoveTaskResponse } from '@/task/dto/move-task-response.dto'; import { TaskResponse } from '@/task/dto/task-response.dto'; import { DeleteTaskResponse } from '@/task/dto/delete-task-response.dto'; import { CreateTaskResponse } from '@/task/dto/create-task-response.dto'; import { Project } from '@/project/entity/project.entity'; -import { CreateTaskRequest } from '@/task/dto/create-task-request.dto'; -import { CustomResponse } from '@/task/domain/custom-response.interface'; -import { TaskEvent } from '../dto/task-event.dto'; -import { EventType } from '../domain/eventType.enum'; +import { TaskEvent } from '@/task/dto/task-event.dto'; +import { EventType } from '@/task/domain/eventType.enum'; +import { Contributor } from '@/project/entity/contributor.entity'; +import { BroadcastService } from '@/task/service/broadcast.service'; +import { ContributorStatus } from '@/project/enum/contributor-status.enum'; +import { TaskEventResponse } from '@/task/dto/task-event-response.dto'; +import { UpdateInformation } from '@/task/domain/update-information.type'; const { json0 } = ShareDB.types; @Injectable() export class TaskService { - private operations: Map = new Map(); - - private connections: Map = new Map(); + private operations: Map = new Map(); constructor( @InjectRepository(Task) @@ -32,57 +36,48 @@ export class TaskService { private sectionRepository: Repository
, @InjectRepository(Project) private projectRepository: Repository, + @InjectRepository(Contributor) + private contributorRepository: Repository, + private broadcastService: BroadcastService, private eventEmitter: EventEmitter2 ) { + this.eventEmitter.on('broadcast', (userId: number, projectId: number, event: TaskEvent) => { + this.broadcastService.sendConnection(userId, projectId, event); + }); this.eventEmitter.on('operationAdded', async (userId: number, projectId: number) => { await this.dequeue(userId, projectId); }); } - addConnection(projectId: number, res: CustomResponse) { - if (!this.connections.has(projectId.toString())) { - this.connections.set(projectId.toString(), [res]); - } - this.connections.get(projectId.toString()).push(res); - } - - removeConnection(projectId: number, res: CustomResponse) { - const fillterdConnections = this.connections - .get(projectId.toString()) - .filter((r) => r.userId !== res.userId); - - this.connections.set(projectId.toString(), fillterdConnections); - } - - sendConnection(projectId: number, userId: number) { - const connections = this.connections.get(projectId.toString()); - } - async enqueue(userId: number, projectId: number, taskEvent: TaskEvent) { - const key = projectId.toString(); + const key = projectId; + const contributor = await this.contributorRepository.findOneBy({ projectId, userId }); + if (!contributor || contributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } const currentEvents = this.operations.get(key) || []; this.operations.set(key, [...currentEvents, taskEvent]); this.eventEmitter.emit('operationAdded', userId, projectId); } private async dequeue(userId: number, projectId: number) { - const key = projectId.toString(); - const changes = this.operations.get(key); - if (!changes) { + const key = projectId; + const taskEvents = this.operations.get(key); + if (!taskEvents) { return; } let lastOp = []; - while (changes.length) { - const change = changes.shift(); - const existing = await this.findTaskOrThrow(change.taskId); - const result = this.merge(change, existing); + while (taskEvents.length) { + const taskEvent = taskEvents.shift(); + const existing = await this.findTaskOrThrow(taskEvent.taskId); + const result = this.merge(taskEvent, existing); const transformedOp = lastOp.length ? json0.type.transform(result, lastOp, 'right') : result; this.taskRepository.save(result); - this.sendConnection(projectId, userId); + this.eventEmitter.emit('broadcast', userId, projectId, new TaskEventResponse(taskEvent)); lastOp = json0.type.compose(lastOp, transformedOp); } @@ -92,19 +87,15 @@ export class TaskService { const updateTitle = change.title; const existingTitle = existing.title; const { event } = change; - const op = this.convertToShareDbOp(event, updateTitle, existingTitle); + const op = this.convertToShareDbOp(event, updateTitle); const newTitle = json0.type.apply(existingTitle, op); return { ...existing, title: newTitle }; } - private convertToShareDbOp( - event: EventType, - updateTitle: UpdateInformation, - existingTitle: string - ) { - const { content, position, length } = updateTitle; + private convertToShareDbOp(event: EventType, updateTitle: UpdateInformation) { + const { content, position } = updateTitle; switch (event) { case EventType.INSERT_TITLE: @@ -121,25 +112,32 @@ export class TaskService { } } - async create(createTaskRequest: CreateTaskRequest) { - const project = await this.projectRepository.findOneBy({ id: createTaskRequest.projectId }); - if (!project) { + async create(userId: number, projectId: number, taskEvent: TaskEvent) { + const contributor = await this.contributorRepository.findOneBy({ projectId, userId }); + if (!contributor || contributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } + if (!taskEvent.sectionId) { + throw new BadRequestException('Required section id'); + } + const section = await this.findSectionOrThrow(taskEvent.sectionId); + if (section.project.id !== projectId) { throw new NotFoundException('Project not found'); } - - const sections = await this.sectionRepository.find({ where: { project } }); - const position: string = createTaskRequest.lastTaskPosition - ? LexoRank.parse(createTaskRequest.lastTaskPosition).genNext().toString() - : LexoRank.min().toString(); - const task = await this.taskRepository.save({ - position, - section: sections[0], + position: taskEvent.position, + section, }); + + this.eventEmitter.emit('broadcast', userId, projectId, new TaskEventResponse(taskEvent)); return new CreateTaskResponse(task); } - async getAll(projectId: number) { + async getAll(userId: number, projectId: number) { + const contributor = await this.contributorRepository.findOneBy({ projectId, userId }); + if (!contributor || contributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } const sections = await this.sectionRepository.find({ where: { project: { id: projectId } }, order: { id: 'ASC' }, @@ -165,28 +163,55 @@ export class TaskService { return taskBySection; } - async move(id: number, moveTaskRequest: MoveTaskRequest) { - const task = await this.findTaskOrThrow(id); - - const section = await this.findSectionOrThrow(moveTaskRequest.sectionId); + async move(userId: number, projectId: number, taskEvent: TaskEvent) { + const contributor = await this.contributorRepository.findOneBy({ projectId, userId }); + if (!contributor || contributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } + if (!taskEvent.taskId || !taskEvent.sectionId || !taskEvent.position) { + throw new BadRequestException('Required section id'); + } + const task = await this.findTaskOrThrow(taskEvent.taskId); + const section = await this.findSectionOrThrow(taskEvent.sectionId); + if (task.section.project.id !== projectId || section.project.id !== projectId) { + throw new NotFoundException('Project not found'); + } + task.position = taskEvent.position; task.section = section; - - const beforePosition = LexoRank.parse(moveTaskRequest.beforePosition); - const afterPosition = LexoRank.parse(moveTaskRequest.afterPosition); - task.position = beforePosition.between(afterPosition).toString(); - await this.taskRepository.save(task); + + this.eventEmitter.emit('broadcast', userId, projectId, new TaskEventResponse(taskEvent)); return new MoveTaskResponse(task); } - async get(id: number) { - const task = await this.findTaskOrThrow(id); + async get(userId: number, taskId: number) { + const task = await this.findTaskOrThrow(taskId); + const contributor = await this.contributorRepository.findOneBy({ + userId, + projectId: task.section.project.id, + }); + if (!contributor || contributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } return new TaskResponse(task); } - async delete(id: number) { - await this.taskRepository.delete(id); - return new DeleteTaskResponse(id); + async delete(userId: number, projectId: number, taskEvent: TaskEvent) { + const contributor = await this.contributorRepository.findOneBy({ projectId, userId }); + if (!contributor || contributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } + if (!taskEvent.taskId) { + throw new BadRequestException('Required section id'); + } + const task = await this.findTaskOrThrow(taskEvent.taskId); + if (!task || task.section.project.id !== projectId) { + throw new NotFoundException('Task not found'); + } + await this.taskRepository.delete(taskEvent.taskId); + + this.eventEmitter.emit('broadcast', userId, projectId, new TaskEventResponse(taskEvent)); + return new DeleteTaskResponse(taskEvent.taskId); } private async findTaskOrThrow(id: number) { diff --git a/apps/server/src/task/task.module.ts b/apps/server/src/task/task.module.ts index 4e0c5df..90b5445 100644 --- a/apps/server/src/task/task.module.ts +++ b/apps/server/src/task/task.module.ts @@ -5,12 +5,14 @@ import { TaskService } from '@/task/service/task.service'; import { Task } from '@/task/domain/task.entity'; import { Section } from '@/task/domain/section.entity'; import { Project } from '@/project/entity/project.entity'; -import { SnapshotController } from '@/task/controller/snapshot.controller'; +import { EventController } from '@/task/controller/event.controller'; +import { BroadcastService } from '@/task/service/broadcast.service'; +import { Contributor } from '@/project/entity/contributor.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Task, Section, Project])], - controllers: [TaskController, SnapshotController], - providers: [TaskService], + imports: [TypeOrmModule.forFeature([Task, Section, Project, Contributor])], + controllers: [TaskController, EventController], + providers: [TaskService, BroadcastService], exports: [TaskService], }) export class TaskModule {}