From 391643233ea172de93cf91fc47e6da991755daeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Fri, 20 Sep 2024 16:08:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B4=88=EB=8C=80=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컨트롤러, 서비스, 레포지토리에서 초대링크 업데이트 메서드 구현 - 리더인 경우, 리더가 아닌경우 각각에 대한 초대링크 업데이트 E2E 테스트 추가 --- .../InviteLinkUpdateRequest.dto.ts | 9 ++ .../InviteLinkUpdateResponse.dto.ts | 23 +++++ backend/src/project/project.module.ts | 2 + backend/src/project/project.repository.ts | 11 +++ .../src/project/service/project.service.ts | 14 +++ backend/src/project/websocket.gateway.ts | 12 +++ .../ws-project-invite-link.controller.ts | 34 +++++++ .../ws-invite-link.e2e-spec.ts | 93 +++++++++++++++++++ 8 files changed, 198 insertions(+) create mode 100644 backend/src/project/dto/invite-link/InviteLinkUpdateRequest.dto.ts create mode 100644 backend/src/project/dto/invite-link/InviteLinkUpdateResponse.dto.ts create mode 100644 backend/src/project/ws-controller/ws-project-invite-link.controller.ts create mode 100644 backend/test/project/ws-landing-page/ws-invite-link.e2e-spec.ts diff --git a/backend/src/project/dto/invite-link/InviteLinkUpdateRequest.dto.ts b/backend/src/project/dto/invite-link/InviteLinkUpdateRequest.dto.ts new file mode 100644 index 0000000..fc5398d --- /dev/null +++ b/backend/src/project/dto/invite-link/InviteLinkUpdateRequest.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, Matches } from 'class-validator'; + +export class InviteLinkUpdateRequestDto { + @Matches(/^update$/) + action: string; + + @IsNotEmpty() + content: Record; +} diff --git a/backend/src/project/dto/invite-link/InviteLinkUpdateResponse.dto.ts b/backend/src/project/dto/invite-link/InviteLinkUpdateResponse.dto.ts new file mode 100644 index 0000000..f81ba67 --- /dev/null +++ b/backend/src/project/dto/invite-link/InviteLinkUpdateResponse.dto.ts @@ -0,0 +1,23 @@ +class inviteLinkDto { + inviteLinkId: string; + + static of(inviteLinkId: string): inviteLinkDto { + const dto = new inviteLinkDto(); + dto.inviteLinkId = inviteLinkId; + return dto; + } +} + +export class InviteLinkUpdateResponseDto { + domain: string; + action: string; + content: inviteLinkDto; + + static of(inviteLinkId: string): InviteLinkUpdateResponseDto { + const dto = new InviteLinkUpdateResponseDto(); + dto.domain = 'inviteLink'; + dto.action = 'update'; + dto.content = inviteLinkDto.of(inviteLinkId); + return dto; + } +} diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index 1a368f7..98a065b 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -23,6 +23,7 @@ import { WsProjectStoryController } from './ws-controller/ws-project-story.contr import { Task } from './entity/task.entity'; import { WsProjectTaskController } from './ws-controller/ws-project-task.controller'; import { WsProjectInfoController } from './ws-controller/ws-project-info.controller'; +import { WsProjectInviteLinkController } from './ws-controller/ws-project-invite-link.controller'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { WsProjectInfoController } from './ws-controller/ws-project-info.control WsProjectStoryController, WsProjectTaskController, WsProjectInfoController, + WsProjectInviteLinkController, ], }) export class ProjectModule {} diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 654cbec..d4a3d2d 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -76,6 +76,17 @@ export class ProjectRepository { }); } + async updateInviteLink( + projectId: number, + newInviteLinkId: string, + ): Promise { + const result = await this.projectRepository.update( + { id: projectId }, + { inviteLinkId: newInviteLinkId }, + ); + return !!result.affected; + } + getProject(projectId: number): Promise { return this.projectRepository.findOne({ where: { id: projectId } }); } diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index cf0246c..1bcf30e 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -10,6 +10,7 @@ import { Story, StoryStatus } from '../entity/story.entity'; import { Task, TaskStatus } from '../entity/task.entity'; import { LexoRank } from 'lexorank'; import { MemberRole } from '../enum/MemberRole.enum'; +import { v4 as uuidv4 } from 'uuid'; @Injectable() export class ProjectService { @@ -51,6 +52,19 @@ export class ProjectService { return project; } + async updateInviteLink(projectId: number, member: Member): Promise { + if (!(await this.isProjectLeader(projectId, member))) { + throw new Error('Member is not the project leader'); + } + const newInviteLinkId = await uuidv4(); + const isUpdated = await this.projectRepository.updateInviteLink( + projectId, + newInviteLinkId, + ); + if (!isUpdated) throw new Error('invite link not updated'); + return newInviteLinkId; + } + async getProject(projectId: number, member: Member): Promise { if (!(await this.isExistProject(projectId))) throw new Error('Project not found'); diff --git a/backend/src/project/websocket.gateway.ts b/backend/src/project/websocket.gateway.ts index 3d31752..e867b01 100644 --- a/backend/src/project/websocket.gateway.ts +++ b/backend/src/project/websocket.gateway.ts @@ -22,6 +22,7 @@ import { WsProjectEpicController } from './ws-controller/ws-project-epic.control import { WsProjectStoryController } from './ws-controller/ws-project-story.controller'; import { WsProjectTaskController } from './ws-controller/ws-project-task.controller'; import { WsProjectInfoController } from './ws-controller/ws-project-info.controller'; +import { WsProjectInviteLinkController } from './ws-controller/ws-project-invite-link.controller'; @WebSocketGateway({ namespace: /project-\d+/, @@ -42,6 +43,7 @@ export class ProjectWebsocketGateway private readonly wsProjectStoryController: WsProjectStoryController, private readonly wsProjectTaskController: WsProjectTaskController, private readonly wsProjectInfoController: WsProjectInfoController, + private readonly wsProjectInviteLinkController: WsProjectInviteLinkController, ) { this.namespaceMap = new Map(); } @@ -132,6 +134,16 @@ export class ProjectWebsocketGateway } } + @SubscribeMessage('inviteLink') + async handleInviteLinkEvent( + @ConnectedSocket() client: ClientSocket, + @MessageBody() data: any, + ) { + if (data.action === 'update') { + this.wsProjectInviteLinkController.updateInviteLink(client, data); + } + } + @SubscribeMessage('joinBacklog') async handleJoinBacklogEvent(@ConnectedSocket() client: ClientSocket) { this.wsProjectController.joinBacklogPage(client); diff --git a/backend/src/project/ws-controller/ws-project-invite-link.controller.ts b/backend/src/project/ws-controller/ws-project-invite-link.controller.ts new file mode 100644 index 0000000..97c9b71 --- /dev/null +++ b/backend/src/project/ws-controller/ws-project-invite-link.controller.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectService } from '../service/project.service'; +import { ClientSocket } from '../type/ClientSocket.type'; +import { validate } from 'class-validator'; +import { plainToClass } from 'class-transformer'; +import { getRecursiveErrorMsgList } from '../util/validation.util'; +import { InviteLinkUpdateRequestDto } from '../dto/invite-link/InviteLinkUpdateRequest.dto'; +import { InviteLinkUpdateResponseDto } from '../dto/invite-link/InviteLinkUpdateResponse.dto'; + +@Injectable() +export class WsProjectInviteLinkController { + constructor(private readonly projectService: ProjectService) {} + async updateInviteLink(client: ClientSocket, data: any) { + const errors = await validate( + plainToClass(InviteLinkUpdateRequestDto, data), + ); + if (errors.length > 0) { + const errorList = getRecursiveErrorMsgList(errors); + client.emit('error', { errorList }); + return; + } + try { + const newInviteLinkId = await this.projectService.updateInviteLink( + client.project.id, + client.member, + ); + client.emit('landing', InviteLinkUpdateResponseDto.of(newInviteLinkId)); + } catch (e) { + if (e.message === 'Member is not the project leader') { + client.disconnect(true); + } else throw e; + } + } +} diff --git a/backend/test/project/ws-landing-page/ws-invite-link.e2e-spec.ts b/backend/test/project/ws-landing-page/ws-invite-link.e2e-spec.ts new file mode 100644 index 0000000..fafe8aa --- /dev/null +++ b/backend/test/project/ws-landing-page/ws-invite-link.e2e-spec.ts @@ -0,0 +1,93 @@ +import { + app, + appInit, + connectServer, + createMember, + createProject, + getProjectLinkId, + joinProject, + listenAppAndSetPortEnv, + memberFixture, + memberFixture2, + projectPayload, +} from 'test/setup'; +import { + emitJoinLanding, + handleConnectErrorWithReject, + handleErrorWithReject, + initLanding, +} from '../ws-common'; + +describe('WS invite link', () => { + beforeEach(async () => { + await app.close(); + await appInit(); + await listenAppAndSetPortEnv(app); + }); + describe('update invite link', () => { + it('should return updated invite link data when project leader request', async () => { + let socket; + return new Promise(async (resolve, reject) => { + const accessToken = (await createMember(memberFixture, app)) + .accessToken; + const project = await createProject(accessToken, projectPayload, app); + socket = connectServer(project.id, accessToken); + handleConnectErrorWithReject(socket, reject); + handleErrorWithReject(socket, reject); + await emitJoinLanding(socket); + await initLanding(socket); + const data = { + action: 'update', + content: {}, + }; + socket.emit('inviteLink', data); + await expectUpdateInviteLink(socket); + resolve(); + }).finally(() => { + socket.close(); + }); + }); + const expectUpdateInviteLink = async (socket) => { + return await new Promise((res) => { + socket.once('landing', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('inviteLink'); + expect(action).toBe('update'); + expect(content.inviteLinkId).toBeDefined(); + expect(typeof content.inviteLinkId).toBe('string'); + res(); + }); + }); + }; + + it('should disconnect if the requester is not the project leader', async () => { + let socket; + return new Promise(async (resolve, reject) => { + const accessToken = (await createMember(memberFixture, app)) + .accessToken; + const project = await createProject(accessToken, projectPayload, app); + const projectLinkId = await getProjectLinkId(accessToken, project.id); + + const accessToken2 = (await createMember(memberFixture2, app)) + .accessToken; + await joinProject(accessToken2, projectLinkId); + + socket = connectServer(project.id, accessToken2); + handleConnectErrorWithReject(socket, reject); + handleErrorWithReject(socket, reject); + await emitJoinLanding(socket); + await initLanding(socket); + const data = { + action: 'update', + content: {}, + }; + socket.emit('inviteLink', data); + socket.on('disconnect', () => { + resolve(); + }); + }).finally(() => { + socket.close(); + }); + }); + }); +});