diff --git a/packages/backend/src/common/constants/error.message.constants.ts b/packages/backend/src/common/constants/error.message.constants.ts index 0dff16ea..cbf7e071 100644 --- a/packages/backend/src/common/constants/error.message.constants.ts +++ b/packages/backend/src/common/constants/error.message.constants.ts @@ -7,6 +7,7 @@ export const ERROR_MESSAGES = { UPDATE_FAILED: '스페이스 업데이트에 실패하였습니다.', INITIALIZE_FAILED: '스페이스가 초기화에 실패하였습니다.', PARENT_NOT_FOUND: '부모 스페이스가 존재하지 않습니다.', + DELETE_FAILED: '노트 삭제에 실패하였습니다.', }, NOTE: { BAD_REQUEST: '잘못된 요청입니다.', @@ -14,6 +15,7 @@ export const ERROR_MESSAGES = { CREATION_FAILED: '노트 생성에 실패하였습니다.', UPDATE_FAILED: '노트 업데이트에 실패하였습니다.', INITIALIZE_FAILED: '노트가 초기화에 실패하였습니다.', + DELETE_FAILED: '노트 삭제에 실패하였습니다.', }, SOCKET: { INVALID_URL: '유효하지 않은 URL 주소입니다.', diff --git a/packages/backend/src/note/note.service.ts b/packages/backend/src/note/note.service.ts index a81ef4ad..ddcef3d4 100644 --- a/packages/backend/src/note/note.service.ts +++ b/packages/backend/src/note/note.service.ts @@ -80,4 +80,22 @@ export class NoteService { throw new BadRequestException(ERROR_MESSAGES.NOTE.UPDATE_FAILED); } } + async deleteById(id: string) { + this.logger.log(`ID가 ${id}인 노트를 삭제하는 중입니다.`); + + try { + const result = await this.noteModel.deleteOne({ id }).exec(); + + if (result.deletedCount === 0) { + this.logger.warn(`삭제 실패: ID가 ${id}인 노트를 찾을 수 없습니다.`); + throw new BadRequestException(ERROR_MESSAGES.NOTE.NOT_FOUND); + } + + this.logger.log(`ID가 ${id}인 노트 삭제 완료.`); + return { success: true, message: '노트가 성공적으로 삭제되었습니다.' }; + } catch (error) { + this.logger.error(`ID가 ${id}인 노트 삭제 중 오류 발생.`, error.stack); + throw new BadRequestException(ERROR_MESSAGES.NOTE.DELETE_FAILED); + } + } } diff --git a/packages/backend/src/space/space.controller.spec.ts b/packages/backend/src/space/space.controller.spec.ts new file mode 100644 index 00000000..00df38ff --- /dev/null +++ b/packages/backend/src/space/space.controller.spec.ts @@ -0,0 +1,145 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpaceController } from './space.controller'; +import { SpaceService } from './space.service'; +import { CreateSpaceDto } from './dto/create.space.dto'; +import { HttpException } from '@nestjs/common'; +import { GUEST_USER_ID } from '../common/constants/space.constants'; + +describe('SpaceController', () => { + let spaceController: SpaceController; + let spaceService: Partial; + + beforeEach(async () => { + spaceService = { + existsById: jest.fn(), + getBreadcrumb: jest.fn(), + create: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SpaceController], + providers: [ + { + provide: SpaceService, + useValue: spaceService, + }, + ], + }).compile(); + + spaceController = module.get(SpaceController); + }); + + describe('existsBySpace', () => { + it('스페이스가 존재할 경우 true를 반환해야 한다', async () => { + const spaceId = '123'; + (spaceService.existsById as jest.Mock).mockResolvedValue(true); + + const result = await spaceController.existsBySpace(spaceId); + + expect(spaceService.existsById).toHaveBeenCalledWith(spaceId); + expect(result).toBe(true); + }); + + it('예외가 발생하면 오류를 던져야 한다', async () => { + const spaceId = '123'; + (spaceService.existsById as jest.Mock).mockRejectedValue( + new Error('Unexpected Error'), + ); + + await expect(spaceController.existsBySpace(spaceId)).rejects.toThrow( + 'Unexpected Error', + ); + }); + }); + + describe('getBreadcrumb', () => { + it('주어진 스페이스 ID에 대한 경로를 반환해야 한다', async () => { + const spaceId = '123'; + const breadcrumb = ['Home', 'Space']; + (spaceService.getBreadcrumb as jest.Mock).mockResolvedValue(breadcrumb); + + const result = await spaceController.getBreadcrumb(spaceId); + + expect(spaceService.getBreadcrumb).toHaveBeenCalledWith(spaceId); + expect(result).toEqual(breadcrumb); + }); + }); + + describe('createSpace', () => { + it('스페이스를 생성하고 URL 경로를 반환해야 한다', async () => { + const createSpaceDto: CreateSpaceDto = { + userId: GUEST_USER_ID, + spaceName: 'New Space', + parentContextNodeId: '123', + }; + + const mockSpace = { toObject: () => ({ id: 'space123' }) }; + (spaceService.create as jest.Mock).mockResolvedValue(mockSpace); + + const result = await spaceController.createSpace(createSpaceDto); + + expect(spaceService.create).toHaveBeenCalledWith( + GUEST_USER_ID, + 'New Space', + '123', + ); + expect(result).toEqual({ urlPath: 'space123' }); + }); + + it('잘못된 요청인 경우 400 오류를 던져야 한다', async () => { + const createSpaceDto: CreateSpaceDto = { + userId: 'invalidUser', + spaceName: '', + parentContextNodeId: '123', + }; + + await expect(spaceController.createSpace(createSpaceDto)).rejects.toThrow( + HttpException, + ); + + expect(spaceService.create).not.toHaveBeenCalled(); + }); + + it('스페이스 생성에 실패한 경우 404 오류를 던져야 한다', async () => { + const createSpaceDto: CreateSpaceDto = { + userId: GUEST_USER_ID, + spaceName: 'New Space', + parentContextNodeId: '123', + }; + + (spaceService.create as jest.Mock).mockResolvedValue(null); + + await expect(spaceController.createSpace(createSpaceDto)).rejects.toThrow( + HttpException, + ); + + expect(spaceService.create).toHaveBeenCalledWith( + GUEST_USER_ID, + 'New Space', + '123', + ); + }); + }); + + describe('updateSpace', () => { + it('스페이스가 존재하지 않을 경우 404 오류를 던져야 한다', async () => { + const spaceId = '123'; + (spaceService.existsById as jest.Mock).mockResolvedValue(false); + + await expect(spaceController.updateSpace(spaceId)).rejects.toThrow( + HttpException, + ); + + expect(spaceService.existsById).toHaveBeenCalledWith(spaceId); + }); + + it('스페이스가 존재할 경우 오류를 던지지 않아야 한다', async () => { + const spaceId = '123'; + (spaceService.existsById as jest.Mock).mockResolvedValue(true); + + await expect(spaceController.updateSpace(spaceId)).resolves.not.toThrow(); + + expect(spaceService.existsById).toHaveBeenCalledWith(spaceId); + }); + }); +}); diff --git a/packages/backend/src/space/space.controller.ts b/packages/backend/src/space/space.controller.ts index c3ed3926..f35034c5 100644 --- a/packages/backend/src/space/space.controller.ts +++ b/packages/backend/src/space/space.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpException, HttpStatus, @@ -157,4 +158,27 @@ export class SpaceController { ); } } + @Version('1') + @Delete('/:id') + @ApiOperation({ summary: '스페이스 삭제' }) + @ApiResponse({ status: 201, description: '스페이스 삭제 성공' }) + @ApiResponse({ status: 400, description: '잘못된 요청' }) + async deleteSpace(@Param('id') id: string) { + const result = await this.spaceService.deleteById(id); + + if (!result) { + this.logger.error( + '스페이스 삭제 실패 - 스페이스 삭제에 실패하였습니다.', + { + method: 'deleteSpace', + error: ERROR_MESSAGES.SPACE.DELETE_FAILED, + id, + }, + ); + throw new HttpException( + ERROR_MESSAGES.SPACE.DELETE_FAILED, + HttpStatus.NOT_FOUND, + ); + } + } } diff --git a/packages/backend/src/space/space.module.ts b/packages/backend/src/space/space.module.ts index dd4332e6..1d303833 100644 --- a/packages/backend/src/space/space.module.ts +++ b/packages/backend/src/space/space.module.ts @@ -5,9 +5,11 @@ import { SpaceController } from './space.controller'; import { SpaceDocument, SpaceSchema } from './space.schema'; import { SpaceService } from './space.service'; import { SpaceValidationService } from './space.validation.service'; +import { NoteModule } from 'src/note/note.module'; @Module({ imports: [ + NoteModule, MongooseModule.forFeature([ { name: SpaceDocument.name, schema: SpaceSchema }, ]), diff --git a/packages/backend/src/space/space.service.spec.ts b/packages/backend/src/space/space.service.spec.ts new file mode 100644 index 00000000..6947f574 --- /dev/null +++ b/packages/backend/src/space/space.service.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpaceService } from './space.service'; +import { getModelToken } from '@nestjs/mongoose'; +import { SpaceDocument } from './space.schema'; +import { SpaceValidationService } from './space.validation.service'; +import { Model } from 'mongoose'; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid'), +})); + +describe('SpaceService', () => { + let spaceService: SpaceService; + let spaceModel: Model; + let spaceValidationService: SpaceValidationService; + + beforeEach(async () => { + const mockSpaceModel = { + findOne: jest.fn().mockReturnValue({ + exec: jest.fn(), + }), + findOneAndUpdate: jest.fn().mockReturnValue({ + exec: jest.fn(), + }), + countDocuments: jest.fn(), + create: jest.fn(), + }; + + const mockSpaceValidationService = { + validateSpaceLimit: jest.fn().mockResolvedValue(undefined), + validateParentNodeExists: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SpaceService, + { + provide: getModelToken(SpaceDocument.name), + useValue: mockSpaceModel, + }, + { + provide: SpaceValidationService, + useValue: mockSpaceValidationService, + }, + ], + }).compile(); + + spaceService = module.get(SpaceService); + spaceModel = module.get>( + getModelToken(SpaceDocument.name), + ); + spaceValidationService = module.get( + SpaceValidationService, + ); + }); + + describe('getBreadcrumb', () => { + it('스페이스의 경로를 반환해야 한다', async () => { + const mockSpaces = [ + { id: 'parent-id', name: 'Parent Space', parentSpaceId: null }, + { id: '123', name: 'Child Space', parentSpaceId: 'parent-id' }, + ]; + + (spaceModel.findOne as jest.Mock) + .mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(mockSpaces[1]), + }) + .mockReturnValueOnce({ + exec: jest.fn().mockResolvedValue(mockSpaces[0]), + }); + + const result = await spaceService.getBreadcrumb('123'); + + expect(spaceModel.findOne).toHaveBeenCalledWith({ id: '123' }); + expect(spaceModel.findOne).toHaveBeenCalledWith({ id: 'parent-id' }); + expect(result).toEqual([ + { name: 'Parent Space', url: 'parent-id' }, + { name: 'Child Space', url: '123' }, + ]); + }); + }); +}); diff --git a/packages/backend/src/space/space.service.ts b/packages/backend/src/space/space.service.ts index 75859f91..a710983e 100644 --- a/packages/backend/src/space/space.service.ts +++ b/packages/backend/src/space/space.service.ts @@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid'; import { SpaceDocument } from './space.schema'; import { SpaceValidationService } from './space.validation.service'; +import { NoteService } from 'src/note/note.service'; @Injectable() export class SpaceService { @@ -13,6 +14,7 @@ export class SpaceService { constructor( private readonly spaceValidationService: SpaceValidationService, + private readonly noteService: NoteService, @InjectModel(SpaceDocument.name) private readonly spaceModel: Model, ) {} @@ -115,4 +117,71 @@ export class SpaceService { return breadcrumb; } + + async deleteById(id: string) { + this.logger.log(`ID가 ${id}인 스페이스와 그 하위 노드를 삭제합니다.`); + + const space = await this.spaceModel.findOne({ id }).exec(); + + if (!space) { + this.logger.warn(`삭제 실패: ID가 ${id}인 스페이스를 찾을 수 없습니다.`); + throw new Error(`ID가 ${id}인 스페이스를 찾을 수 없습니다.`); + } + + let nodes: Record; + try { + nodes = JSON.parse(space.nodes); + this.logger.debug(`노드 데이터 확인: ${JSON.stringify(nodes)}`); + } catch (error) { + this.logger.error(`노드 데이터 파싱 실패 - ID: ${id}`, error.stack); + throw new Error('노드 데이터 파싱 중 오류가 발생했습니다.'); + } + + for (const nodeId in nodes) { + const node = nodes[nodeId]; + this.logger.debug( + `노드 순회 - ID: ${nodeId}, 데이터: ${JSON.stringify(node)}`, + ); + + switch (node.type) { + case 'note': + this.logger.log(`노트 노드 삭제 - ID: ${node.id}`); + await this.noteService.deleteById(node.id); + break; + + case 'subspace': + if (!node.src) { + this.logger.warn( + `서브스페이스 노드에 src가 없습니다 - ID: ${node.id}`, + ); + continue; + } + this.logger.log( + `서브스페이스 노드 삭제 - ID: ${node.id}, 하위 스페이스 ID: ${node.src}`, + ); + await this.deleteById(node.src); + break; + + default: + this.logger.log( + `노드 ID: ${node.id}는 특별한 삭제 작업이 필요하지 않습니다.`, + ); + break; + } + } + + this.logger.log(`스페이스 삭제 - ID: ${id}`); + const result = await this.spaceModel.deleteOne({ id }).exec(); + + if (result.deletedCount === 0) { + this.logger.warn(`스페이스 삭제 실패 - ID: ${id}`); + throw new Error(`ID가 ${id}인 스페이스를 삭제할 수 없습니다.`); + } + + this.logger.log(`ID가 ${id}인 스페이스 및 하위 노드 삭제 완료.`); + return { + success: true, + message: '스페이스와 하위 노드가 성공적으로 삭제되었습니다.', + }; + } } diff --git a/packages/backend/src/space/space.validation.service.spec.ts b/packages/backend/src/space/space.validation.service.spec.ts new file mode 100644 index 00000000..74e1753a --- /dev/null +++ b/packages/backend/src/space/space.validation.service.spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpaceValidationService } from './space.validation.service'; +import { getModelToken } from '@nestjs/mongoose'; +import { SpaceDocument } from './space.schema'; +import { Model } from 'mongoose'; +import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; +import { MAX_SPACES } from '../common/constants/space.constants'; + +describe('SpaceValidationService', () => { + let spaceValidationService: SpaceValidationService; + let spaceModel: Model; + + beforeEach(async () => { + const mockSpaceModel = { + countDocuments: jest.fn(), + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SpaceValidationService, + { + provide: getModelToken(SpaceDocument.name), + useValue: mockSpaceModel, + }, + ], + }).compile(); + + spaceValidationService = module.get( + SpaceValidationService, + ); + spaceModel = module.get>( + getModelToken(SpaceDocument.name), + ); + }); + + describe('validateSpaceLimit', () => { + it('스페이스가 최대 제한을 초과하면 예외를 던져야 한다', async () => { + (spaceModel.countDocuments as jest.Mock).mockResolvedValue(MAX_SPACES); + + await expect( + spaceValidationService.validateSpaceLimit('user123'), + ).rejects.toThrow(ERROR_MESSAGES.SPACE.LIMIT_EXCEEDED); + + expect(spaceModel.countDocuments).toHaveBeenCalledWith({ + userId: 'user123', + }); + }); + + it('스페이스가 최대 제한을 초과하지 않으면 예외를 던지지 않아야 한다', async () => { + (spaceModel.countDocuments as jest.Mock).mockResolvedValue( + MAX_SPACES - 1, + ); + + await expect( + spaceValidationService.validateSpaceLimit('user123'), + ).resolves.not.toThrow(); + + expect(spaceModel.countDocuments).toHaveBeenCalledWith({ + userId: 'user123', + }); + }); + }); + + describe('validateParentNodeExists', () => { + it('parentContextNodeId가 없으면 예외를 던지지 않아야 한다', async () => { + await expect( + spaceValidationService.validateParentNodeExists(null), + ).resolves.not.toThrow(); + + expect(spaceModel.findOne).not.toHaveBeenCalled(); + }); + + it('parentContextNodeId가 존재하지만 스페이스를 찾지 못하면 예외를 던져야 한다', async () => { + (spaceModel.findOne as jest.Mock).mockResolvedValue(null); + + await expect( + spaceValidationService.validateParentNodeExists('parent-id'), + ).rejects.toThrow(ERROR_MESSAGES.SPACE.PARENT_NOT_FOUND); + + expect(spaceModel.findOne).toHaveBeenCalledWith({ + id: 'parent-id', + }); + }); + + it('parentContextNodeId가 존재하고 스페이스를 찾으면 예외를 던지지 않아야 한다', async () => { + (spaceModel.findOne as jest.Mock).mockResolvedValue({ + id: 'parent-id', + }); + + await expect( + spaceValidationService.validateParentNodeExists('parent-id'), + ).resolves.not.toThrow(); + + expect(spaceModel.findOne).toHaveBeenCalledWith({ + id: 'parent-id', + }); + }); + }); + + describe('validateSpaceExists', () => { + it('urlPath에 해당하는 스페이스가 없으면 예외를 던져야 한다', async () => { + (spaceModel.findOne as jest.Mock).mockResolvedValue(null); + + await expect( + spaceValidationService.validateSpaceExists('test-path'), + ).rejects.toThrow(ERROR_MESSAGES.SPACE.NOT_FOUND); + + expect(spaceModel.findOne).toHaveBeenCalledWith({ + urlPath: 'test-path', + }); + }); + + it('urlPath에 해당하는 스페이스가 있으면 예외를 던지지 않고 해당 스페이스를 반환해야 한다', async () => { + const mockSpace = { id: 'space-id', name: 'Test Space' }; + (spaceModel.findOne as jest.Mock).mockResolvedValue(mockSpace); + + const result = + await spaceValidationService.validateSpaceExists('test-path'); + + expect(spaceModel.findOne).toHaveBeenCalledWith({ + urlPath: 'test-path', + }); + expect(result).toEqual(mockSpace); + }); + }); +}); diff --git a/packages/backend/src/test/mock/note.mock.data.ts b/packages/backend/src/test/mock/note.mock.data.ts index 50874944..f1754919 100644 --- a/packages/backend/src/test/mock/note.mock.data.ts +++ b/packages/backend/src/test/mock/note.mock.data.ts @@ -6,7 +6,7 @@ export const noteMockData: MockNote[] = [ userId: 'test-user-id', name: 'test-name', content: - 'AoIB+Ia//AoABwDujav1AwAGAQD4hr/8CgAEhPiGv/wKBAPthYyB+Ia//AoFAoT4hr/8CgcG7Iqk7Yq4h+6Nq/UDAAMJcGFyYWdyYXBoBwD4hr/8CgoGAQD4hr/8CgsDgfiGv/wKCgEABIH4hr/8Cg8BgfiGv/wKDgKE+Ia//AoWA+yXhIH4hr/8ChcChPiGv/wKGQPssq2B+Ia//AoaA4T4hr/8Ch0H64KY6rKMIIH4hr/8CiAChPiGv/wKIgTquLQggfiGv/wKJASE+Ia//AooA+usuIH4hr/8CikChPiGv/wKKwPsnpCB+Ia//AosAYT4hr/8Ci0D7Je0gfiGv/wKLgGE+Ia//AovA...', // Base64 encoded content + 'AoIB+Ia//AoABwDujav1AwAGAQD4hr/8CgAEhPiGv/wKBAPthYyB+Ia//AoFAoT4hr/8CgcG7Iqk7Yq4h+6Nq/UDAAMJcGFyYWdyYXBoBwD4hr/8CgoGAQD4hr/8CgsDgfiGv/wKCgEABIH4hr/8Cg8BgfiGv/wKDgKE+Ia//AoWA+yXhIH4hr/8ChcChPiGv/wKGQPssq2B+Ia//AoaA4T4hr/8Ch0H64KY6rKMIIH4hr/8CiAChPiGv/wKIgTquLQggfiGv/wKJASE+Ia//AooA+usuIH4hr/8CikChPiGv/wKKwPsnpCB+Ia//AosAYT4hr/8Ci0D7Je0gfiGv/wKLgGE+Ia//AovA...', createdAt: new Date(), updatedAt: new Date(), }, diff --git a/packages/backend/src/yjs/yjs.gateway.spec.ts b/packages/backend/src/yjs/yjs.gateway.spec.ts new file mode 100644 index 00000000..de5ef8aa --- /dev/null +++ b/packages/backend/src/yjs/yjs.gateway.spec.ts @@ -0,0 +1,90 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { YjsGateway } from './yjs.gateway'; +import { CollaborativeService } from '../collaborative/collaborative.service'; +import { WebSocket } from 'ws'; +import { Request } from 'express'; +import { ERROR_MESSAGES } from '../common/constants/error.message.constants'; +import { WebsocketStatus } from '../common/constants/websocket.constants'; + +describe('YjsGateway', () => { + let gateway: YjsGateway; + let collaborativeService: CollaborativeService; + + beforeEach(async () => { + const mockCollaborativeService = { + findByNote: jest.fn(), + findBySpace: jest.fn(), + updateByNote: jest.fn(), + updateBySpace: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + YjsGateway, + { + provide: CollaborativeService, + useValue: mockCollaborativeService, + }, + ], + }).compile(); + + gateway = module.get(YjsGateway); + collaborativeService = + module.get(CollaborativeService); + }); + + describe('handleConnection', () => { + it('유효하지 않은 URL로 WebSocket 연결을 닫아야 한다', async () => { + const connection = { + close: jest.fn(), + } as unknown as WebSocket; + + const request = { + url: '/invalid-url', + } as Request; + + await gateway.handleConnection(connection, request); + + expect(connection.close).toHaveBeenCalledWith( + WebsocketStatus.POLICY_VIOLATION, + ERROR_MESSAGES.SOCKET.INVALID_URL, + ); + }); + + it('유효한 노트 URL로 WebSocket 연결을 초기화해야 한다', async () => { + const connection = { + close: jest.fn(), + } as unknown as WebSocket; + + const request = { + url: '/note/123', + } as Request; + + (collaborativeService.findByNote as jest.Mock).mockResolvedValue({ + id: '123', + }); + + await gateway.handleConnection(connection, request); + + expect(collaborativeService.findByNote).toHaveBeenCalledWith('123'); + }); + + it('유효한 스페이스 URL로 WebSocket 연결을 초기화해야 한다', async () => { + const connection = { + close: jest.fn(), + } as unknown as WebSocket; + + const request = { + url: '/space/123', + } as Request; + + (collaborativeService.findBySpace as jest.Mock).mockResolvedValue({ + id: '123', + }); + + await gateway.handleConnection(connection, request); + + expect(collaborativeService.findBySpace).toHaveBeenCalledWith('123'); + }); + }); +}); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 93780a8c..95202e24 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", diff --git a/packages/frontend/src/api/http.ts b/packages/frontend/src/api/http.ts index 60f96551..f1db6d4f 100644 --- a/packages/frontend/src/api/http.ts +++ b/packages/frontend/src/api/http.ts @@ -32,7 +32,7 @@ async function http( } // json으로 한정 - const data = (await response.json()) as T; + const data = config.method === "DELETE" ? null : await response.json(); return { data, diff --git a/packages/frontend/src/api/space.ts b/packages/frontend/src/api/space.ts index 4f926860..39d9b570 100644 --- a/packages/frontend/src/api/space.ts +++ b/packages/frontend/src/api/space.ts @@ -29,3 +29,8 @@ export async function getBreadcrumbOfSpace(spaceUrlPath: string) { ); return response.data; } + +export async function deleteSpace(spaceId: string) { + const response = await http.delete(`${API_V1_URL}/space/${spaceId}`); + return response; +} diff --git a/packages/frontend/src/components/Edge.tsx b/packages/frontend/src/components/Edge.tsx index 413538fa..5608d5ff 100644 --- a/packages/frontend/src/components/Edge.tsx +++ b/packages/frontend/src/components/Edge.tsx @@ -1,11 +1,70 @@ import { useEffect, useState } from "react"; -import { Group, Line } from "react-konva"; +import { Circle, Group, Line, Text } from "react-konva"; import Konva from "konva"; +import { KonvaEventObject } from "konva/lib/Node"; import type { Edge } from "shared/types"; type EdgeProps = Edge & Konva.LineConfig; +const BUTTON_RADIUS = 12; + +type EdgeEditButtonProps = { + points: number[]; + onTap: (edgeId: string) => void; +}; + +function EdgeEditButton({ points, onTap }: EdgeEditButtonProps) { + const [isTouch, setIsTouch] = useState(false); + + useEffect(() => { + setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + if (!isTouch || points.length < 4) return null; + + const handleTap = (e: KonvaEventObject) => { + const targetGroup = e.target + .findAncestor("Group") + .findAncestor("Group") as Konva.Group; + + if (!targetGroup) return; + + const targetEdge = targetGroup.children.find( + (konvaNode) => konvaNode.attrs.name === "edge", + ); + + if (!targetEdge) return; + + onTap(targetEdge.attrs.id); + }; + + const middleX = (points[0] + points[2]) / 2; + const middleY = (points[1] + points[3]) / 2; + + return ( + + + + + ); +} + function calculateOffsets( from: { x: number; y: number }, to: { x: number; y: number }, @@ -26,6 +85,7 @@ export default function Edge({ to, id, onContextMenu, + onDelete, ...rest }: EdgeProps) { const [points, setPoints] = useState([]); @@ -66,6 +126,7 @@ export default function Edge({ name="edge" id={id} /> + ); } diff --git a/packages/frontend/src/components/Node.tsx b/packages/frontend/src/components/Node.tsx index dc60972c..03f748ce 100644 --- a/packages/frontend/src/components/Node.tsx +++ b/packages/frontend/src/components/Node.tsx @@ -3,9 +3,15 @@ import { Circle, Group, KonvaNodeEvents, Text } from "react-konva"; import { useNavigate } from "react-router-dom"; import Konva from "konva"; +import { + KonvaEventObject, + Node as KonvaNode, + NodeConfig, +} from "konva/lib/Node"; import { Vector2d } from "konva/lib/types"; const RADIUS = 64; +const MORE_BUTTON_RADIUS = 12; type NodeProps = { id: string; @@ -72,6 +78,7 @@ Node.Text = function NodeText({ fontSize, fontStyle, width, + ...rest }: NodeTextProps) { const ref = useRef(null); const [offset, setOffset] = useState(undefined); @@ -95,10 +102,76 @@ Node.Text = function NodeText({ align="center" wrap="none" ellipsis + {...rest} /> ); }; +type NodeMoreButtonProps = { + onTap?: + | ((evt: KonvaEventObject>) => void) + | undefined; + content: string; +}; + +Node.MoreButton = function NodeMoreButton({ content }: NodeMoreButtonProps) { + const [isTouch, setIsTouch] = useState(false); + + useEffect(() => { + setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + if (!isTouch) return null; + + const handleTap = (e: KonvaEventObject) => { + e.cancelBubble = true; + + const parentNode = e.target.findAncestor("Group").findAncestor("Group"); + + if (!parentNode) return; + + const absolutePosition = parentNode.getAbsolutePosition(); + + const contextMenuEvent = new MouseEvent("contextmenu", { + button: 2, + buttons: 2, + clientX: absolutePosition.x, + clientY: absolutePosition.y, + bubbles: true, + }); + + parentNode.fire("contextmenu", { + evt: contextMenuEvent, + target: parentNode, + }); + }; + + return ( + + + + + ); +}; + export type HeadNodeProps = { id: string; name: string; @@ -126,7 +199,15 @@ export type NoteNodeProps = { name: string; } & NodeHandlers; -export function NoteNode({ id, x, y, name, src, ...rest }: NoteNodeProps) { +export function NoteNode({ + id, + x, + y, + name, + src, + onContextMenu, + ...rest +}: NoteNodeProps) { // TODO: src 적용 필요 const navigate = useNavigate(); return ( @@ -139,10 +220,12 @@ export function NoteNode({ id, x, y, name, src, ...rest }: NoteNodeProps) { navigate(`/note/${src}`); } }} + onContextMenu={onContextMenu} {...rest} > + ); } @@ -161,6 +244,7 @@ export function SubspaceNode({ y, name, src, + onContextMenu, ...rest }: SubspaceNodeProps) { const navigate = useNavigate(); @@ -171,10 +255,17 @@ export function SubspaceNode({ x={x} y={y} onClick={() => navigate(`/space/${src}`)} + onContextMenu={onContextMenu} {...rest} > - + + ); } diff --git a/packages/frontend/src/components/space/InteractionGuide.tsx b/packages/frontend/src/components/space/InteractionGuide.tsx new file mode 100644 index 00000000..63f687fc --- /dev/null +++ b/packages/frontend/src/components/space/InteractionGuide.tsx @@ -0,0 +1,63 @@ +import { InfoIcon } from "lucide-react"; + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; + +const interactions = [ + { + id: "node-drag", + title: "새로운 노드 생성", + description: "노드 드래그", + }, + { + id: "node-move", + title: "노드 이동", + description: "클릭 + 0.5초 이상 홀드", + }, + { + id: "screen-move", + title: "화면 이동", + description: "스페이스 빈 공간 클릭 후 드래그", + }, + { + id: "screen-zoom", + title: "화면 줌", + description: "ctrl + 마우스 휠 또는 트랙패드 제스처", + }, + { + id: "node-edit", + title: "노드 편집", + description: "노드 위에서 우클릭", + }, + { + id: "edge-edit", + title: "간선 편집", + description: "간선 위에서 우클릭", + }, +]; + +export default function InteractionGuide() { + return ( + + + + + +
+

상호작용 가이드 🐝

+
    + {interactions.map((interaction) => ( +
  • + {interaction.title}:  + {interaction.description} +
  • + ))} +
+
+
+
+ ); +} diff --git a/packages/frontend/src/components/space/SpaceView.tsx b/packages/frontend/src/components/space/SpaceView.tsx index 7b550e93..421665e4 100644 --- a/packages/frontend/src/components/space/SpaceView.tsx +++ b/packages/frontend/src/components/space/SpaceView.tsx @@ -146,7 +146,7 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { }; }, [autofitTo]); - const handleContextMenu = (e: KonvaEventObject) => { + const handleContextMenu = (e: KonvaEventObject) => { clearSelection(); const { target } = e; @@ -159,7 +159,9 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { return; } - const group = target.findAncestor("Group"); + // Mobile 환경에서는 group을 대상으로 임의로 이벤트 발생시킴 + const group = + target instanceof Konva.Group ? target : target.findAncestor("Group"); const nodeId = group?.attrs?.id as string | undefined; @@ -176,7 +178,7 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { ) return; - selectNode({ id: nodeId, type: node.type }); + selectNode({ id: nodeId, type: node.type, src: node.src }); }; const handleHover = (e: KonvaEventObject) => { @@ -299,6 +301,7 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { to={edge.to} nodes={nodes} onContextMenu={handleContextMenu} + onDelete={deleteEdge} /> )); diff --git a/packages/frontend/src/components/space/context-menu/SpaceContextMenuWrapper.tsx b/packages/frontend/src/components/space/context-menu/SpaceContextMenuWrapper.tsx index 878067af..79c262f0 100644 --- a/packages/frontend/src/components/space/context-menu/SpaceContextMenuWrapper.tsx +++ b/packages/frontend/src/components/space/context-menu/SpaceContextMenuWrapper.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { deleteSpace } from "@/api/space"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { prompt } from "@/lib/prompt-dialog"; @@ -51,7 +52,19 @@ export default function SpaceContextMenuWrapper({ }, { label: "제거", - action: () => { + action: async () => { + // 서브스페이스인 경우 스페이스도 함께 삭제 + console.log(selectedNode); + if (selectedNode.type === "subspace" && selectedNode.src) { + try { + const result = await deleteSpace(selectedNode.src); + console.log(result); + } catch (error) { + console.error("스페이스 삭제 실패:", error); + } + } + + // 노드 삭제 onNodeDelete(selectedNode.id); clearSelection(); }, diff --git a/packages/frontend/src/components/space/context-menu/type.ts b/packages/frontend/src/components/space/context-menu/type.ts index 169aaec4..ad5dd567 100644 --- a/packages/frontend/src/components/space/context-menu/type.ts +++ b/packages/frontend/src/components/space/context-menu/type.ts @@ -8,6 +8,7 @@ export type ContextMenuItemConfig = { export type SelectedNodeInfo = { id: string; type: Exclude; + src?: string | undefined; }; export type SelectedEdgeInfo = { diff --git a/packages/frontend/src/components/ui/hover-card.tsx b/packages/frontend/src/components/ui/hover-card.tsx new file mode 100644 index 00000000..4438f7a4 --- /dev/null +++ b/packages/frontend/src/components/ui/hover-card.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; + +import { cn } from "@/lib/utils"; + +const HoverCard = HoverCardPrimitive.Root; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/packages/frontend/src/hooks/useMoveNode.ts b/packages/frontend/src/hooks/useMoveNode.ts index 4f0ee6d4..b9b52e15 100644 --- a/packages/frontend/src/hooks/useMoveNode.ts +++ b/packages/frontend/src/hooks/useMoveNode.ts @@ -64,6 +64,11 @@ export default function useMoveNode({ nodes, spaceActions }: useMoveNodeProps) { // onMouseDown, onTouchStart const startHold = (node: Node, e: KonvaInteractionEvent) => { + // 우클릭으로 이벤트가 발생했을 경우 이동모드 활성화 방지 + if (e.evt instanceof MouseEvent && e.evt.button === 2) { + return; + } + setMoveState((prev) => ({ ...prev, isHolding: true, diff --git a/packages/frontend/src/hooks/useSpaceSelection.ts b/packages/frontend/src/hooks/useSpaceSelection.ts index 05c5970e..7212e9ef 100644 --- a/packages/frontend/src/hooks/useSpaceSelection.ts +++ b/packages/frontend/src/hooks/useSpaceSelection.ts @@ -11,10 +11,11 @@ export default function useSpaceSelection() { null, ); - const selectNode = ({ id, type }: SelectedNodeInfo) => { + const selectNode = ({ id, type, src }: SelectedNodeInfo) => { setSelectNode({ id, type: type || null, + src, }); setSelectedEdge(null); }; diff --git a/packages/frontend/src/pages/Space.tsx b/packages/frontend/src/pages/Space.tsx index 37e615e1..3bf591c7 100644 --- a/packages/frontend/src/pages/Space.tsx +++ b/packages/frontend/src/pages/Space.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { CircleDashedIcon, MoveLeftIcon } from "lucide-react"; import ErrorSection from "@/components/ErrorSection"; +import InteractionGuide from "@/components/space/InteractionGuide"; import SpacePageHeader from "@/components/space/SpacePageHeader"; import SpaceView from "@/components/space/SpaceView"; import { Button } from "@/components/ui/button"; @@ -44,7 +45,7 @@ export default function SpacePage() { return ( -
+
{status === "connecting" ? (
@@ -53,6 +54,14 @@ export default function SpacePage() { )} +
+ +
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b427ac15..e02b9000 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 9.14.0(jiti@1.21.6) eslint-config-airbnb-base: specifier: ^15.0.0 - version: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)) + version: 15.0.0(eslint-plugin-import@2.31.0(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)) eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@9.14.0(jiti@1.21.6)) @@ -58,7 +58,7 @@ importers: version: 8.16.2 '@nestjs/cache-manager': specifier: ^2.3.0 - version: 2.3.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(cache-manager@5.7.6)(rxjs@7.8.1) + version: 2.3.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(cache-manager@5.7.6)(rxjs@7.8.1) '@nestjs/common': specifier: ^10.4.7 version: 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -73,7 +73,7 @@ importers: version: 10.0.2(@elastic/elasticsearch@8.16.2)(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) '@nestjs/mongoose': specifier: ^10.1.0 - version: 10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(mongoose@8.8.1)(rxjs@7.8.1) + version: 10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(mongoose@8.8.1)(rxjs@7.8.1) '@nestjs/platform-express': specifier: ^10.0.0 version: 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7) @@ -82,19 +82,19 @@ importers: version: 10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.8)(rxjs@7.8.1) '@nestjs/platform-ws': specifier: ^10.4.8 - version: 10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.8)(rxjs@7.8.1) + version: 10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/platform-socket.io@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) '@nestjs/schedule': specifier: ^4.1.1 - version: 4.1.1(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7) + version: 4.1.1(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/swagger': specifier: ^8.0.7 - version: 8.0.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2) + version: 8.0.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^10.2.3 - version: 10.2.3(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/mongoose@10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(mongoose@8.8.1)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))))(mongoose@8.8.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) + version: 10.2.3(tcghqeflqipsuk3fmvezs7zdze) '@nestjs/typeorm': specifier: ^10.0.2 - version: 10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) + version: 10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) '@nestjs/websockets': specifier: ^10.4.8 version: 10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/platform-socket.io@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -164,7 +164,7 @@ importers: version: 10.2.3(chokidar@3.6.0)(typescript@5.6.3) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/platform-express@10.4.7) + version: 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)) '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -255,6 +255,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.2 version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-hover-card': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2103,6 +2106,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-hover-card@1.1.2': + resolution: {integrity: sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.0': resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} peerDependencies: @@ -9087,7 +9103,7 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 - '@nestjs/cache-manager@2.3.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(cache-manager@5.7.6)(rxjs@7.8.1)': + '@nestjs/cache-manager@2.3.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(cache-manager@5.7.6)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9164,7 +9180,7 @@ snapshots: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: 0.2.2 - '@nestjs/mongoose@10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(mongoose@8.8.1)(rxjs@7.8.1)': + '@nestjs/mongoose@10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(mongoose@8.8.1)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9195,7 +9211,7 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/platform-ws@10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.8)(rxjs@7.8.1)': + '@nestjs/platform-ws@10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/platform-socket.io@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/websockets': 10.4.8(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/platform-socket.io@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9206,7 +9222,7 @@ snapshots: - bufferutil - utf-8-validate - '@nestjs/schedule@4.1.1(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)': + '@nestjs/schedule@4.1.1(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9224,7 +9240,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@8.0.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2)': + '@nestjs/swagger@8.0.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.0 '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9236,7 +9252,7 @@ snapshots: reflect-metadata: 0.2.2 swagger-ui-dist: 5.18.2 - '@nestjs/terminus@10.2.3(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/mongoose@10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(mongoose@8.8.1)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))))(mongoose@8.8.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)))': + '@nestjs/terminus@10.2.3(tcghqeflqipsuk3fmvezs7zdze)': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9245,12 +9261,12 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.1 optionalDependencies: - '@nestjs/mongoose': 10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(mongoose@8.8.1)(rxjs@7.8.1) - '@nestjs/typeorm': 10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) + '@nestjs/mongoose': 10.1.0(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(mongoose@8.8.1)(rxjs@7.8.1) + '@nestjs/typeorm': 10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3))) mongoose: 8.8.1 typeorm: 0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) - '@nestjs/testing@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(@nestjs/platform-express@10.4.7)': + '@nestjs/testing@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7))': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9258,7 +9274,7 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7) - '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)))': + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.11.4)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)))': dependencies: '@nestjs/common': 10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.7(@nestjs/common@10.4.7(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.7)(@nestjs/websockets@10.4.8)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -9454,6 +9470,23 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-hover-card@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-id@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -11683,11 +11716,11 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)): dependencies: confusing-browser-globals: 1.0.11 eslint: 9.14.0(jiti@1.21.6) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0(jiti@1.21.6)) object.assign: 4.1.5 object.entries: 1.1.8 semver: 6.3.1 @@ -11710,7 +11743,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 9.14.0(jiti@1.21.6) - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0(jiti@1.21.6)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -11723,7 +11756,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0(jiti@1.21.6)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)): dependencies: debug: 3.2.7 optionalDependencies: @@ -11745,36 +11778,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.14.0(jiti@1.21.6) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0(jiti@1.21.6)) - hasown: 2.0.2 - is-core-module: 2.15.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.0 - semver: 6.3.1 - string.prototype.trimend: 1.0.8 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.14.0(jiti@1.21.6)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.5 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.14.0(jiti@1.21.6) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.14.0(jiti@1.21.6)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.14.0(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.14.0(jiti@1.21.6)))(eslint@9.14.0(jiti@1.21.6)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3