diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3ea54222..ca8d4501 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,9 +4,10 @@ import { AppService } from './app.service'; import { WorkspaceModule } from './workspace/workspace.module'; import { WidgetModule } from './widget/widget.module'; import { CursorModule } from './cursor/cursor.module'; +import { MarkdownModule } from './markdown/markdown.module'; @Module({ - imports: [WorkspaceModule, WidgetModule, CursorModule], + imports: [WorkspaceModule, WidgetModule, CursorModule, MarkdownModule], controllers: [AppController], providers: [AppService], }) diff --git a/backend/src/markdown/__test__/markdown.controller.spec.ts b/backend/src/markdown/__test__/markdown.controller.spec.ts new file mode 100644 index 00000000..c6cf190c --- /dev/null +++ b/backend/src/markdown/__test__/markdown.controller.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MarkdownController } from '../markdown.controller'; +import { MarkdownService } from '../markdown.service'; + +describe('MarkdownController', () => { + let controller: MarkdownController; + const markdownServiceMock = { + generateMarkdown: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MarkdownController], + providers: [ + { + provide: MarkdownService, + useValue: markdownServiceMock, + }, + ], + }).compile(); + + controller = module.get(MarkdownController); + }); + + it('컨트롤러가 정의되어 있어야 한다', () => { + expect(controller).toBeDefined(); + }); + + it('generateMarkdown 결과를 반환한다', async () => { + markdownServiceMock.generateMarkdown.mockResolvedValue('# md'); + + const result = await controller.find('w1'); + + expect(markdownServiceMock.generateMarkdown).toHaveBeenCalledWith('w1'); + expect(result).toEqual({ markdown: '# md' }); + }); +}); diff --git a/backend/src/markdown/__test__/markdown.service.spec.ts b/backend/src/markdown/__test__/markdown.service.spec.ts new file mode 100644 index 00000000..2e8a434d --- /dev/null +++ b/backend/src/markdown/__test__/markdown.service.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { MarkdownService } from '../markdown.service'; +import { IWidgetService, WIDGET_SERVICE } from '../../widget/widget.interface'; +import { WidgetType } from '../../widget/dto/widget-content.dto'; + +type MockWidgetService = { + [P in keyof IWidgetService]: jest.Mock; +}; + +describe('MarkdownService', () => { + let service: MarkdownService; + let widgetServiceMock: MockWidgetService; + const workspaceId = 'w1'; + + beforeEach(async () => { + widgetServiceMock = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + findOneByWidgetType: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + updateLayout: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MarkdownService, + { + provide: WIDGET_SERVICE, + useValue: widgetServiceMock, + }, + ], + }).compile(); + + service = module.get(MarkdownService); + jest.useFakeTimers().setSystemTime(new Date('2024-01-01T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('모든 위젯이 없으면 헤더, 푸터, 안내 문구를 반환한다.', async () => { + widgetServiceMock.findOneByWidgetType.mockResolvedValue(null); + + const markdown = await service.generateMarkdown(workspaceId); + + expect(markdown).toContain('# 🚀 Project Team Align Report'); + expect(markdown).toContain( + '아직 적은 내용이 없는 것 같습니다! 위젯에 내용을 추가해보세요! 🚀', + ); + expect(markdown).toContain('*Generated by TeamConfig*'); + }); + + it('각 위젯을 마크다운 섹션으로 변환한다', async () => { + widgetServiceMock.findOneByWidgetType + .mockImplementationOnce(() => ({ + data: { + content: { + widgetType: WidgetType.GROUND_RULE, + rules: ['Folder', 'Commit'], + }, + }, + })) + .mockImplementationOnce(() => ({ + data: { + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: ['React', 'NestJS'], + }, + }, + })) + .mockImplementationOnce(() => ({ + data: { + content: { + widgetType: WidgetType.POST_IT, + text: '기타 메모', + }, + }, + })); + + const markdown = await service.generateMarkdown(workspaceId); + const lines = markdown.split('\n'); + + // Ground Rule 섹션 + expect(lines).toContain('## 1. 📋 Ground Rule'); + expect(lines).toContain('| Folder | - |'); + expect(lines).toContain('| Commit | - |'); + + // Tech Stack 섹션 + expect(lines).toContain('## 2. 🛠 Tech Stack Selection'); + expect(lines).toContain('| React | vLatest |'); + expect(lines).toContain('| NestJS | vLatest |'); + + // Else 섹션 (Post-it) + expect(lines).toContain('## 3. Else'); + expect(lines).toContain('기타 메모'); + }); +}); diff --git a/backend/src/markdown/dto/get-markdown.dto.ts b/backend/src/markdown/dto/get-markdown.dto.ts new file mode 100644 index 00000000..a17be47a --- /dev/null +++ b/backend/src/markdown/dto/get-markdown.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class GetMarkdownDto { + @ApiProperty({ description: 'Markdown 내용', example: '# Hello, World!' }) + @IsString() + readonly markdown: string; +} diff --git a/backend/src/markdown/markdown.controller.ts b/backend/src/markdown/markdown.controller.ts new file mode 100644 index 00000000..188d24b1 --- /dev/null +++ b/backend/src/markdown/markdown.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { MarkdownService } from './markdown.service'; +import { GetMarkdownDto } from './dto/get-markdown.dto'; + +@Controller('markdown') +export class MarkdownController { + constructor(private readonly markdownService: MarkdownService) {} + + @Get() + @ApiQuery({ + name: 'workspaceId', + required: true, + description: '워크스페이스 ID', + example: 'w1', + }) + @ApiResponse({ + status: 200, + description: '생성된 마크다운 문서', + type: GetMarkdownDto, + }) + async find( + @Query('workspaceId') workspaceId: string, + ): Promise { + const markdown = await this.markdownService.generateMarkdown(workspaceId); + return { markdown }; + } +} diff --git a/backend/src/markdown/markdown.module.ts b/backend/src/markdown/markdown.module.ts new file mode 100644 index 00000000..63bdee15 --- /dev/null +++ b/backend/src/markdown/markdown.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MarkdownController } from './markdown.controller'; +import { MarkdownService } from './markdown.service'; +import { WidgetModule } from '../widget/widget.module'; + +@Module({ + imports: [WidgetModule], + controllers: [MarkdownController], + providers: [MarkdownService], +}) +export class MarkdownModule {} diff --git a/backend/src/markdown/markdown.service.ts b/backend/src/markdown/markdown.service.ts new file mode 100644 index 00000000..a96e8abc --- /dev/null +++ b/backend/src/markdown/markdown.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Inject } from '@nestjs/common'; +import type { IWidgetService } from '../widget/widget.interface'; +import { WIDGET_SERVICE } from '../widget/widget.interface'; +import { + WidgetType, + GroundRuleContentDto, + TechStackContentDto, +} from '../widget/dto/widget-content.dto'; +import { CreateWidgetDto } from '../widget/dto/create-widget.dto'; + +@Injectable() +export class MarkdownService { + constructor( + @Inject(WIDGET_SERVICE) private readonly widgetService: IWidgetService, + ) {} + + private buildGroundRuleSection(widget: CreateWidgetDto | null): string[] { + if (!widget) return []; + + const lines: string[] = []; + const content = widget.data.content as GroundRuleContentDto; + + lines.push('## 1. 📋 Ground Rule'); + lines.push('| Ground Rule| Value| '); + lines.push('| :--- | :--- | '); + + if (content.rules && content.rules.length > 0) { + content.rules.forEach((rule) => { + lines.push(`| ${rule} | - |`); + }); + } + + lines.push(''); + return lines; + } + + private buildTechStackSection(widget: CreateWidgetDto | null): string[] { + if (!widget) return []; + + const lines: string[] = []; + const content = widget.data.content as TechStackContentDto; + + lines.push('## 2. 🛠 Tech Stack Selection'); + lines.push('| Tech Name | Version |'); + lines.push('| :--- | :--- |'); + + if (content.selectedItems && content.selectedItems.length > 0) { + content.selectedItems.forEach((item) => { + lines.push(`| ${item} | vLatest |`); + }); + } + + lines.push(''); + return lines; + } + + private buildElseSection(widget: CreateWidgetDto | null): string[] { + if (!widget) return []; + + const lines: string[] = []; + + lines.push('## 3. Else'); + lines.push('---'); + + if (widget) { + const content = widget.data.content as { text?: string }; + if (content.text) { + lines.push(content.text); + } + } + + lines.push(''); + return lines; + } + + async generateMarkdown(workspaceId: string): Promise { + const now = new Date(); + const formattedDate = now.toLocaleString('ko-KR', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + + const markdownParts: string[] = []; + + markdownParts.push('# 🚀 Project Team Align Report'); + markdownParts.push(`> Created at: ${formattedDate}`); + markdownParts.push(''); + + const groundRuleWidget = await this.widgetService.findOneByWidgetType( + workspaceId, + WidgetType.GROUND_RULE, + ); + markdownParts.push(...this.buildGroundRuleSection(groundRuleWidget)); + + const techStackWidget = await this.widgetService.findOneByWidgetType( + workspaceId, + WidgetType.TECH_STACK, + ); + markdownParts.push(...this.buildTechStackSection(techStackWidget)); + + const postItWidget = await this.widgetService.findOneByWidgetType( + workspaceId, + WidgetType.POST_IT, + ); + + markdownParts.push(...this.buildElseSection(postItWidget)); + + if (!groundRuleWidget && !techStackWidget && !postItWidget) { + markdownParts.push( + '아직 적은 내용이 없는 것 같습니다! 위젯에 내용을 추가해보세요! 🚀', + ); + markdownParts.push(''); + } + + markdownParts.push('*Generated by TeamConfig*'); + + return markdownParts.join('\n'); + } +} diff --git a/backend/src/widget/__test__/widget.gateway.spec.ts b/backend/src/widget/__test__/widget.gateway.spec.ts index bfee8a54..4e62424c 100644 --- a/backend/src/widget/__test__/widget.gateway.spec.ts +++ b/backend/src/widget/__test__/widget.gateway.spec.ts @@ -27,8 +27,6 @@ describe('WidgetGateway', () => { const roomId = 'room-1'; const socketId = 's1'; - const userId = 'u1'; - const otherUserId = 'u2'; beforeEach(async () => { serviceMock = { @@ -38,17 +36,10 @@ describe('WidgetGateway', () => { remove: jest.fn(), findAll: jest.fn(), findOne: jest.fn(), - lock: jest.fn(), - unlock: jest.fn(), - getLockOwner: jest.fn(), - unlockAllByUser: jest.fn(), }; workspaceServiceMock = { - getUserBySocketId: jest.fn().mockReturnValue({ - roomId, - user: { id: userId }, - }), + getUserBySocketId: jest.fn(), }; serverToEmitMock = jest.fn(); @@ -82,13 +73,13 @@ describe('WidgetGateway', () => { gateway.server = serverMock as unknown as Server; }); - it('게이트웨이가 정상적으로 정의되어야 한다', () => { + it('게이트웨이가 정의되어 있어야 한다', () => { expect(gateway).toBeDefined(); }); - describe('widget:create (위젯 생성)', () => { - it('새로운 위젯을 생성하고 해당 워크스페이스에 브로드캐스트해야 한다', async () => { - // given: 위젯 생성을 위한 유효한 데이터가 준비된 상태 + describe('create (위젯 생성 이벤트)', () => { + it('위젯을 생성하고 특정 룸에 "widget:created" 이벤트를 전파해야 한다', async () => { + // given: 클라이언트로부터 유효한 위젯 생성 데이터가 전달되었을 때 const createDto: CreateWidgetDto = { widgetId: 'w-1', type: WidgetType.TECH_STACK, @@ -104,12 +95,14 @@ describe('WidgetGateway', () => { } as TechStackContentDto, }, }; + + workspaceServiceMock.getUserBySocketId.mockReturnValue({ roomId }); serviceMock.create.mockResolvedValue(createDto); - // when: 클라이언트가 `widget:create` 이벤트를 전송했을 때 + // when: 클라이언트가 위젯 생성 이벤트를 요청하면 await gateway.create(createDto, clientMock as unknown as Socket); - // then: 위젯 서비스의 create가 호출되고, 같은 방의 모든 클라이언트에게 `widget:created` 이벤트가 전송된다 + // then: 서비스 로직을 통해 위젯을 생성하고, 해당 룸의 모든 클라이언트에게 생성된 위젯 정보를 브로드캐스트해야 한다 expect(serviceMock.create).toHaveBeenCalledWith(roomId, createDto); expect(serverMock.to).toHaveBeenCalledWith(roomId); expect(serverToEmitMock).toHaveBeenCalledWith( @@ -119,221 +112,93 @@ describe('WidgetGateway', () => { }); }); - describe('widget:lock (위젯 잠금)', () => { - const lockDto = { widgetId: 'w-1' }; - - it('위젯 잠금에 성공하면 다른 사용자에게 잠금 사실을 알려야 한다', async () => { - // given: 위젯 서비스가 잠금 성공(true)을 반환하도록 설정 - serviceMock.lock.mockResolvedValue(true); - - // when: 클라이언트가 `widget:lock` 이벤트를 전송했을 때 - await gateway.lock(lockDto, clientMock as unknown as Socket); - - // then: 위젯 서비스의 lock이 호출되고, 자신을 제외한 다른 클라이언트에게 `widget:locked` 이벤트가 전송된다 - expect(serviceMock.lock).toHaveBeenCalledWith(roomId, 'w-1', userId); - expect(clientToEmitMock).toHaveBeenCalledWith('widget:locked', { - widgetId: 'w-1', - userId, - }); - expect(clientMock.emit).not.toHaveBeenCalledWith( - 'error', - expect.any(String), - ); - }); - - it('위젯 잠금에 실패하면 요청한 사용자에게 에러를 전송해야 한다', async () => { - // given: 위젯 서비스가 잠금 실패(false)를 반환하도록 설정 - serviceMock.lock.mockResolvedValue(false); - - // when: 클라이언트가 `widget:lock` 이벤트를 전송했을 때 - await gateway.lock(lockDto, clientMock as unknown as Socket); - - // then: 위젯 서비스의 lock이 호출되고, 요청한 클라이언트에게만 'error' 이벤트가 전송된다 - expect(serviceMock.lock).toHaveBeenCalledWith(roomId, 'w-1', userId); - expect(clientMock.emit).toHaveBeenCalledWith('error', expect.any(String)); - }); - }); - - describe('widget:unlock (위젯 잠금 해제)', () => { - const unlockDto = { widgetId: 'w-1' }; - - it('위젯 잠금 해제에 성공하면 다른 사용자에게 잠금 해제 사실을 알려야 한다', async () => { - // given: 위젯 서비스가 잠금 해제 성공(true)을 반환하도록 설정 - serviceMock.unlock.mockResolvedValue(true); - - // when: 클라이언트가 `widget:unlock` 이벤트를 전송했을 때 - await gateway.unlock(unlockDto, clientMock as unknown as Socket); - - // then: 위젯 서비스의 unlock이 호출되고, 자신을 제외한 다른 클라이언트에게 `widget:unlocked` 이벤트가 전송된다 - expect(serviceMock.unlock).toHaveBeenCalledWith(roomId, 'w-1', userId); - expect(clientToEmitMock).toHaveBeenCalledWith('widget:unlocked', { + describe('move (위젯 이동/레이아웃 변경 이벤트)', () => { + it('위젯 좌표를 수정하고 특정 룸에 "widget:moved" 이벤트를 전파해야 한다', async () => { + // given: 클라이언트로부터 위젯 위치 이동(레이아웃) 데이터가 전달되었을 때 + const layoutDto: UpdateWidgetLayoutDto = { widgetId: 'w-1', - userId, - }); - }); - - it('위젯 잠금 해제에 실패하면 아무런 이벤트를 전송하지 않아야 한다', async () => { - // given: 위젯 서비스가 잠금 해제 실패(false)를 반환하도록 설정 - serviceMock.unlock.mockResolvedValue(false); - - // when: 클라이언트가 `widget:unlock` 이벤트를 전송했을 때 - await gateway.unlock(unlockDto, clientMock as unknown as Socket); - - // then: 위젯 서비스의 unlock은 호출되지만, 다른 클라이언트에게는 아무런 이벤트도 전송되지 않는다 - expect(serviceMock.unlock).toHaveBeenCalledWith(roomId, 'w-1', userId); - expect(clientToEmitMock).not.toHaveBeenCalled(); - }); - }); - - describe('widget:move (위젯 이동)', () => { - const layoutDto: UpdateWidgetLayoutDto = { - widgetId: 'w-1', - x: 100, - y: 50, - }; - - it('잠금을 소유한 사용자가 요청 시, 위젯 위치를 변경하고 브로드캐스트해야 한다', async () => { - // given: 사용자가 해당 위젯의 잠금을 소유하고 있는 상황 - serviceMock.getLockOwner.mockResolvedValue(userId); + x: 100, + y: 50, + }; const updatedWidget = { widgetId: 'w-1', data: { x: 100, y: 50 } }; + + workspaceServiceMock.getUserBySocketId.mockReturnValue({ roomId }); serviceMock.updateLayout.mockResolvedValue(updatedWidget); - // when: 클라이언트가 `widget:move` 이벤트를 전송했을 때 + // when: 클라이언트가 위젯 이동 이벤트를 요청하면 await gateway.move(layoutDto, clientMock as unknown as Socket); - // then: 잠금 소유자를 확인하고, 레이아웃을 업데이트한 뒤, 다른 사용자에게 `widget:moved`로 변경사항을 알린다 - expect(serviceMock.getLockOwner).toHaveBeenCalledWith(roomId, 'w-1'); + // then: 서비스의 레이아웃 업데이트 메서드를 호출하고, 나를 제외한 다른 클라이언트들에게 변경된 위치 정보를 전송해야 한다 expect(serviceMock.updateLayout).toHaveBeenCalledWith(roomId, layoutDto); + expect(clientMock.to).toHaveBeenCalledWith(roomId); expect(clientToEmitMock).toHaveBeenCalledWith( 'widget:moved', updatedWidget, ); }); - - it('다른 사용자가 잠금한 위젯에 대해서는 이동 요청을 무시해야 한다', async () => { - // given: 다른 사용자가 위젯의 잠금을 소유하고 있는 상황 - serviceMock.getLockOwner.mockResolvedValue(otherUserId); - - // when: 클라이언트가 `widget:move` 이벤트를 전송했을 때 - await gateway.move(layoutDto, clientMock as unknown as Socket); - - // then: 잠금 소유자를 확인 후, 자신의 잠금이 아니므로 아무 작업도 수행하지 않는다 - expect(serviceMock.getLockOwner).toHaveBeenCalledWith(roomId, 'w-1'); - expect(serviceMock.updateLayout).not.toHaveBeenCalled(); - expect(clientToEmitMock).not.toHaveBeenCalled(); - }); }); - describe('widget:update (위젯 내용 수정)', () => { - const updateDto: UpdateWidgetDto = { - widgetId: 'w-1', - data: { content: { selectedItems: ['NestJS'] } }, - }; - - it('잠금을 소유한 사용자가 요청 시, 위젯 내용을 수정하고 브로드캐스트해야 한다', async () => { - // given: 사용자가 해당 위젯의 잠금을 소유하고 있는 상황 - serviceMock.getLockOwner.mockResolvedValue(userId); - const updatedWidget = { widgetId: 'w-1', data: updateDto.data }; - serviceMock.update.mockResolvedValue(updatedWidget); - - // when: 클라이언트가 `widget:update` 이벤트를 전송했을 때 - await gateway.update(updateDto, clientMock as unknown as Socket); - - // then: 잠금 소유자를 확인하고, 위젯 내용을 업데이트한 뒤, 다른 사용자에게 `widget:updated`로 변경사항을 알린다 - expect(serviceMock.getLockOwner).toHaveBeenCalledWith(roomId, 'w-1'); - expect(serviceMock.update).toHaveBeenCalledWith(roomId, updateDto); - expect(clientToEmitMock).toHaveBeenCalledWith( - 'widget:updated', - updatedWidget, - ); - }); + describe('update (위젯 콘텐츠 수정 이벤트)', () => { + it('위젯 내용을 수정하고 특정 룸에 "widget:updated" 이벤트를 전파해야 한다', async () => { + // given: 클라이언트로부터 위젯의 내부 콘텐츠 수정 데이터가 전달되었을 때 + const updateDto: UpdateWidgetDto = { + widgetId: 'w-1', + data: { + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: ['NestJS'], + }, + }, + }; + const updatedWidget = { + widgetId: 'w-1', + data: { content: { selectedItems: ['NestJS'] } }, + }; - it('잠금되지 않은 위젯은 누구나 수정하고 브로드캐스트할 수 있다', async () => { - // given: 위젯이 잠금되어 있지 않은 상황 (소유자 null) - serviceMock.getLockOwner.mockResolvedValue(null); - const updatedWidget = { widgetId: 'w-1', data: updateDto.data }; + workspaceServiceMock.getUserBySocketId.mockReturnValue({ roomId }); serviceMock.update.mockResolvedValue(updatedWidget); - // when: 클라이언트가 `widget:update` 이벤트를 전송했을 때 + // when: 클라이언트가 위젯 내용 수정 이벤트를 요청하면 await gateway.update(updateDto, clientMock as unknown as Socket); - // then: 위젯 내용을 즉시 업데이트하고, 다른 사용자에게 `widget:updated`로 변경사항을 알린다 + // then: 서비스의 콘텐츠 업데이트 메서드를 호출하고, 나를 제외한 다른 클라이언트들에게 변경된 콘텐츠 정보를 전송해야 한다 expect(serviceMock.update).toHaveBeenCalledWith(roomId, updateDto); + expect(clientMock.to).toHaveBeenCalledWith(roomId); expect(clientToEmitMock).toHaveBeenCalledWith( 'widget:updated', updatedWidget, ); }); - - it('다른 사용자가 잠금한 위젯에 대해서는 수정 요청 시 에러를 발생시켜야 한다', async () => { - // given: 다른 사용자가 위젯의 잠금을 소유하고 있는 상황 - serviceMock.getLockOwner.mockResolvedValue(otherUserId); - - // when: 클라이언트가 `widget:update` 이벤트를 전송했을 때 - await gateway.update(updateDto, clientMock as unknown as Socket); - - // then: 잠금 소유자를 확인 후, 자신의 잠금이 아니므로 업데이트를 수행하지 않고 요청자에게 에러를 보낸다 - expect(serviceMock.update).not.toHaveBeenCalled(); - expect(clientMock.emit).toHaveBeenCalledWith('error', expect.any(String)); - }); }); - describe('widget:delete (위젯 삭제)', () => { - const widgetId = 'w-1'; + describe('remove (위젯 삭제 이벤트)', () => { + it('위젯을 삭제하고 특정 룸에 "widget:deleted" 이벤트를 전파해야 한다', async () => { + // given: 삭제할 위젯의 ID가 전달되었을 때 + const widgetId = 'w-1'; + const result = { widgetId }; + workspaceServiceMock.getUserBySocketId.mockReturnValue({ roomId }); + serviceMock.remove.mockResolvedValue(result); - it('잠금을 소유한 사용자가 요청 시, 위젯을 삭제하고 브로드캐스트해야 한다', async () => { - // given: 사용자가 해당 위젯의 잠금을 소유하고 있는 상황 - serviceMock.getLockOwner.mockResolvedValue(userId); - serviceMock.remove.mockResolvedValue({ widgetId }); - - // when: 클라이언트가 `widget:delete` 이벤트를 전송했을 때 + // when: 클라이언트가 위젯 삭제 이벤트를 요청하면 await gateway.remove({ widgetId }, clientMock as unknown as Socket); - // then: 잠금 소유자를 확인하고, 위젯을 삭제한 뒤, 전체 사용자에게 `widget:deleted`로 삭제 사실을 알린다 + // then: 서비스의 삭제 메서드를 호출하고, 룸의 모든 클라이언트에게 삭제된 위젯 ID를 브로드캐스트해야 한다 expect(serviceMock.remove).toHaveBeenCalledWith(roomId, widgetId); - expect(serverToEmitMock).toHaveBeenCalledWith('widget:deleted', { - widgetId, - }); - }); - - it('잠금되지 않은 위젯은 누구나 삭제하고 브로드캐스트할 수 있다', async () => { - // given: 위젯이 잠금되어 있지 않은 상황 (소유자 null) - serviceMock.getLockOwner.mockResolvedValue(null); - serviceMock.remove.mockResolvedValue({ widgetId }); - - // when: 클라이언트가 `widget:delete` 이벤트를 전송했을 때 - await gateway.remove({ widgetId }, clientMock as unknown as Socket); - - // then: 위젯을 즉시 삭제하고, 전체 사용자에게 `widget:deleted`로 삭제 사실을 알린다 - expect(serviceMock.remove).toHaveBeenCalledWith(roomId, widgetId); - expect(serverToEmitMock).toHaveBeenCalledWith('widget:deleted', { - widgetId, - }); - }); - - it('다른 사용자가 잠금한 위젯에 대해서는 삭제 요청 시 에러를 발생시켜야 한다', async () => { - // given: 다른 사용자가 위젯의 잠금을 소유하고 있는 상황 - serviceMock.getLockOwner.mockResolvedValue(otherUserId); - - // when: 클라이언트가 `widget:delete` 이벤트를 전송했을 때 - await gateway.remove({ widgetId }, clientMock as unknown as Socket); - - // then: 잠금 소유자를 확인 후, 자신의 잠금이 아니므로 삭제를 수행하지 않고 요청자에게 에러를 보낸다 - expect(serviceMock.remove).not.toHaveBeenCalled(); - expect(clientMock.emit).toHaveBeenCalledWith('error', expect.any(String)); + expect(serverToEmitMock).toHaveBeenCalledWith('widget:deleted', result); }); }); - describe('widget:load_all (전체 위젯 조회)', () => { - it('요청한 사용자에게만 전체 위젯 목록을 반환해야 한다', async () => { - // given: 서비스에 여러 위젯이 저장되어 있는 상황 + describe('findAll (전체 위젯 로드 이벤트)', () => { + it('워크스페이스의 모든 위젯을 조회하여 요청자에게 "widget:load_all_response"로 반환해야 한다', async () => { + // given: 워크스페이스에 조회할 위젯 목록이 존재할 때 const widgets = [{ widgetId: 'w-1' }]; + workspaceServiceMock.getUserBySocketId.mockReturnValue({ roomId }); serviceMock.findAll.mockResolvedValue(widgets); - // when: 클라이언트가 `widget:load_all` 이벤트를 전송했을 때 + // when: 클라이언트가 전체 위젯 목록 로드를 요청하면 await gateway.findAll(clientMock as unknown as Socket); - // then: 위젯 서비스의 findAll이 호출되고, 요청한 클라이언트에게만 `widget:load_all_response`로 목록이 전송된다 + // then: 서비스에서 모든 위젯을 조회한 후, 요청을 보낸 클라이언트에게만 데이터를 응답해야 한다 expect(serviceMock.findAll).toHaveBeenCalledWith(roomId); expect(clientMock.emit).toHaveBeenCalledWith( 'widget:load_all_response', diff --git a/backend/src/widget/__test__/widget.service.spec.ts b/backend/src/widget/__test__/widget.service.spec.ts index d129f8a8..bca77d9f 100644 --- a/backend/src/widget/__test__/widget.service.spec.ts +++ b/backend/src/widget/__test__/widget.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { WidgetMemoryService } from '../widget.memory.service'; -import { CreateWidgetDto } from '../dto/create-widget.dto'; +import { CreateWidgetDto, WidgetData } from '../dto/create-widget.dto'; import { WidgetType, TechStackContentDto } from '../dto/widget-content.dto'; import { NotFoundException } from '@nestjs/common'; import { UpdateWidgetLayoutDto } from '../dto/update-widget-layout.dto'; @@ -8,11 +8,6 @@ import { UpdateWidgetLayoutDto } from '../dto/update-widget-layout.dto'; describe('WidgetMemoryService', () => { let service: WidgetMemoryService; const workspaceId = 'workspace-1'; - const widgetId = 'widget-1'; - const userId1 = 'user-1'; - const userId2 = 'user-2'; - - let initialDto: CreateWidgetDto; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -20,25 +15,6 @@ describe('WidgetMemoryService', () => { }).compile(); service = module.get(WidgetMemoryService); - - // given: 테스트에 사용할 초기 위젯 데이터 생성 - initialDto = { - widgetId, - type: WidgetType.TECH_STACK, - data: { - x: 100, - y: 100, - width: 200, - height: 200, - zIndex: 1, - content: { - widgetType: WidgetType.TECH_STACK, - selectedItems: ['React'], - } as TechStackContentDto, - }, - }; - // given: 모든 테스트 시작 전 위젯 생성 - await service.create(workspaceId, initialDto); }); it('서비스가 정의되어 있어야 한다', () => { @@ -47,58 +23,92 @@ describe('WidgetMemoryService', () => { describe('create (위젯 생성)', () => { it('새로운 기술 스택 위젯을 특정 워크스페이스에 생성하고 저장해야 한다', async () => { - // given: 생성할 위젯의 초기 데이터 - const newWidget: CreateWidgetDto = { - widgetId: 'new-widget', + // given: 생성할 위젯의 초기 데이터(위치, 크기, 콘텐츠)가 주어졌을 때 + const widgetData: WidgetData = { + x: 100, + y: 200, + width: 100, + height: 100, + zIndex: 1, + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: ['React'], + } as TechStackContentDto, + }; + + const createDto: CreateWidgetDto = { + widgetId: 'test-1', type: WidgetType.TECH_STACK, - data: { - x: 10, - y: 10, - width: 10, - height: 10, - zIndex: 1, - content: { - widgetType: WidgetType.TECH_STACK, - selectedItems: [], - } as TechStackContentDto, - }, + data: widgetData, }; // when: 위젯 생성 메서드를 호출하면 - const result = await service.create(workspaceId, newWidget); + const result = await service.create(workspaceId, createDto); // then: 생성된 위젯 객체가 반환되고, 실제 메모리 저장소에서도 조회가 가능해야 한다 - expect(result).toEqual(newWidget); - expect(await service.findOne(workspaceId, 'new-widget')).toEqual( - newWidget, - ); + expect(result).toEqual(createDto); + expect(await service.findOne(workspaceId, 'test-1')).toEqual(createDto); }); }); describe('findAll (전체 조회)', () => { it('해당 워크스페이스의 모든 위젯 목록을 반환해야 한다', async () => { - // given: beforeEach에서 위젯 하나가 이미 저장됨 + // given: 워크스페이스에 하나의 위젯이 미리 저장되어 있을 때 + const createDto: CreateWidgetDto = { + widgetId: 'test-1', + type: WidgetType.TECH_STACK, + data: { + x: 0, + y: 0, + width: 0, + height: 0, + zIndex: 0, + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: [], + } as TechStackContentDto, + }, + }; + await service.create(workspaceId, createDto); + // when: 전체 위젯 목록 조회를 요청하면 const result = await service.findAll(workspaceId); // then: 저장된 위젯을 포함한 배열이 반환되어야 한다 expect(result).toHaveLength(1); - expect(result[0]).toEqual(initialDto); + expect(result[0]).toEqual(createDto); }); }); describe('findOne (단일 조회)', () => { it('존재하는 위젯 ID로 조회하면 해당 위젯을 반환해야 한다', async () => { - // given: beforeEach에서 위젯이 이미 저장됨 + // given: 특정 위젯이 저장되어 있을 때 + const createDto: CreateWidgetDto = { + widgetId: 'test-1', + type: WidgetType.TECH_STACK, + data: { + x: 0, + y: 0, + width: 0, + height: 0, + zIndex: 0, + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: [], + } as TechStackContentDto, + }, + }; + await service.create(workspaceId, createDto); + // when: 해당 위젯 ID로 조회를 요청하면 - const result = await service.findOne(workspaceId, widgetId); + const result = await service.findOne(workspaceId, 'test-1'); // then: 저장된 위젯 정보와 일치하는 데이터가 반환되어야 한다 - expect(result).toEqual(initialDto); + expect(result).toEqual(createDto); }); it('존재하지 않는 위젯 ID로 조회하면 NotFoundException을 던져야 한다', async () => { - // given: 존재하지 않는 위젯 ID + // given: 존재하지 않는 위젯 ID가 주어졌을 때 // when: 조회를 요청하면 // then: NotFoundException 예외가 발생해야 한다 await expect( @@ -109,14 +119,31 @@ describe('WidgetMemoryService', () => { describe('updateLayout (위치 변경)', () => { it('위젯의 좌표를 수정하면 해당 값만 변경되어야 한다', async () => { - // given: beforeEach에서 위젯이 이미 저장됨 + // given: 초기 상태의 위젯이 저장되어 있을 때 + const initialDto: CreateWidgetDto = { + widgetId: 'test-1', + type: WidgetType.TECH_STACK, + data: { + x: 100, + y: 100, + width: 200, + height: 200, + zIndex: 1, + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: ['React'], + } as TechStackContentDto, + }, + }; + await service.create(workspaceId, initialDto); + // when: 위젯의 x 좌표만 300으로 변경하는 레이아웃 업데이트를 요청하면 const updateResult = await service.updateLayout(workspaceId, { - widgetId: widgetId, + widgetId: 'test-1', x: 300, } as UpdateWidgetLayoutDto); - // then: x 좌표는 300으로 업데이트되고, y 좌표나 내부 콘텐츠는 유지되어야 한다 + // then: x 좌표는 300으로 업데이트되고, y 좌표나 내부 콘텐츠(selectedItems)는 변경되지 않고 유지되어야 한다 expect(updateResult.data.x).toBe(300); expect(updateResult.data.y).toBe(100); const content = updateResult.data.content as TechStackContentDto; @@ -126,10 +153,27 @@ describe('WidgetMemoryService', () => { describe('update (콘텐츠 수정)', () => { it('위젯의 콘텐츠 내용을 수정하면 해당 내용이 반영되어야 한다', async () => { - // given: beforeEach에서 위젯이 이미 저장됨 - // when: 위젯의 내부 콘텐츠를 변경하는 업데이트를 요청하면 + // given: 초기 상태의 위젯이 저장되어 있을 때 + const initialDto: CreateWidgetDto = { + widgetId: 'test-1', + type: WidgetType.TECH_STACK, + data: { + x: 100, + y: 100, + width: 200, + height: 200, + zIndex: 1, + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: ['React'], + } as TechStackContentDto, + }, + }; + await service.create(workspaceId, initialDto); + + // when: 위젯의 내부 콘텐츠(기술 스택 목록)를 변경하는 업데이트를 요청하면 const updateResult = await service.update(workspaceId, { - widgetId: widgetId, + widgetId: 'test-1', data: { content: { widgetType: WidgetType.TECH_STACK, @@ -138,32 +182,45 @@ describe('WidgetMemoryService', () => { }, }); - // then: 콘텐츠 내용은 변경되고, 위치는 유지되어야 한다 + // then: 콘텐츠 내용은 'NestJS'로 변경되어야 하고, 위젯의 위치(x 좌표)는 변경되지 않아야 한다 const content = updateResult.data.content as TechStackContentDto; expect(content.selectedItems).toEqual(['NestJS']); - expect(updateResult.data.x).toBe(100); + expect(updateResult.data.x).toBe(100); // 위치는 유지 }); }); describe('remove (위젯 삭제)', () => { - it('존재하는 위젯을 삭제하면 삭제된 위젯 ID를 반환하고, 관련 lock도 해제해야 한다', async () => { - // given: 위젯이 생성되어 있고, 특정 유저가 lock을 점유하고 있을 때 - await service.lock(workspaceId, widgetId, userId1); - expect(await service.getLockOwner(workspaceId, widgetId)).toBe(userId1); + it('존재하는 위젯을 삭제하면 삭제된 위젯 ID를 반환해야 한다', async () => { + // given: 삭제할 위젯이 미리 생성되어 있을 때 + const createDto: CreateWidgetDto = { + widgetId: 'test-1', + type: WidgetType.TECH_STACK, + data: { + x: 0, + y: 0, + width: 0, + height: 0, + zIndex: 0, + content: { + widgetType: WidgetType.TECH_STACK, + selectedItems: [], + } as TechStackContentDto, + }, + }; + await service.create(workspaceId, createDto); // when: 해당 위젯에 대한 삭제를 요청하면 - const result = await service.remove(workspaceId, widgetId); + const result = await service.remove(workspaceId, 'test-1'); - // then: 삭제된 위젯 ID가 반환되고, 이후 조회 시 NotFoundException이 발생하며, lock도 해제되어야 한다 - expect(result).toEqual({ widgetId: widgetId }); - await expect(service.findOne(workspaceId, widgetId)).rejects.toThrow( + // then: 삭제된 위젯 ID가 반환되고, 이후 조회 시 NotFoundException이 발생해야 한다 + expect(result).toEqual({ widgetId: 'test-1' }); + await expect(service.findOne(workspaceId, 'test-1')).rejects.toThrow( NotFoundException, ); - expect(await service.getLockOwner(workspaceId, widgetId)).toBeNull(); }); it('존재하지 않는 위젯을 삭제하려 하면 NotFoundException을 던져야 한다', async () => { - // given: 존재하지 않는 위젯 ID + // given: 존재하지 않는 위젯 ID가 주어졌을 때 // when: 삭제를 요청하면 // then: NotFoundException 예외가 발생해야 한다 await expect(service.remove(workspaceId, 'non-existent')).rejects.toThrow( @@ -171,111 +228,4 @@ describe('WidgetMemoryService', () => { ); }); }); - - describe('Widget Locking (위젯 잠금 관리)', () => { - describe('lock', () => { - it('아무도 점유하지 않은 위젯에 lock을 설정하면 true를 반환한다', async () => { - // given: 점유되지 않은 위젯 - // when: user1이 lock을 요청하면 - const result = await service.lock(workspaceId, widgetId, userId1); - // then: 성공(true)해야 하고, lock의 소유자는 user1이어야 한다 - expect(result).toBe(true); - expect(await service.getLockOwner(workspaceId, widgetId)).toBe(userId1); - }); - - it('이미 다른 유저가 점유한 위젯에 lock을 설정하면 false를 반환한다', async () => { - // given: user1이 이미 위젯을 점유하고 있을 때 - await service.lock(workspaceId, widgetId, userId1); - // when: user2가 동일한 위젯에 lock을 요청하면 - const result = await service.lock(workspaceId, widgetId, userId2); - // then: 실패(false)해야 하고, lock의 소유자는 여전히 user1이어야 한다 - expect(result).toBe(false); - expect(await service.getLockOwner(workspaceId, widgetId)).toBe(userId1); - }); - - it('이미 자신이 점유한 위젯에 다시 lock을 설정하면 true를 반환한다', async () => { - // given: user1이 이미 위젯을 점유하고 있을 때 - await service.lock(workspaceId, widgetId, userId1); - // when: user1이 다시 동일한 위젯에 lock을 요청하면 - const result = await service.lock(workspaceId, widgetId, userId1); - // then: 성공(true)해야 하고, lock의 소유자는 계속 user1이어야 한다 - expect(result).toBe(true); - expect(await service.getLockOwner(workspaceId, widgetId)).toBe(userId1); - }); - - it('존재하지 않는 위젯에 lock을 설정하려 하면 NotFoundException을 던진다', async () => { - // given: 존재하지 않는 위젯 ID - // when: lock을 요청하면 - // then: NotFoundException 예외가 발생해야 한다 - await expect( - service.lock(workspaceId, 'non-existent', userId1), - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('unlock', () => { - it('lock 소유자가 unlock을 요청하면 true를 반환하고 lock을 해제한다', async () => { - // given: user1이 위젯을 점유하고 있을 때 - await service.lock(workspaceId, widgetId, userId1); - // when: user1이 unlock을 요청하면 - const result = await service.unlock(workspaceId, widgetId, userId1); - // then: 성공(true)해야 하고, lock은 해제되어 소유자가 없어야 한다 - expect(result).toBe(true); - expect(await service.getLockOwner(workspaceId, widgetId)).toBeNull(); - }); - - it('lock 소유자가 아닌 유저가 unlock을 요청하면 false를 반환하고 lock은 유지된다', async () => { - // given: user1이 위젯을 점유하고 있을 때 - await service.lock(workspaceId, widgetId, userId1); - // when: user2가 unlock을 요청하면 - const result = await service.unlock(workspaceId, widgetId, userId2); - // then: 실패(false)해야 하고, lock 소유자는 여전히 user1이어야 한다 - expect(result).toBe(false); - expect(await service.getLockOwner(workspaceId, widgetId)).toBe(userId1); - }); - - it('점유되지 않은 위젯에 unlock을 요청하면 false를 반환한다', async () => { - // given: 점유되지 않은 위젯 - // when: unlock을 요청하면 - const result = await service.unlock(workspaceId, widgetId, userId1); - // then: 실패(false)해야 한다 - expect(result).toBe(false); - }); - }); - - describe('unlockAllByUser', () => { - it('특정 유저가 점유한 모든 위젯의 lock을 해제하고, 해제된 위젯 ID 목록을 반환한다', async () => { - // given: user1이 두 개의 위젯을, user2가 하나의 위젯을 점유하고 있을 때 - const widgetId2 = 'widget-2'; - const widgetId3 = 'widget-3'; - await service.create(workspaceId, { - ...initialDto, - widgetId: widgetId2, - }); - await service.create(workspaceId, { - ...initialDto, - widgetId: widgetId3, - }); - - await service.lock(workspaceId, widgetId, userId1); - await service.lock(workspaceId, widgetId2, userId1); - await service.lock(workspaceId, widgetId3, userId2); - - // when: user1에 대해 모든 lock 해제를 요청하면 - const unlockedIds = await service.unlockAllByUser(workspaceId, userId1); - - // then: user1이 점유했던 위젯 ID 목록이 반환되고, 해당 위젯들의 lock은 해제되어야 한다 - expect(unlockedIds).toHaveLength(2); - expect(unlockedIds).toContain(widgetId); - expect(unlockedIds).toContain(widgetId2); - - expect(await service.getLockOwner(workspaceId, widgetId)).toBeNull(); - expect(await service.getLockOwner(workspaceId, widgetId2)).toBeNull(); - // and: user2의 lock은 그대로 유지되어야 한다 - expect(await service.getLockOwner(workspaceId, widgetId3)).toBe( - userId2, - ); - }); - }); - }); }); diff --git a/backend/src/widget/widget.gateway.ts b/backend/src/widget/widget.gateway.ts index e644ea1c..03664f16 100644 --- a/backend/src/widget/widget.gateway.ts +++ b/backend/src/widget/widget.gateway.ts @@ -46,11 +46,6 @@ export class WidgetGateway { return userInfo.roomId; } - private getUserId(client: Socket): string | null { - const userInfo = this.workspaceService.getUserBySocketId(client.id); - return userInfo ? userInfo.user.id : null; - } - @AsyncApiSub({ channel: 'widget:create', summary: '위젯 생성', @@ -81,84 +76,12 @@ export class WidgetGateway { return widget; } - @AsyncApiSub({ - channel: 'widget:lock', - summary: '위젯 점유(Lock) 요청', - description: '드래그 시작 시 위젯을 점유하기 위해 호출합니다.', - message: { payload: UpdateWidgetLayoutDto }, - }) - @AsyncApiPub({ - channel: 'widget:locked', - summary: '위젯 점유 브로드캐스트', - description: '위젯이 특정 유저에게 점유되었음을 알립니다.', - message: { payload: Object }, - }) - @SubscribeMessage('widget:lock') - async lock( - @MessageBody() dto: UpdateWidgetLayoutDto, - @ConnectedSocket() client: Socket, - ) { - const roomId = this.getRoomId(client); - const userId = this.getUserId(client); - if (!roomId || !userId) return; - - const success = await this.widgetService.lock(roomId, dto.widgetId, userId); - - if (success) { - // 다른 사용자들에게 userId가 점유중을 알림(FE에서 시각적으로 필요하면 사용. 아닌경우 추후 삭제) - client.to(roomId).emit('widget:locked', { - widgetId: dto.widgetId, - userId, - }); - } else { - // 점유 실패 시 본인에게 에러 전송 - client.emit( - 'error', - 'Failed to lock widget. It might be already locked.', - ); - } - } - - @AsyncApiSub({ - channel: 'widget:unlock', - summary: '위젯 점유 해제(Unlock) 요청', - description: '드래그 종료 시 위젯 점유를 해제하기 위해 호출합니다.', - message: { payload: UpdateWidgetLayoutDto }, - }) - @AsyncApiPub({ - channel: 'widget:unlocked', - summary: '위젯 점유 해제 브로드캐스트', - description: '위젯 점유가 해제되었음을 알립니다.', - message: { payload: Object }, - }) - @SubscribeMessage('widget:unlock') - async unlock( - @MessageBody() dto: UpdateWidgetLayoutDto, - @ConnectedSocket() client: Socket, - ) { - const roomId = this.getRoomId(client); - const userId = this.getUserId(client); - if (!roomId || !userId) return; - - const success = await this.widgetService.unlock( - roomId, - dto.widgetId, - userId, - ); - - if (success) { - client.to(roomId).emit('widget:unlocked', { - widgetId: dto.widgetId, - userId, - }); - } - } - + // 레이아웃 변경 (이동, 크기 조절 등) @AsyncApiSub({ channel: 'widget:move', summary: '위젯 레이아웃 변경', description: - '클라이언트에서 위젯의 위치 또는 크기를 변경할 때 서버로 보내는 이벤트입니다. (Lock 소유자만 가능)', + '클라이언트에서 위젯의 위치 또는 크기를 변경할 때 서버로 보내는 이벤트입니다.', message: { payload: UpdateWidgetLayoutDto, }, @@ -178,18 +101,7 @@ export class WidgetGateway { @ConnectedSocket() client: Socket, ) { const roomId = this.getRoomId(client); - const userId = this.getUserId(client); - if (!roomId || !userId) return; - - const owner = await this.widgetService.getLockOwner( - roomId, - updateLayoutDto.widgetId, - ); - - // 락이 없거나, 다른 사람이 점유중일 시 무시 - if (owner !== userId) { - return; - } + if (!roomId) return; const updatedWidget = await this.widgetService.updateLayout( roomId, @@ -201,11 +113,12 @@ export class WidgetGateway { return updatedWidget; } + // 콘텐츠 변경 (내용 수정) @AsyncApiSub({ channel: 'widget:update', summary: '위젯 수정', description: - '클라이언트에서 위젯의 내용을 수정할 때 서버로 보내는 이벤트입니다. (Lock 체크)', + '클라이언트에서 위젯의 내용 등을 수정할 때 서버로 보내는 이벤트입니다.', message: { payload: UpdateWidgetDto, }, @@ -214,7 +127,7 @@ export class WidgetGateway { channel: 'widget:updated', summary: '위젯 수정 브로드캐스트', description: - '위젯이 수정된 이후, 동일 워크스페이스의 모든 클라이언트에게 수정된 위젯 정보를 브로드캐스트합니다.', + '위젯이 수정된 이후, 동일 워크스페이스의 모든 클라이언트에게 수정된 위젯 내용 정보를 브로드캐스트합니다.', message: { payload: Object, }, @@ -225,17 +138,7 @@ export class WidgetGateway { @ConnectedSocket() client: Socket, ) { const roomId = this.getRoomId(client); - const userId = this.getUserId(client); - if (!roomId || !userId) return; - - const owner = await this.widgetService.getLockOwner( - roomId, - updateWidgetDto.widgetId, - ); - if (owner && owner !== userId) { - client.emit('error', 'Widget is locked by another user.'); - return; - } + if (!roomId) return; const updatedWidget = await this.widgetService.update( roomId, @@ -251,7 +154,7 @@ export class WidgetGateway { channel: 'widget:delete', summary: '위젯 삭제', description: - '특정 위젯을 삭제할 때 서버로 보내는 이벤트입니다. widgetId를 포함합니다. (Lock 체크) /* 현재는 DTO로 받지 않고 단일 widgetId로 받고 있어서 나오지 않음 */', + '특정 위젯을 삭제할 때 서버로 보내는 이벤트입니다. widgetId를 포함합니다. /* 현재는 DTO로 받지 않고 단일 widgetId로 받고 있어서 나오지 않음 */', message: { payload: Object, }, @@ -271,14 +174,7 @@ export class WidgetGateway { @ConnectedSocket() client: Socket, ) { const roomId = this.getRoomId(client); - const userId = this.getUserId(client); - if (!roomId || !userId) return; - - const owner = await this.widgetService.getLockOwner(roomId, body.widgetId); - if (owner && owner !== userId) { - client.emit('error', 'Widget is locked by another user.'); - return; - } + if (!roomId) return; const result = await this.widgetService.remove(roomId, body.widgetId); this.server.to(roomId).emit('widget:deleted', result); diff --git a/backend/src/widget/widget.interface.ts b/backend/src/widget/widget.interface.ts index 3fca6b43..945f9d8c 100644 --- a/backend/src/widget/widget.interface.ts +++ b/backend/src/widget/widget.interface.ts @@ -1,5 +1,6 @@ import { CreateWidgetDto } from './dto/create-widget.dto'; import { UpdateWidgetDto } from './dto/update-widget.dto'; +import { WidgetType } from './dto/widget-content.dto'; import { UpdateWidgetLayoutDto } from './dto/update-widget-layout.dto'; export interface IWidgetService { @@ -7,11 +8,12 @@ export interface IWidgetService { workspaceId: string, createWidgetDto: CreateWidgetDto, ): Promise; - findAll(workspaceId: string): Promise; - findOne(workspaceId: string, widgetId: string): Promise; - + findOneByWidgetType( + workspaceId: string, + widgetType: WidgetType, + ): Promise; update( workspaceId: string, updateWidgetDto: UpdateWidgetDto, @@ -23,18 +25,6 @@ export interface IWidgetService { ): Promise; remove(workspaceId: string, widgetId: string): Promise<{ widgetId: string }>; - - lock(workspaceId: string, widgetId: string, userId: string): Promise; - - unlock( - workspaceId: string, - widgetId: string, - userId: string, - ): Promise; - - getLockOwner(workspaceId: string, widgetId: string): Promise; - - unlockAllByUser(workspaceId: string, userId: string): Promise; } export const WIDGET_SERVICE = 'WIDGET_SERVICE'; diff --git a/backend/src/widget/widget.memory.service.ts b/backend/src/widget/widget.memory.service.ts index 42370866..8e7de4f1 100644 --- a/backend/src/widget/widget.memory.service.ts +++ b/backend/src/widget/widget.memory.service.ts @@ -2,12 +2,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { IWidgetService } from './widget.interface'; import { CreateWidgetDto } from './dto/create-widget.dto'; import { UpdateWidgetDto } from './dto/update-widget.dto'; +import { WidgetType } from './dto/widget-content.dto'; import { UpdateWidgetLayoutDto } from './dto/update-widget-layout.dto'; @Injectable() export class WidgetMemoryService implements IWidgetService { private readonly workspaces = new Map>(); - private readonly locks = new Map(); private getWidgetsMap(workspaceId: string): Map { if (!this.workspaces.has(workspaceId)) { @@ -42,6 +42,21 @@ export class WidgetMemoryService implements IWidgetService { return Promise.resolve(widget); } + async findOneByWidgetType( + workspaceId: string, + widgetType: WidgetType, + ): Promise { + const widgets = this.getWidgetsMap(workspaceId); + const widget = Array.from(widgets.values()).find( + (widget) => widget.data.content.widgetType === widgetType, + ); + if (!widget) { + return Promise.resolve(null); + } + return Promise.resolve(widget); + } + + // 콘텐츠 내용 수정 (Deep Merge) async update( workspaceId: string, updateWidgetDto: UpdateWidgetDto, @@ -70,6 +85,7 @@ export class WidgetMemoryService implements IWidgetService { return Promise.resolve(updatedWidget); } + // 레이아웃 수정 (Shallow Merge for Layout Props) async updateLayout( workspaceId: string, layoutDto: UpdateWidgetLayoutDto, @@ -83,13 +99,14 @@ export class WidgetMemoryService implements IWidgetService { ); } + // 변경된 레이아웃 속성만 추출 const { widgetId, ...layoutChanges } = layoutDto; const updatedWidget = { ...existingWidget, data: { ...existingWidget.data, - ...layoutChanges, + ...layoutChanges, // x, y, width, height, zIndex 덮어쓰기 }, } as CreateWidgetDto; @@ -106,60 +123,6 @@ export class WidgetMemoryService implements IWidgetService { throw new NotFoundException(`Widget with ID ${widgetId} not found`); } widgets.delete(widgetId); - this.locks.delete(widgetId); return Promise.resolve({ widgetId }); } - - async lock( - workspaceId: string, - widgetId: string, - userId: string, - ): Promise { - const widgets = this.getWidgetsMap(workspaceId); - if (!widgets.has(widgetId)) { - throw new NotFoundException(`Widget with ID ${widgetId} not found`); - } - - const currentOwner = this.locks.get(widgetId); - if (currentOwner && currentOwner !== userId) { - return Promise.resolve(false); - } - - this.locks.set(widgetId, userId); - return Promise.resolve(true); - } - - async unlock( - workspaceId: string, - widgetId: string, - userId: string, - ): Promise { - const currentOwner = this.locks.get(widgetId); - if (currentOwner === userId) { - this.locks.delete(widgetId); - return Promise.resolve(true); - } - return Promise.resolve(false); - } - - async getLockOwner( - workspaceId: string, - widgetId: string, - ): Promise { - return Promise.resolve(this.locks.get(widgetId) || null); - } - - async unlockAllByUser( - workspaceId: string, - userId: string, - ): Promise { - const unlockedWidgetIds: string[] = []; - for (const [widgetId, ownerId] of this.locks.entries()) { - if (ownerId === userId) { - this.locks.delete(widgetId); - unlockedWidgetIds.push(widgetId); - } - } - return Promise.resolve(unlockedWidgetIds); - } } diff --git a/backend/src/widget/widget.module.ts b/backend/src/widget/widget.module.ts index e0776d40..3dc38771 100644 --- a/backend/src/widget/widget.module.ts +++ b/backend/src/widget/widget.module.ts @@ -1,11 +1,11 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { WidgetGateway } from './widget.gateway'; import { WidgetMemoryService } from './widget.memory.service'; import { WIDGET_SERVICE } from './widget.interface'; import { WorkspaceModule } from '../workspace/workspace.module'; @Module({ - imports: [forwardRef(() => WorkspaceModule)], + imports: [WorkspaceModule], providers: [ WidgetGateway, { @@ -13,6 +13,5 @@ import { WorkspaceModule } from '../workspace/workspace.module'; useClass: WidgetMemoryService, // 나중에 WidgetRedisService로 교체 가능 }, ], - exports: [WIDGET_SERVICE], }) export class WidgetModule {} diff --git a/backend/src/workspace/__test__/workspace.gateway.spec.ts b/backend/src/workspace/__test__/workspace.gateway.spec.ts index 0a56a2d8..1f8b8128 100644 --- a/backend/src/workspace/__test__/workspace.gateway.spec.ts +++ b/backend/src/workspace/__test__/workspace.gateway.spec.ts @@ -1,37 +1,27 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Server, Socket } from 'socket.io'; + import { WorkspaceGateway } from '../workspace.gateway'; import { WorkspaceService } from '../workspace.service'; import { JoinUserDTO } from '../dto/join-user.dto'; import { LeaveUserDTO } from '../dto/left-user.dto'; -import { IWidgetService, WIDGET_SERVICE } from '../../widget/widget.interface'; describe('WorkspaceGateway', () => { let gateway: WorkspaceGateway; - let serverMock: { to: jest.Mock; emit: jest.Mock }; + let serverMock: Partial; let clientMock: Partial; let workspaceServiceMock: Partial; - let widgetServiceMock: Partial; - let serverToEmitMock: jest.Mock; - - const workspaceId = 'w1'; - const userId = 'u1'; - const socketId = 's1'; beforeEach(async () => { - serverToEmitMock = jest.fn(); serverMock = { - to: jest.fn().mockReturnValue({ emit: serverToEmitMock }), + to: jest.fn(), emit: jest.fn(), }; - clientMock = { - id: socketId, join: jest.fn(), leave: jest.fn(), data: {}, }; - workspaceServiceMock = { joinUser: jest.fn(), leaveUser: jest.fn(), @@ -39,76 +29,69 @@ describe('WorkspaceGateway', () => { getUserBySocketId: jest.fn(), }; - widgetServiceMock = { - unlockAllByUser: jest.fn().mockResolvedValue([]), - }; - const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspaceGateway, { provide: WorkspaceService, useValue: workspaceServiceMock }, - { provide: WIDGET_SERVICE, useValue: widgetServiceMock }, ], }).compile(); gateway = module.get(WorkspaceGateway); - gateway.server = serverMock as unknown as Server; + (gateway as unknown as { server: Server }).server = serverMock as Server; jest.clearAllMocks(); }); - it('게이트웨이가 정상적으로 생성되어야 한다', () => { + it('워크 스페이스 게이트웨이 생성', () => { expect(gateway).toBeDefined(); }); - describe('handleDisconnect (연결 해제)', () => { - it('유저의 연결이 끊어지면, 퇴장처리 및 관련 리소스(커서, 위젯 잠금)를 해제해야 한다', async () => { - // given - workspaceServiceMock.handleDisconnect = jest - .fn() - .mockReturnValue({ roomId: workspaceId, userId: userId }); - widgetServiceMock.unlockAllByUser = jest - .fn() - .mockResolvedValue(['widget-1']); - - // when - await gateway.handleDisconnect(clientMock as Socket); - - // then - expect(workspaceServiceMock.handleDisconnect).toHaveBeenCalledWith( - socketId, - ); - expect(widgetServiceMock.unlockAllByUser).toHaveBeenCalledWith( - workspaceId, - userId, - ); - expect(serverMock.to).toHaveBeenCalledWith(workspaceId); - expect(serverToEmitMock).toHaveBeenCalledWith('widget:unlocked', { - widgetId: 'widget-1', - userId: userId, - }); - expect(serverToEmitMock).toHaveBeenCalledWith('user:status', { - userId, - status: 'OFFLINE', - }); - expect(serverToEmitMock).toHaveBeenCalledWith('user:left', userId); + describe('disconnect', () => { + beforeEach(() => { + serverMock.to = jest.fn().mockReturnValue(serverMock as Server); + clientMock = { + id: 's1', + join: jest.fn().mockResolvedValue(undefined), + leave: jest.fn().mockResolvedValue(undefined), + } as Partial; }); - it('유효하지 않은 유저의 연결 해제는 무시되어야 한다', async () => { - // given + it('disconnect 시 handleDisconnect가 null이면 emit 하지 않는다', () => { + // GIVEN workspaceServiceMock.handleDisconnect = jest.fn().mockReturnValue(null); - // when - await gateway.handleDisconnect(clientMock as Socket); + // WHEN + gateway.handleDisconnect(clientMock as Socket); - // then + // THEN + expect(workspaceServiceMock.handleDisconnect).toHaveBeenCalledWith('s1'); + expect(serverMock.emit).not.toHaveBeenCalled(); expect(serverMock.to).not.toHaveBeenCalled(); - expect(serverToEmitMock).not.toHaveBeenCalled(); + }); + + it('disconnect 시 user:left, user:status(OFFLINE) 이벤트를 발생시킨다', () => { + // GIVEN + workspaceServiceMock.handleDisconnect = jest.fn().mockReturnValue({ + roomId: 'w1', + userId: 'u1', + }); + + // WHEN + gateway.handleDisconnect(clientMock as Socket); + + // THEN + expect(workspaceServiceMock.handleDisconnect).toHaveBeenCalledWith('s1'); + expect(serverMock.to).toHaveBeenCalledWith('w1'); + expect(serverMock.emit).toHaveBeenCalledWith('user:status', { + userId: 'u1', + status: 'OFFLINE', + }); + expect(serverMock.emit).toHaveBeenCalledWith('user:left', 'u1'); }); }); - describe('user:join (워크스페이스 참여)', () => { + describe('user:join', () => { beforeEach(() => { workspaceServiceMock.joinUser = jest.fn().mockReturnValue({ roomId: 'w1', @@ -128,9 +111,7 @@ describe('WorkspaceGateway', () => { ], }); - serverMock.to = jest - .fn() - .mockReturnValue(serverMock as unknown as Server); + serverMock.to = jest.fn().mockReturnValue(serverMock as Server); clientMock = { id: 's1', join: jest.fn().mockResolvedValue(undefined), @@ -141,24 +122,16 @@ describe('WorkspaceGateway', () => { it('user:join 이벤트 발생 시 일련의 과정을 거친 후 user:status(ONLINE), user:joined 이벤트 발생', async () => { // GIVEN const payload: JoinUserDTO = { - workspaceId: workspaceId, + workspaceId: 'w1', user: { - id: userId, + id: 'u1', nickname: 'user1', color: '#000000', backgroundColor: '#ffffff', }, }; - workspaceServiceMock.joinUser = jest.fn().mockReturnValue({ - roomId: workspaceId, - user: payload.user, - allUsers: [payload.user], - }); - - serverMock.to.mockClear(); - serverMock.emit.mockClear(); - // when + // WHEN await gateway.handleUserJoin(payload, clientMock as Socket); // THEN @@ -217,33 +190,67 @@ describe('WorkspaceGateway', () => { }); }); - describe('user:leave (워크스페이스 퇴장)', () => { - it('유저가 퇴장하면, 퇴장처리 및 관련 리소스(커서, 위젯 잠금)를 해제해야 한다', async () => { - // given + describe('user:leave', () => { + beforeEach(() => { + workspaceServiceMock.leaveUser = jest.fn().mockReturnValue({ + roomId: 'w1', + userId: 'u1', + }); + + serverMock.to = jest.fn().mockReturnValue(serverMock as Server); + clientMock = { + id: 's1', + join: jest.fn().mockResolvedValue(undefined), + leave: jest.fn().mockResolvedValue(undefined), + } as unknown as Partial; + }); + + it('user:leave 이벤트 발생 시 일련의 과정을 거친 후 user:left, user:status 이벤트 발생', async () => { + // GIVEN const payload: LeaveUserDTO = { - workspaceId: workspaceId, - userId: userId, + workspaceId: 'w1', + userId: 'u1', }; - workspaceServiceMock.leaveUser = jest - .fn() - .mockReturnValue({ roomId: workspaceId, userId: userId }); - // when + // WHEN await gateway.handleUserLeave(payload, clientMock as Socket); - // then - expect(workspaceServiceMock.leaveUser).toHaveBeenCalledWith(socketId); - expect(clientMock.leave).toHaveBeenCalledWith(workspaceId); - expect(widgetServiceMock.unlockAllByUser).toHaveBeenCalledWith( - workspaceId, - userId, - ); - expect(serverMock.to).toHaveBeenCalledWith(workspaceId); - expect(serverToEmitMock).toHaveBeenCalledWith('user:status', { - userId, + // THEN + expect(workspaceServiceMock.leaveUser).toHaveBeenCalledWith('s1'); + expect(clientMock.leave).toHaveBeenCalledWith('w1'); + expect(serverMock.emit).toHaveBeenCalledWith('user:left', payload.userId); + expect(serverMock.emit).toHaveBeenCalledWith('user:status', { + userId: 'u1', status: 'OFFLINE', }); - expect(serverToEmitMock).toHaveBeenCalledWith('user:left', userId); + }); + + it('user:leave 이벤트 발생 시 client가 방에서 나가는지', async () => { + // GIVEN + const payload: LeaveUserDTO = { + workspaceId: 'w1', + userId: 'u1', + }; + + // WHEN + await gateway.handleUserLeave(payload, clientMock as Socket); + + // THEN + expect(clientMock.leave).toHaveBeenCalledWith('w1'); + }); + + it('user:leave 이벤트 발생 시 workspaceService의 leaveUser 메서드가 호출되는지', async () => { + // GIVEN + const payload: LeaveUserDTO = { + workspaceId: 'w1', + userId: 'u1', + }; + + // WHEN + await gateway.handleUserLeave(payload, clientMock as Socket); + + // THEN + expect(workspaceServiceMock.leaveUser).toHaveBeenCalledWith('s1'); }); }); }); diff --git a/backend/src/workspace/workspace.gateway.ts b/backend/src/workspace/workspace.gateway.ts index baa65d6d..9e241dcf 100644 --- a/backend/src/workspace/workspace.gateway.ts +++ b/backend/src/workspace/workspace.gateway.ts @@ -9,15 +9,10 @@ import { import { Server, Socket } from 'socket.io'; import { AsyncApiPub, AsyncApiSub } from 'nestjs-asyncapi'; -import { Inject } from '@nestjs/common'; import { JoinUserDTO } from './dto/join-user.dto'; import { LeaveUserDTO } from './dto/left-user.dto'; import { UserStatus, UserStatusDTO } from './dto/user-status.dto'; import { WorkspaceService } from './workspace.service'; -import { - type IWidgetService, - WIDGET_SERVICE, -} from '../widget/widget.interface'; const isProduction = process.env.NODE_ENV === 'production'; const allowedOrigins = isProduction ? process.env.HOST_URL : '*'; @@ -33,31 +28,15 @@ export class WorkspaceGateway implements OnGatewayDisconnect { @WebSocketServer() server: Server; - constructor( - private readonly workspaceService: WorkspaceService, - @Inject(WIDGET_SERVICE) - private readonly widgetService: IWidgetService, - ) {} + constructor(private readonly workspaceService: WorkspaceService) {} - async handleDisconnect(client: Socket) { + handleDisconnect(client: Socket) { const result = this.workspaceService.handleDisconnect(client.id); if (!result) { return; } const { roomId, userId } = result; - const unlockedWidgetIds = await this.widgetService.unlockAllByUser( - roomId, - userId, - ); - - unlockedWidgetIds.forEach((widgetId) => { - this.server.to(roomId).emit('widget:unlocked', { - widgetId, - userId, - }); - }); - this.server.to(roomId).emit('user:status', { userId, status: UserStatus.OFFLINE, @@ -74,7 +53,7 @@ export class WorkspaceGateway implements OnGatewayDisconnect { channel: 'user:join', summary: '워크스페이스 입장', description: - '사용자가 특정 워크스페이스(room)에 처음 접속할 때 클라이언트에서 보내는 이벤트', + '사용자가 특정 워크스페이스(room)에 처음 접속할 때 클라이언트에서 보내는 이벤트입니다.', message: { payload: JoinUserDTO, }, @@ -83,7 +62,7 @@ export class WorkspaceGateway implements OnGatewayDisconnect { channel: 'user:joined', summary: '워크스페이스 유저/커서 정보 브로드캐스트', description: - '새 유저가 입장했을 때, 같은 워크스페이스의 모든 유저와 커서 목록을 브로드캐스트합니다.', + '새 유저가 입장했을 때, 같은 워크스페이스의 모든 유저 목록을 브로드캐스트합니다.', message: { // allUsers, cursors 구조를 간단히 표현하기 위해 DTO 대신 any 사용 payload: Object, @@ -122,7 +101,7 @@ export class WorkspaceGateway implements OnGatewayDisconnect { channel: 'user:leave', summary: '워크스페이스 퇴장', description: - '사용자가 워크스페이스(room)에서 나갈 때 클라이언트에서 보내는 이벤트', + '사용자가 워크스페이스(room)에서 나갈 때 클라이언트에서 보내는 이벤트입니다.', message: { payload: LeaveUserDTO, }, @@ -156,18 +135,6 @@ export class WorkspaceGateway implements OnGatewayDisconnect { } const { roomId, userId } = result; - // 위젯 락 정리 - const unlockedWidgetIds = await this.widgetService.unlockAllByUser( - roomId, - userId, - ); - unlockedWidgetIds.forEach((widgetId) => { - this.server.to(roomId).emit('widget:unlocked', { - widgetId, - userId, - }); - }); - await client.leave(roomId); this.server.to(roomId).emit('user:status', { diff --git a/backend/src/workspace/workspace.module.ts b/backend/src/workspace/workspace.module.ts index 7df154ef..bbf85b8f 100644 --- a/backend/src/workspace/workspace.module.ts +++ b/backend/src/workspace/workspace.module.ts @@ -1,10 +1,8 @@ -import { forwardRef, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { WorkspaceService } from './workspace.service'; import { WorkspaceGateway } from './workspace.gateway'; -import { WidgetModule } from '../widget/widget.module'; @Module({ - imports: [forwardRef(() => WidgetModule)], providers: [WorkspaceService, WorkspaceGateway], exports: [WorkspaceService], }) diff --git a/frontend/package.json b/frontend/package.json index f06a98c5..931d0b1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.17", + "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.561.0", diff --git a/frontend/src/common/api/apiClient.ts b/frontend/src/common/api/apiClient.ts new file mode 100644 index 00000000..b5ff6e3b --- /dev/null +++ b/frontend/src/common/api/apiClient.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const getApiUrl = () => { + return import.meta.env.MODE === 'production' + ? window.location.origin + : 'http://localhost:3000'; +}; + +export const apiClient = axios.create({ + baseURL: getApiUrl() + '/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 기본적인 에러 처리 하기 +apiClient.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + return Promise.reject(error); + }, +); diff --git a/frontend/src/common/api/markdownApi.ts b/frontend/src/common/api/markdownApi.ts new file mode 100644 index 00000000..db8b683b --- /dev/null +++ b/frontend/src/common/api/markdownApi.ts @@ -0,0 +1,19 @@ +import { apiClient } from './apiClient'; + +export interface GetMarkdownResponse { + markdown: string; +} + +export const markdownApi = { + /** + * 워크스페이스 ID로 마크다운 문서를 생성합니다. + * @param workspaceId 워크스페이스 ID + * @returns 생성된 마크다운 문서 + */ + getMarkdown: async (workspaceId: string): Promise => { + const response = await apiClient.get('/markdown', { + params: { workspaceId }, + }); + return response.data.markdown; + }, +}; diff --git a/frontend/src/common/hooks/useMarkdown.ts b/frontend/src/common/hooks/useMarkdown.ts new file mode 100644 index 00000000..2e8b5752 --- /dev/null +++ b/frontend/src/common/hooks/useMarkdown.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from 'react'; +import { markdownApi } from '../api/markdownApi'; + +interface UseMarkdownReturn { + markdown: string; + fetchMarkdown: (workspaceId: string) => Promise; +} + +/** + * 마크다운 문서를 가져오고 관리하는 hook + */ +export const useMarkdown = (): UseMarkdownReturn => { + const [markdown, setMarkdown] = useState(''); + + const fetchMarkdown = useCallback(async (workspaceId: string) => { + const markdownData = await markdownApi.getMarkdown(workspaceId); + setMarkdown(markdownData); + }, []); + + return { + markdown, + fetchMarkdown, + }; +}; diff --git a/frontend/src/pages/workspace/WorkSpacePage.tsx b/frontend/src/pages/workspace/WorkSpacePage.tsx index e25a2eb4..5f082fed 100644 --- a/frontend/src/pages/workspace/WorkSpacePage.tsx +++ b/frontend/src/pages/workspace/WorkSpacePage.tsx @@ -6,6 +6,7 @@ import TechStackModal from '@/features/widgets/techStack/components/modal/TechSt import { getRandomColor } from '@/utils/getRandomColor'; import { useSocket } from '@/common/hooks/useSocket'; +import { useMarkdown } from '@/common/hooks/useMarkdown'; import CanvasContent from '@/features/canvas/CanvasContent'; import ToolBar from '@/pages/workspace/components/toolbar/ToolBar'; import type { Cursor } from '@/common/types/cursor'; @@ -33,6 +34,9 @@ function WorkSpacePage() { const [hoveredUser, setHoveredUser] = useState(null); const [hoverPosition, setHoverPosition] = useState({ top: 0, left: 0 }); + // 마크다운 관리 hook + const { markdown: exportMarkdown, fetchMarkdown } = useMarkdown(); + const { camera, containerRef, @@ -109,6 +113,16 @@ function WorkSpacePage() { setHoveredUser(null); }; + const handleExportClick = useCallback(async () => { + try { + await fetchMarkdown(workspaceId); + setIsExportModalOpen(true); + } catch (error) { + // 일단 alert를 사용했는데, 그냥 마크다운 내용으로 (마크다운 생성 실패)를 보내는 것도 나쁘지 않을 것 같습니다! + alert('마크다운 생성에 실패했습니다.'); + } + }, [workspaceId, fetchMarkdown]); + return (
{/* Hide Scrollbar CSS */} @@ -122,7 +136,7 @@ function WorkSpacePage() { } `} - setIsExportModalOpen(true)} /> + {/* Main Workspace */}
@@ -158,7 +172,7 @@ function WorkSpacePage() { setIsExportModalOpen(false)} - techStack={techStack} + markdown={exportMarkdown} />
); diff --git a/frontend/src/pages/workspace/components/ExportModal.tsx b/frontend/src/pages/workspace/components/ExportModal.tsx index d4bdc109..589e5ee6 100644 --- a/frontend/src/pages/workspace/components/ExportModal.tsx +++ b/frontend/src/pages/workspace/components/ExportModal.tsx @@ -3,28 +3,10 @@ import { LuFileText, LuX, LuCopy, LuCheck } from 'react-icons/lu'; interface ExportModalProps { isOpen: boolean; onClose: () => void; - techStack: Set; + markdown: string; } -function ExportModal({ isOpen, onClose, techStack }: ExportModalProps) { - const generateMarkdown = () => { - const techs = Array.from(techStack) - .map((t) => `| ${t} | vLatest | Selected |`) - .join('\n'); - - return `# 🚀 Project Team Align Report -> Created at: ${new Date().toLocaleString()} - - -## 2. 🛠 Tech Stack Selection -| Tech Name | Version | Status | -| :--- | :--- | :--- | -${techs.length ? techs : '| None | - | - |'} - ---- -*Generated by TeamConfig*`; - }; - +function ExportModal({ isOpen, onClose, markdown }: ExportModalProps) { if (!isOpen) return null; return ( @@ -55,14 +37,14 @@ ${techs.length ? techs : '| None | - | - |'}
-            {generateMarkdown()}
+            {markdown}
           
@@ -75,7 +57,7 @@ ${techs.length ? techs : '| None | - | - |'}