diff --git a/apps/server/src/modules/board/board-ws-api.module.ts b/apps/server/src/modules/board/board-ws-api.module.ts index c10e7a9adf9..6955e56e43f 100644 --- a/apps/server/src/modules/board/board-ws-api.module.ts +++ b/apps/server/src/modules/board/board-ws-api.module.ts @@ -4,11 +4,11 @@ import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '../authorization'; import { BoardModule } from './board.module'; import { BoardCollaborationGateway } from './gateway/board-collaboration.gateway'; -import { BoardUc, CardUc, ColumnUc } from './uc'; +import { BoardUc, CardUc, ColumnUc, ElementUc } from './uc'; @Module({ imports: [BoardModule, forwardRef(() => AuthorizationModule), LoggerModule], - providers: [BoardCollaborationGateway, CardUc, ColumnUc, BoardUc, CourseRepo], + providers: [BoardCollaborationGateway, CardUc, ColumnUc, ElementUc, BoardUc, CourseRepo], exports: [], }) export class BoardWsApiModule {} diff --git a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts index 3c4f5857917..700b75ac950 100644 --- a/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/element/update-element-content.body.params.ts @@ -150,7 +150,6 @@ export class UpdateElementContentBodyParams { { value: RichTextElementContentBody, name: ContentElementType.RICH_TEXT }, { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, - { value: ExternalToolElementContentBody, name: ContentElementType.DRAWING }, { value: DrawingElementContentBody, name: ContentElementType.DRAWING }, ], }, diff --git a/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts b/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts index 0859914792c..d7ccf66a707 100644 --- a/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts +++ b/apps/server/src/modules/board/gateway/api-test/board-collaboration.gateway.spec.ts @@ -1,14 +1,16 @@ -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { BoardExternalReferenceType, CardProps } from '@shared/domain/domainobject'; +import { BoardExternalReferenceType, CardProps, ContentElementType } from '@shared/domain/domainobject'; +import { InputFormat } from '@shared/domain/types'; import { cardNodeFactory, cleanupCollections, columnBoardNodeFactory, columnNodeFactory, courseFactory, + richTextElementNodeFactory, userFactory, } from '@shared/testing'; import { getSocketApiClient, waitForEvent } from '@shared/testing/test-socket-api-client'; @@ -55,19 +57,31 @@ describe(BoardCollaborationGateway.name, () => { const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); const columnNode2 = columnNodeFactory.buildWithId({ parent: columnBoardNode }); - const cardNodes = cardNodeFactory.buildList(2, { parent: columnNode }); + const cardNodes = cardNodeFactory.buildListWithId(2, { parent: columnNode }); + const elementNodes = richTextElementNodeFactory.buildListWithId(3, { parent: cardNodes[0] }); - await em.persistAndFlush([columnBoardNode, columnNode, columnNode2, ...cardNodes]); + await em.persistAndFlush([columnBoardNode, columnNode, columnNode2, ...cardNodes, ...elementNodes]); em.clear(); - return { user, columnBoardNode, columnNode, columnNode2, cardNodes }; + return { user, columnBoardNode, columnNode, columnNode2, cardNodes, elementNodes }; }; it('should be defined', () => { expect(ws).toBeDefined(); }); + describe('validation errors', () => { + it('should answer with failure', async () => { + await setup(); + ioClient.emit('create-card-request', { columnId: 'invalid' }); + + const failure = await waitForEvent(ioClient, 'exception'); + + expect(failure).toBeDefined(); + }); + }); + describe('create card', () => { describe('when column exists', () => { it('should answer with new card', async () => { @@ -87,7 +101,7 @@ describe(BoardCollaborationGateway.name, () => { it('should answer with failure', async () => { await setup(); - ioClient.emit('create-card-request', { columnId: 'non-existing-column' }); + ioClient.emit('create-card-request', { columnId: new ObjectId().toHexString() }); const failure = await waitForEvent(ioClient, 'create-card-failure'); expect(failure).toBeDefined(); @@ -111,7 +125,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when board does not exist', () => { it('should answer with failure', async () => { await setup(); - const boardId = 'non-existing-id'; + const boardId = new ObjectId().toHexString(); ioClient.emit('fetch-board-request', { boardId }); const failure = await waitForEvent(ioClient, 'fetch-board-failure'); @@ -131,6 +145,8 @@ describe(BoardCollaborationGateway.name, () => { oldIndex: 0, newIndex: 0, fromColumnId: columnNode.id, + fromColumnIndex: 0, + toColumnIndex: 1, toColumnId: columnNode2.id, }; @@ -150,6 +166,8 @@ describe(BoardCollaborationGateway.name, () => { oldIndex: 0, newIndex: 1, fromColumnId: columnNode.id, + fromColumnIndex: 0, + toColumnIndex: 0, toColumnId: columnNode.id, }; @@ -165,11 +183,13 @@ describe(BoardCollaborationGateway.name, () => { const { columnNode } = await setup(); const moveCardProps = { - cardId: 'non-existing-card', + cardId: new ObjectId().toHexString(), oldIndex: 0, newIndex: 1, fromColumnId: columnNode.id, + fromColumnIndex: 0, toColumnId: columnNode.id, + toColumnIndex: 0, }; ioClient.emit('move-card-request', moveCardProps); @@ -196,7 +216,7 @@ describe(BoardCollaborationGateway.name, () => { it('should answer with failure', async () => { await setup(); - ioClient.emit('update-column-title-request', { columnId: 'non-existing-id', newTitle: 'new title' }); + ioClient.emit('update-column-title-request', { columnId: new ObjectId().toHexString(), newTitle: 'new title' }); const failure = await waitForEvent(ioClient, 'update-column-title-failure'); expect(failure).toBeDefined(); @@ -220,7 +240,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when board does not exist', () => { it('should answer with failure', async () => { await setup(); - const boardId = 'non-existing-id'; + const boardId = new ObjectId().toHexString(); ioClient.emit('delete-board-request', { boardId }); const failure = await waitForEvent(ioClient, 'delete-board-failure'); @@ -246,7 +266,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when board does not exist', () => { it('should answer with failure', async () => { await setup(); - const boardId = 'non-existing-id'; + const boardId = new ObjectId().toHexString(); ioClient.emit('update-board-title-request', { boardId, newTitle: 'new title' }); const failure = await waitForEvent(ioClient, 'update-board-title-failure'); @@ -272,7 +292,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when board does not exist', () => { it('should answer with failure', async () => { await setup(); - const boardId = 'non-existing-id'; + const boardId = new ObjectId().toHexString(); ioClient.emit('create-column-request', { boardId }); const failure = await waitForEvent(ioClient, 'create-column-failure'); @@ -298,7 +318,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when board does not exist', () => { it('should answer with failure', async () => { await setup(); - const boardId = 'non-existing-id'; + const boardId = new ObjectId().toHexString(); ioClient.emit('update-board-visibility-request', { boardId, isVisible: false }); const failure = await waitForEvent(ioClient, 'update-board-visibility-failure'); @@ -324,7 +344,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when column does not exist', () => { it('should answer with failure', async () => { await setup(); - const columnId = 'not-existing-id'; + const columnId = new ObjectId().toHexString(); ioClient.emit('delete-column-request', { columnId }); const failure = await waitForEvent(ioClient, 'delete-column-failure'); @@ -340,9 +360,7 @@ describe(BoardCollaborationGateway.name, () => { const { columnBoardNode, columnNode } = await setup(); const moveColumnProps = { - columnId: columnNode.id, targetBoardId: columnBoardNode.id, - newIndex: 1, columnMove: { addedIndex: 1, removedIndex: 0, @@ -361,13 +379,13 @@ describe(BoardCollaborationGateway.name, () => { const { columnBoardNode } = await setup(); const moveColumnProps = { - columnId: 'non-existing-id', + columnId: new ObjectId().toHexString(), targetBoardId: columnBoardNode.id, newIndex: 1, columnMove: { addedIndex: 1, removedIndex: 0, - columnId: 'non-existing-id', + columnId: new ObjectId().toHexString(), }, }; @@ -395,7 +413,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when card does not exist', () => { it('should answer with failure', async () => { await setup(); - const cardId = 'non-existing-id'; + const cardId = new ObjectId().toHexString(); ioClient.emit('update-card-title-request', { cardId, newTitle: 'new title' }); const failure = await waitForEvent(ioClient, 'update-card-title-failure'); @@ -422,7 +440,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when card does not exist', () => { it('should answer with failure', async () => { await setup(); - const cardId = 'non-existing-id'; + const cardId = new ObjectId().toHexString(); ioClient.emit('update-card-height-request', { cardId, newHeight: 200 }); const failure = await waitForEvent(ioClient, 'update-card-height-failure'); @@ -448,7 +466,7 @@ describe(BoardCollaborationGateway.name, () => { describe('when card does not exist', () => { it('should answer with failure', async () => { await setup(); - const cardId = 'non-existing-id'; + const cardId = new ObjectId().toHexString(); ioClient.emit('fetch-card-request', { cardIds: [cardId] }); const failure = await waitForEvent(ioClient, 'fetch-card-failure'); @@ -467,20 +485,142 @@ describe(BoardCollaborationGateway.name, () => { ioClient.emit('delete-card-request', { cardId }); const success = await waitForEvent(ioClient, 'delete-card-success'); - expect(success).toEqual({ cardId }); + expect(success).toEqual(expect.objectContaining({ cardId })); }); }); describe('when card does not exist', () => { it('should answer with failure', async () => { await setup(); - const cardId = 'non-existing-id'; + const cardId = new ObjectId().toHexString(); - ioClient.emit('delete-card-request', { cardIds: [cardId] }); + ioClient.emit('delete-card-request', { cardId }); const failure = await waitForEvent(ioClient, 'delete-card-failure'); expect(failure).toBeDefined(); }); }); }); + + describe('create element', () => { + it('should answer with success', async () => { + const { cardNodes } = await setup(); + const cardId = cardNodes[1].id; + + ioClient.emit('create-element-request', { cardId, type: ContentElementType.RICH_TEXT }); + const success = (await waitForEvent(ioClient, 'create-element-success')) as { + cardId: string; + newElement: unknown; + }; + + expect(Object.keys(success)).toEqual(expect.arrayContaining(['cardId', 'newElement'])); + }); + + describe('when card does not exist', () => { + it('should answer with failure', async () => { + await setup(); + const cardId = new ObjectId().toHexString(); + + ioClient.emit('create-element-request', { cardId, type: ContentElementType.RICH_TEXT }); + const failure = await waitForEvent(ioClient, 'create-element-failure'); + + expect(failure).toBeDefined(); + }); + }); + }); + + describe('delete element', () => { + describe('when element exists', () => { + it('should answer with success', async () => { + const { cardNodes, elementNodes } = await setup(); + const cardId = cardNodes[0].id; + const elementId = elementNodes[0].id; + + ioClient.emit('delete-element-request', { cardId, elementId }); + const success = await waitForEvent(ioClient, 'delete-element-success'); + + expect(success).toEqual(expect.objectContaining({ cardId, elementId })); + }); + }); + + describe('when element does not exist', () => { + it('should answer with failure', async () => { + const { cardNodes } = await setup(); + const cardId = cardNodes[0].id; + const elementId = new ObjectId().toHexString(); + + ioClient.emit('delete-element-request', { cardId, elementId }); + const failure = await waitForEvent(ioClient, 'delete-element-failure'); + + expect(failure).toBeDefined(); + }); + }); + }); + + describe('update element', () => { + describe('when element exists', () => { + it('should answer with success', async () => { + const { elementNodes } = await setup(); + const elementId = elementNodes[0].id; + + const payload = { + elementId, + data: { + type: ContentElementType.RICH_TEXT, + content: { text: 'some new text', inputFormat: InputFormat.PLAIN_TEXT }, + }, + }; + + ioClient.emit('update-element-request', payload); + const success = await waitForEvent(ioClient, 'update-element-success'); + + expect(success).toEqual(expect.objectContaining(payload)); + }); + }); + + describe('when element does not exist', () => { + it('should answer with failure', async () => { + await setup(); + const elementId = new ObjectId().toHexString(); + + ioClient.emit('update-element-request', { + elementId, + data: { + type: ContentElementType.RICH_TEXT, + content: { text: 'some new text', inputFormat: InputFormat.PLAIN_TEXT }, + }, + }); + const failure = await waitForEvent(ioClient, 'update-element-failure'); + + expect(failure).toBeDefined(); + }); + }); + }); + + describe('move element', () => { + describe('when element exists', () => { + it('should answer with success', async () => { + const { cardNodes, elementNodes } = await setup(); + const data = { elementId: elementNodes[0].id, toCardId: cardNodes[0].id, toPosition: 2 }; + + ioClient.emit('move-element-request', data); + const success = await waitForEvent(ioClient, 'move-element-success'); + + expect(success).toEqual(expect.objectContaining(data)); + }); + }); + + describe('when element does not exist', () => { + it('should answer with failure', async () => { + const { cardNodes } = await setup(); + const elementId = new ObjectId().toHexString(); + const toCardId = cardNodes[0].id; + + ioClient.emit('move-element-request', { elementId, toCardId, toPosition: 2 }); + const failure = await waitForEvent(ioClient, 'move-element-failure'); + + expect(failure).toBeDefined(); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts index b5f9dcb6cae..c14a8c26986 100644 --- a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts +++ b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts @@ -1,11 +1,12 @@ import { MikroORM, UseRequestContext } from '@mikro-orm/core'; -import { UseGuards } from '@nestjs/common'; +import { UseGuards, UsePipes } from '@nestjs/common'; import { SubscribeMessage, WebSocketGateway, WsException } from '@nestjs/websockets'; import { LegacyLogger } from '@src/core/logger'; import { WsJwtAuthGuard } from '@src/modules/authentication/guard/ws-jwt-auth.guard'; -import { BoardResponseMapper, CardResponseMapper } from '../controller/mapper'; +import { BoardResponseMapper, CardResponseMapper, ContentElementResponseFactory } from '../controller/mapper'; +import { ColumnResponseMapper } from '../controller/mapper/column-response.mapper'; import { BoardDoAuthorizableService } from '../service'; -import { BoardUc, CardUc, ColumnUc } from '../uc'; +import { BoardUc, CardUc, ColumnUc, ElementUc } from '../uc'; import { CreateCardMessageParams, DeleteColumnMessageParams, @@ -13,19 +14,24 @@ import { UpdateColumnTitleMessageParams, } from './dto'; import BoardCollaborationConfiguration from './dto/board-collaboration-config'; -import { ColumnResponseMapper } from '../controller/mapper/column-response.mapper'; import { CreateColumnMessageParams } from './dto/create-column.message.param'; +import { CreateContentElementMessageParams } from './dto/create-content-element.message.param'; import { DeleteBoardMessageParams } from './dto/delete-board.message.param'; import { DeleteCardMessageParams } from './dto/delete-card.message.param'; +import { DeleteContentElementMessageParams } from './dto/delete-content-element.message.param'; import { FetchBoardMessageParams } from './dto/fetch-board.message.param'; import { FetchCardsMessageParams } from './dto/fetch-cards.message.param'; import { MoveColumnMessageParams } from './dto/move-column.message.param'; +import { MoveContentElementMessageParams } from './dto/move-content-element.message.param'; import { UpdateBoardTitleMessageParams } from './dto/update-board-title.message.param'; import { UpdateBoardVisibilityMessageParams } from './dto/update-board-visibility.message.param'; import { UpdateCardHeightMessageParams } from './dto/update-card-height.message.param'; import { UpdateCardTitleMessageParams } from './dto/update-card-title.message.param'; +import { UpdateContentElementMessageParams } from './dto/update-content-element.message.param'; import { Socket } from './types'; +import { WsValidationPipe } from './ws-validation.pipe'; +@UsePipes(new WsValidationPipe()) @WebSocketGateway(BoardCollaborationConfiguration.websocket) @UseGuards(WsJwtAuthGuard) export class BoardCollaborationGateway { @@ -36,6 +42,7 @@ export class BoardCollaborationGateway { private readonly boardUc: BoardUc, private readonly columnUc: ColumnUc, private readonly cardUc: CardUc, + private readonly elementUc: ElementUc, private readonly authorizableService: BoardDoAuthorizableService // to be removed ) {} @@ -57,8 +64,8 @@ export class BoardCollaborationGateway { const room = await this.ensureUserInRoom(client, data.boardId); await this.boardUc.deleteBoard(userId, data.boardId); - client.to(room).emit('delete-board-success', data); - client.emit('delete-board-success', data); + client.to(room).emit('delete-board-success', { ...data, isOwnAction: false }); + client.emit('delete-board-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('delete-board-failure', new Error('Failed to delete board')); } @@ -72,8 +79,8 @@ export class BoardCollaborationGateway { await this.boardUc.updateBoardTitle(userId, data.boardId, data.newTitle); const room = await this.ensureUserInRoom(client, data.boardId); - client.to(room).emit('update-board-title-success', data); - client.emit('update-board-title-success', data); + client.to(room).emit('update-board-title-success', { ...data, isOwnAction: false }); + client.emit('update-board-title-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('update-board-title-failure', new Error('Failed to update board title')); } @@ -87,8 +94,8 @@ export class BoardCollaborationGateway { await this.cardUc.updateCardTitle(userId, data.cardId, data.newTitle); const room = await this.ensureUserInRoom(client, data.cardId); - client.to(room).emit('update-card-title-success', data); - client.emit('update-card-title-success', data); + client.to(room).emit('update-card-title-success', { ...data, isOwnAction: false }); + client.emit('update-card-title-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('update-card-title-failure', new Error('Failed to update card title')); } @@ -102,8 +109,8 @@ export class BoardCollaborationGateway { await this.cardUc.updateCardHeight(userId, data.cardId, data.newHeight); const room = await this.ensureUserInRoom(client, data.cardId); - client.to(room).emit('update-card-height-success', data); - client.emit('update-card-height-success', data); + client.to(room).emit('update-card-height-success', { ...data, isOwnAction: false }); + client.emit('update-card-height-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('update-card-height-failure', new Error('Failed to update card height')); } @@ -117,8 +124,8 @@ export class BoardCollaborationGateway { const room = await this.ensureUserInRoom(client, data.cardId); await this.cardUc.deleteCard(userId, data.cardId); - client.to(room).emit('delete-card-success', data); - client.emit('delete-card-success', data); + client.to(room).emit('delete-card-success', { ...data, isOwnAction: false }); + client.emit('delete-card-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('delete-card-failure', new Error('Failed to update card height')); } @@ -136,8 +143,8 @@ export class BoardCollaborationGateway { }; const room = await this.ensureUserInRoom(client, data.columnId); - client.to(room).emit('create-card-success', responsePayload); - client.emit('create-card-success', responsePayload); + client.to(room).emit('create-card-success', { ...responsePayload, isOwnAction: false }); + client.emit('create-card-success', { ...responsePayload, isOwnAction: true }); } catch (err) { client.emit('create-card-failure', new Error('Failed to create card')); } @@ -156,8 +163,8 @@ export class BoardCollaborationGateway { }; const room = await this.ensureUserInRoom(client, data.boardId); - client.to(room).emit('create-column-success', responsePayload); - client.emit('create-column-success', responsePayload); + client.to(room).emit('create-column-success', { ...responsePayload, isOwnAction: false }); + client.emit('create-column-success', { ...responsePayload, isOwnAction: true }); // payload needs to be returned to allow the client to do sequential operation // of createColumn and move the card into that column @@ -191,8 +198,8 @@ export class BoardCollaborationGateway { await this.columnUc.moveCard(userId, data.cardId, data.toColumnId, data.newIndex); const room = await this.ensureUserInRoom(client, data.cardId); - client.to(room).emit('move-card-success', data); - client.emit('move-card-success', data); + client.to(room).emit('move-card-success', { ...data, isOwnAction: false }); + client.emit('move-card-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('move-card-failure', new Error('Failed to move card')); } @@ -206,8 +213,8 @@ export class BoardCollaborationGateway { await this.boardUc.moveColumn(userId, data.columnMove.columnId, data.targetBoardId, data.columnMove.addedIndex); const room = await this.ensureUserInRoom(client, data.targetBoardId); - client.to(room).emit('move-column-success', data); - client.emit('move-column-success', data); + client.to(room).emit('move-column-success', { ...data, isOwnAction: false }); + client.emit('move-column-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('move-column-failure', new Error('Failed to move column')); } @@ -221,8 +228,8 @@ export class BoardCollaborationGateway { await this.columnUc.updateColumnTitle(userId, data.columnId, data.newTitle); const room = await this.ensureUserInRoom(client, data.columnId); - client.to(room).emit('update-column-title-success', data); - client.emit('update-column-title-success', data); + client.to(room).emit('update-column-title-success', { ...data, isOwnAction: false }); + client.emit('update-column-title-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('update-column-title-failure', new Error('Failed to update column title')); } @@ -236,8 +243,8 @@ export class BoardCollaborationGateway { await this.boardUc.updateVisibility(userId, data.boardId, data.isVisible); const room = await this.ensureUserInRoom(client, data.boardId); - client.to(room).emit('update-board-visibility-success', data); - client.emit('update-board-visibility-success', data); + client.to(room).emit('update-board-visibility-success', { ...data, isOwnAction: false }); + client.emit('update-board-visibility-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('update-board-visibility-failure', new Error('Failed to update board visibility')); } @@ -251,8 +258,8 @@ export class BoardCollaborationGateway { const room = await this.ensureUserInRoom(client, data.columnId); await this.columnUc.deleteColumn(userId, data.columnId); - client.to(room).emit('delete-column-success', data); - client.emit('delete-column-success', data); + client.to(room).emit('delete-column-success', { ...data, isOwnAction: false }); + client.emit('delete-column-success', { ...data, isOwnAction: true }); } catch (err) { client.emit('delete-column-failure', new Error('Failed to delete column')); } @@ -267,12 +274,76 @@ export class BoardCollaborationGateway { const cardResponses = cards.map((card) => CardResponseMapper.mapToResponse(card)); await this.ensureUserInRoom(client, data.cardIds[0]); - client.emit('fetch-card-success', { cards: cardResponses }); + client.emit('fetch-card-success', { cards: cardResponses, isOwnAction: true }); } catch (err) { client.emit('fetch-card-failure', new Error('Failed to fetch board')); } } + @SubscribeMessage('create-element-request') + @UseRequestContext() + async createElement(client: Socket, data: CreateContentElementMessageParams) { + try { + const { userId } = this.getCurrentUser(client); + const element = await this.cardUc.createElement(userId, data.cardId, data.type, data.toPosition); + const responsePayload = { + ...data, + newElement: ContentElementResponseFactory.mapToResponse(element), + }; + + const room = await this.ensureUserInRoom(client, data.cardId); + client.to(room).emit('create-element-success', { ...responsePayload, isOwnAction: false }); + client.emit('create-element-success', { ...responsePayload, isOwnAction: true }); + } catch (err) { + client.emit('create-element-failure', new Error('Failed to create element')); + } + } + + @SubscribeMessage('update-element-request') + @UseRequestContext() + async updateElement(client: Socket, data: UpdateContentElementMessageParams) { + try { + const { userId } = this.getCurrentUser(client); + await this.elementUc.updateElement(userId, data.elementId, data.data.content); + + const room = await this.ensureUserInRoom(client, data.elementId); + client.to(room).emit('update-element-success', { ...data, isOwnAction: false }); + client.emit('update-element-success', { ...data, isOwnAction: true }); + } catch (err) { + client.emit('update-element-failure', new Error('Failed to update element')); + } + } + + @SubscribeMessage('delete-element-request') + @UseRequestContext() + async deleteElement(client: Socket, data: DeleteContentElementMessageParams) { + try { + const { userId } = this.getCurrentUser(client); + const room = await this.ensureUserInRoom(client, data.elementId); + await this.elementUc.deleteElement(userId, data.elementId); + + client.to(room).emit('delete-element-success', { ...data, isOwnAction: false }); + client.emit('delete-element-success', { ...data, isOwnAction: true }); + } catch (err) { + client.emit('delete-element-failure', new Error('Failed to delete element')); + } + } + + @SubscribeMessage('move-element-request') + @UseRequestContext() + async moveElement(client: Socket, data: MoveContentElementMessageParams) { + try { + const { userId } = this.getCurrentUser(client); + await this.cardUc.moveElement(userId, data.elementId, data.toCardId, data.toPosition); + + const room = await this.ensureUserInRoom(client, data.elementId); + client.to(room).emit('move-element-success', { ...data, isOwnAction: false }); + client.emit('move-element-success', { ...data, isOwnAction: true }); + } catch (err) { + client.emit('move-element-failure', new Error('Failed to move element')); + } + } + private async ensureUserInRoom(client: Socket, id: string) { const rootId = await this.getRootIdForId(id); const room = `board_${rootId}`; diff --git a/apps/server/src/modules/board/gateway/dto/create-content-element.message.param.ts b/apps/server/src/modules/board/gateway/dto/create-content-element.message.param.ts new file mode 100644 index 00000000000..70885d03146 --- /dev/null +++ b/apps/server/src/modules/board/gateway/dto/create-content-element.message.param.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain/domainobject'; +import { IsEnum, IsInt, IsMongoId, IsOptional, Min } from 'class-validator'; + +export class CreateContentElementMessageParams { + @IsMongoId() + cardId!: string; + + @IsEnum(ContentElementType) + @ApiProperty({ + description: 'The type of element', + enum: ContentElementType, + required: true, + nullable: false, + enumName: 'ContentElementType', + }) + type!: ContentElementType; + + @IsOptional() + @IsInt() + @Min(0) + @ApiPropertyOptional({ + description: 'to bring element to a specific position, default is last position', + type: Number, + required: false, + nullable: false, + }) + toPosition?: number; +} diff --git a/apps/server/src/modules/board/gateway/dto/delete-content-element.message.param.ts b/apps/server/src/modules/board/gateway/dto/delete-content-element.message.param.ts new file mode 100644 index 00000000000..88fec01e6a2 --- /dev/null +++ b/apps/server/src/modules/board/gateway/dto/delete-content-element.message.param.ts @@ -0,0 +1,9 @@ +import { IsMongoId } from 'class-validator'; + +export class DeleteContentElementMessageParams { + @IsMongoId() + cardId!: string; + + @IsMongoId() + elementId!: string; +} diff --git a/apps/server/src/modules/board/gateway/dto/move-card.message.param.ts b/apps/server/src/modules/board/gateway/dto/move-card.message.param.ts index 79cf9892645..8a3bd3a2026 100644 --- a/apps/server/src/modules/board/gateway/dto/move-card.message.param.ts +++ b/apps/server/src/modules/board/gateway/dto/move-card.message.param.ts @@ -1,13 +1,32 @@ -import { IsMongoId, IsNumber, Min } from 'class-validator'; +import { IsBoolean, IsMongoId, IsNumber, IsOptional, Min } from 'class-validator'; export class MoveCardMessageParams { @IsMongoId() cardId!: string; + @IsMongoId() + fromColumnId!: string; + @IsMongoId() toColumnId!: string; @IsNumber() @Min(0) newIndex!: number; + + @IsNumber() + @Min(0) + oldIndex!: number; + + @IsNumber() + @Min(0) + fromColumnIndex!: number; + + @IsNumber() + @Min(0) + toColumnIndex!: number; + + @IsOptional() + @IsBoolean() + forceNextTick?: boolean; } diff --git a/apps/server/src/modules/board/gateway/dto/move-column.message.param.ts b/apps/server/src/modules/board/gateway/dto/move-column.message.param.ts index ce4396f3bed..ee87f2204cb 100644 --- a/apps/server/src/modules/board/gateway/dto/move-column.message.param.ts +++ b/apps/server/src/modules/board/gateway/dto/move-column.message.param.ts @@ -1,4 +1,4 @@ -import { IsMongoId, IsNumber, IsString, Min, ValidateNested } from 'class-validator'; +import { IsMongoId, IsNumber, IsString, ValidateNested } from 'class-validator'; export class ColumnMove { @IsNumber() @@ -12,16 +12,9 @@ export class ColumnMove { } export class MoveColumnMessageParams { - @IsMongoId() - columnId!: string; - @IsMongoId() targetBoardId!: string; - @IsNumber() - @Min(0) - newIndex!: number; - @ValidateNested() columnMove!: ColumnMove; } diff --git a/apps/server/src/modules/board/gateway/dto/move-content-element.message.param.ts b/apps/server/src/modules/board/gateway/dto/move-content-element.message.param.ts new file mode 100644 index 00000000000..cfa5f01d8ec --- /dev/null +++ b/apps/server/src/modules/board/gateway/dto/move-content-element.message.param.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId, IsNumber, Min } from 'class-validator'; + +export class MoveContentElementMessageParams { + @IsMongoId() + elementId!: string; + + @IsMongoId() + @ApiProperty({ + required: true, + nullable: false, + }) + toCardId!: string; + + @IsNumber() + @Min(0) + @ApiProperty({ + required: true, + nullable: false, + }) + toPosition!: number; +} diff --git a/apps/server/src/modules/board/gateway/dto/update-content-element.message.param.ts b/apps/server/src/modules/board/gateway/dto/update-content-element.message.param.ts new file mode 100644 index 00000000000..5f06e9dd0d2 --- /dev/null +++ b/apps/server/src/modules/board/gateway/dto/update-content-element.message.param.ts @@ -0,0 +1,7 @@ +import { IsMongoId } from 'class-validator'; +import { UpdateElementContentBodyParams } from '../../controller/dto'; + +export class UpdateContentElementMessageParams extends UpdateElementContentBodyParams { + @IsMongoId() + elementId!: string; +} diff --git a/apps/server/src/modules/board/gateway/ws-validation.pipe.ts b/apps/server/src/modules/board/gateway/ws-validation.pipe.ts new file mode 100644 index 00000000000..fc6f151a056 --- /dev/null +++ b/apps/server/src/modules/board/gateway/ws-validation.pipe.ts @@ -0,0 +1,26 @@ +import { ValidationPipe, ValidationError } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; + +export class WsValidationPipe extends ValidationPipe { + constructor() { + super({ + // enable DTO instance creation for incoming data + transform: true, + transformOptions: { + // enable type coersion, requires transform:true + enableImplicitConversion: true, + }, + whitelist: true, // only pass valid @ApiProperty-decorated DTO properties, remove others + forbidNonWhitelisted: false, // additional params are just skipped (required when extracting multiple DTO from single query) + forbidUnknownValues: true, + exceptionFactory: (errors: ValidationError[]) => new WsException(errors), + validationError: { + // make sure target (DTO) is set on validation error + // we need this to be able to get DTO metadata for checking if a value has to be the obfuscated on output + // see e.g. ErrorLoggable + target: true, + value: true, + }, + }); + } +}