diff --git a/apps/server/src/modules/board/index.ts b/apps/server/src/modules/board/index.ts index 689926702bc..663e27964f5 100644 --- a/apps/server/src/modules/board/index.ts +++ b/apps/server/src/modules/board/index.ts @@ -4,3 +4,4 @@ export * from './service/card.service'; export * from './service/column-board.service'; export * from './service/column.service'; export * from './service/content-element.service'; +export * from './service/column-board-copy.service'; diff --git a/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts new file mode 100644 index 00000000000..5d4693f44d1 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts @@ -0,0 +1,130 @@ +import { LinkElement } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { + cardFactory, + columnBoardFactory, + columnFactory, + externalToolElementFactory, + fileElementFactory, + linkElementFactory, + richTextElementFactory, + submissionContainerElementFactory, + submissionItemFactory, +} from '@shared/testing'; +import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; +import { ObjectId } from 'bson'; +import { SwapInternalLinksVisitor } from './swap-internal-links.visitor'; + +describe('swap internal links visitor', () => { + it('should keep link unchanged', () => { + const map = new Map(); + const linkElement = linkElementFactory.build({ url: 'testurl.dev' }); + const visitor = new SwapInternalLinksVisitor(map); + + linkElement.accept(visitor); + + expect(linkElement.url).toEqual('testurl.dev'); + }); + + const setupIdPair = () => { + const originalId = new ObjectId().toString(); + const newId = new ObjectId().toString(); + const value = { + originalId, + newId, + originalUrl: `testurl.dev/${originalId}`, + expectedUrl: `testurl.dev/${newId}`, + }; + return value; + }; + + const buildIdMap = (pairs: { originalId: string; newId: string }[]) => { + const map = new Map(); + pairs.forEach((pair) => { + map.set(pair.originalId, pair.newId); + }); + return map; + }; + + const buildBoardContaining = (linkelelements: LinkElement[]) => { + const cardContainingLinks = cardFactory.build({ children: linkelelements }); + const submissionContainer = submissionContainerElementFactory.build({ + children: [ + submissionItemFactory.build({ + children: [richTextElementFactory.build()], + }), + ], + }); + const cardContainingOthers = cardFactory.build({ + children: [ + richTextElementFactory.build(), + fileElementFactory.build(), + submissionContainer, + drawingElementFactory.build(), + externalToolElementFactory.build(), + ], + }); + const column = columnFactory.build({ + children: [cardContainingLinks, cardContainingOthers], + }); + const columnBoard = columnBoardFactory.build({ + children: [column], + }); + return columnBoard; + }; + + describe('when a single id is matched', () => { + const setupWithIdPair = () => { + const pair = setupIdPair(); + const map = buildIdMap([pair]); + const visitor = new SwapInternalLinksVisitor(map); + + return { pair, visitor }; + }; + + it('should change ids in link', () => { + const { pair, visitor } = setupWithIdPair(); + const linkElement = linkElementFactory.build({ url: pair.originalUrl }); + + linkElement.accept(visitor); + + expect(linkElement.url).toEqual(pair.expectedUrl); + }); + + it('should change ids in multiple matching links', () => { + const { pair, visitor } = setupWithIdPair(); + const firstLinkElement = linkElementFactory.build({ url: pair.originalUrl }); + const secondLinkElement = linkElementFactory.build({ url: pair.originalUrl }); + const root = buildBoardContaining([firstLinkElement, secondLinkElement]); + + root.accept(visitor); + + expect(firstLinkElement.url).toEqual(pair.expectedUrl); + expect(secondLinkElement.url).toEqual(pair.expectedUrl); + }); + }); + + describe('when multiple different ids are matched', () => { + const setupWithMultipleIds = () => { + const pairs = [setupIdPair(), setupIdPair()]; + + const idMap = buildIdMap(pairs); + const visitor = new SwapInternalLinksVisitor(idMap); + + return { visitor, pairs }; + }; + + const buildLinkElementsWithUrls = (urls: string[]) => urls.map((url) => linkElementFactory.build({ url })); + + it('should change multiple ids in different links', () => { + const { visitor, pairs } = setupWithMultipleIds(); + const linkElements = buildLinkElementsWithUrls(pairs.map((pair) => pair.originalUrl)); + const root = buildBoardContaining(linkElements); + + root.accept(visitor); + + expect(linkElements[0].url).toEqual(pairs[0].expectedUrl); + expect(linkElements[1].url).toEqual(pairs[1].expectedUrl); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts new file mode 100644 index 00000000000..c4f3c0ae88c --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts @@ -0,0 +1,64 @@ +import { + AnyBoardDo, + BoardCompositeVisitor, + Card, + Column, + ColumnBoard, + LinkElement, + SubmissionContainerElement, + SubmissionItem, +} from '@shared/domain/domainobject'; +import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; +import { EntityId } from '@shared/domain/types'; + +export class SwapInternalLinksVisitor implements BoardCompositeVisitor { + constructor(private readonly idMap: Map) {} + + visitDrawingElement(drawingElement: DrawingElement): void { + this.visitChildrenOf(drawingElement); + } + + visitCard(card: Card): void { + this.visitChildrenOf(card); + } + + visitColumn(column: Column): void { + this.visitChildrenOf(column); + } + + visitColumnBoard(columnBoard: ColumnBoard): void { + this.visitChildrenOf(columnBoard); + } + + visitExternalToolElement(): void { + this.doNothing(); + } + + visitFileElement(): void { + this.doNothing(); + } + + visitLinkElement(linkElement: LinkElement): void { + this.idMap.forEach((value, key) => { + linkElement.url = linkElement.url.replace(key, value); + }); + } + + visitRichTextElement(): void { + this.doNothing(); + } + + visitSubmissionContainerElement(submissionContainerElement: SubmissionContainerElement): void { + this.visitChildrenOf(submissionContainerElement); + } + + visitSubmissionItem(submissionItem: SubmissionItem): void { + this.visitChildrenOf(submissionItem); + } + + private visitChildrenOf(boardDo: AnyBoardDo) { + boardDo.children.forEach((child) => child.accept(this)); + } + + private doNothing() {} +} diff --git a/apps/server/src/modules/board/service/column-board-copy.service.spec.ts b/apps/server/src/modules/board/service/column-board-copy.service.spec.ts index ef37775138f..268c65297e7 100644 --- a/apps/server/src/modules/board/service/column-board-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board-copy.service.spec.ts @@ -3,8 +3,19 @@ import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helpe import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReferenceType, ColumnBoard, UserDO } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo'; -import { columnBoardFactory, courseFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { + cardFactory, + columnBoardFactory, + columnFactory, + courseFactory, + linkElementFactory, + schoolFactory, + setupEntities, + userFactory, +} from '@shared/testing'; +import { ObjectId } from 'bson'; import { BoardDoRepo } from '../repo'; import { BoardDoCopyService, @@ -168,4 +179,53 @@ describe('column board copy service', () => { expect(result).toEqual(expectedCopyStatus); }); }); + + describe('when changing linked ids', () => { + const setup = () => { + const linkedIdBefore = new ObjectId().toString(); + const linkElement = linkElementFactory.build({ + url: `someurl/${linkedIdBefore}`, + }); + const board = columnBoardFactory.build({ + children: [ + columnFactory.build({ + children: [ + cardFactory.build({ + children: [linkElement], + }), + ], + }), + ], + }); + boardRepo.findById.mockResolvedValue(board); + + return { board, linkElement, linkedIdBefore }; + }; + + it('should get board', async () => { + const { board } = setup(); + + await service.swapLinkedIds(board.id, new Map()); + + expect(boardRepo.findById).toHaveBeenCalledWith(board.id); + }); + + it('should update links in board', async () => { + const { board, linkElement, linkedIdBefore } = setup(); + const expectedId = new ObjectId().toString(); + const map = new Map().set(linkedIdBefore, expectedId); + + await service.swapLinkedIds(board.id, map); + + expect(linkElement.url).toEqual(`someurl/${expectedId}`); + }); + + it('should persist updates', async () => { + const { board } = setup(); + + await service.swapLinkedIds(board.id, new Map()); + + expect(boardRepo.save).toHaveBeenCalledWith(board); + }); + }); }); diff --git a/apps/server/src/modules/board/service/column-board-copy.service.ts b/apps/server/src/modules/board/service/column-board-copy.service.ts index b68ae9d1a90..1317f4b5c22 100644 --- a/apps/server/src/modules/board/service/column-board-copy.service.ts +++ b/apps/server/src/modules/board/service/column-board-copy.service.ts @@ -11,6 +11,7 @@ import { EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo'; import { BoardDoRepo } from '../repo'; import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './board-do-copy-service'; +import { SwapInternalLinksVisitor } from './board-do-copy-service/swap-internal-links.visitor'; @Injectable() export class ColumnBoardCopyService { @@ -54,4 +55,16 @@ export class ColumnBoardCopyService { return copyStatus; } + + public async swapLinkedIds(boardId: EntityId, idMap: Map) { + const board = await this.boardDoRepo.findById(boardId); + + const visitor = new SwapInternalLinksVisitor(idMap); + + board.accept(visitor); + + await this.boardDoRepo.save(board); + + return board; + } } diff --git a/apps/server/src/modules/files-storage/helper/index.ts b/apps/server/src/modules/files-storage/helper/index.ts index b6d62a05f97..76ef1a16f9f 100644 --- a/apps/server/src/modules/files-storage/helper/index.ts +++ b/apps/server/src/modules/files-storage/helper/index.ts @@ -1,4 +1,3 @@ export * from './file-name'; export * from './file-record'; export * from './path'; -export * from './promise'; diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts index 58b42b656e1..f64e3725fec 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts @@ -1,11 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ColumnBoardCopyService } from '@modules/board/service/column-board-copy.service'; +import { ColumnBoardCopyService } from '@modules/board'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { LessonCopyService } from '@modules/lesson/service'; -import { TaskCopyService } from '@modules/task/service'; +import { LessonCopyService } from '@modules/lesson'; +import { TaskCopyService } from '@modules/task'; import { Test, TestingModule } from '@nestjs/testing'; +import { AuthorizableObject } from '@shared/domain/domain-object'; import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; import { Board } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; import { BoardRepo } from '@shared/repo'; import { boardFactory, @@ -292,6 +294,103 @@ describe('board copy service', () => { }); }); + describe('when different elements have been copied', () => { + const setup = () => { + const originalTask = taskFactory.buildWithId(); + const taskElement = taskBoardElementFactory.buildWithId({ target: originalTask }); + const taskCopy = taskFactory.buildWithId({ name: originalTask.name }); + taskCopyService.copyTask.mockResolvedValue({ + title: taskCopy.name, + type: CopyElementType.TASK, + status: CopyStatusEnum.SUCCESS, + copyEntity: taskCopy, + }); + + const originalLesson = lessonFactory.buildWithId(); + const lessonElement = lessonBoardElementFactory.buildWithId({ target: originalLesson }); + const lessonCopy = lessonFactory.buildWithId({ name: originalLesson.name }); + lessonCopyService.copyLesson.mockResolvedValue({ + title: originalLesson.name, + type: CopyElementType.LESSON, + status: CopyStatusEnum.SUCCESS, + copyEntity: lessonCopy, + }); + lessonCopyService.updateCopiedEmbeddedTasks = jest.fn().mockImplementation((status: CopyStatus) => status); + + const originalColumnBoard = columnBoardFactory.build(); + const columnBoardTarget = columnBoardTargetFactory.buildWithId({ + columnBoardId: originalColumnBoard.id, + title: originalColumnBoard.title, + }); + const columnBoardElement = columnboardBoardElementFactory.buildWithId({ target: columnBoardTarget }); + const columnBoardCopy = columnBoardFactory.build(); + columnBoardCopyService.copyColumnBoard.mockResolvedValue({ + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + copyEntity: columnBoardCopy, + originalEntity: originalColumnBoard, + title: columnBoardCopy.title, + }); + + const copyDict = new Map() + .set(originalLesson.id, lessonCopy) + .set(originalTask.id, taskCopy) + .set(originalColumnBoard.id, columnBoardCopy); + copyHelperService.buildCopyEntityDict.mockReturnValue(copyDict); + + const originalCourse = courseFactory.buildWithId(); + const destinationCourse = courseFactory.buildWithId(); + const originalBoard = boardFactory.buildWithId({ + references: [lessonElement, taskElement, columnBoardElement], + course: originalCourse, + }); + const user = userFactory.buildWithId(); + + return { + originalCourse, + destinationCourse, + originalBoard, + user, + lessonCopy, + columnBoardCopy, + originalTask, + taskCopy, + originalLesson, + }; + }; + + it('should trigger swapping ids for board', async () => { + const { destinationCourse, originalBoard, user, columnBoardCopy } = setup(); + await copyService.copyBoard({ originalBoard, user, destinationCourse }); + + expect(columnBoardCopyService.swapLinkedIds).toHaveBeenCalledWith(columnBoardCopy.id, expect.anything()); + }); + + it('should pass task for swapping ids', async () => { + const { destinationCourse, originalBoard, user, originalTask, taskCopy } = setup(); + await copyService.copyBoard({ originalBoard, user, destinationCourse }); + + const map = columnBoardCopyService.swapLinkedIds.mock.calls[0][1]; + expect(map.get(originalTask.id)).toEqual(taskCopy.id); + }); + + it('should pass lesson for swapping ids', async () => { + const { destinationCourse, originalBoard, user, originalLesson, lessonCopy } = setup(); + await copyService.copyBoard({ originalBoard, user, destinationCourse }); + + const map = columnBoardCopyService.swapLinkedIds.mock.calls[0][1]; + expect(map.get(originalLesson.id)).toEqual(lessonCopy.id); + }); + + it('should pass course for swapping ids', async () => { + const { originalCourse, destinationCourse, originalBoard, user } = setup(); + await copyService.copyBoard({ originalBoard, user, destinationCourse }); + + const map = columnBoardCopyService.swapLinkedIds.mock.calls[0][1]; + expect(map.get(originalCourse.id)).toEqual(destinationCourse.id); + }); + }); + describe('derive status from elements', () => { const setup = () => { const originalTask = taskFactory.build(); diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.ts b/apps/server/src/modules/learnroom/service/board-copy.service.ts index 9b6582c92cd..2356f06a02c 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.ts @@ -1,9 +1,9 @@ -import { ColumnBoardCopyService } from '@modules/board/service/column-board-copy.service'; +import { ColumnBoardCopyService } from '@modules/board'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { getResolvedValues } from '@modules/files-storage/helper'; -import { LessonCopyService } from '@modules/lesson/service'; -import { TaskCopyService } from '@modules/task/service'; +import { LessonCopyService } from '@modules/lesson'; +import { TaskCopyService } from '@modules/task'; import { Injectable } from '@nestjs/common'; +import { getResolvedValues } from '@shared/common/utils/promise'; import { ColumnBoard } from '@shared/domain/domainobject'; import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; import { @@ -22,6 +22,7 @@ import { isLesson, isTask, } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; import { BoardRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { sortBy } from 'lodash'; @@ -65,6 +66,8 @@ export class BoardCopyService { boardCopy = status.copyEntity as Board; } + status = await this.swapLinkedIdsInBoards(status); + try { await this.boardRepo.save(boardCopy); } catch (err) { @@ -174,6 +177,29 @@ export class BoardCopyService { return boardStatus; } + private async swapLinkedIdsInBoards(copyStatus: CopyStatus): Promise { + const map = new Map(); + const copyDict = this.copyHelperService.buildCopyEntityDict(copyStatus); + copyDict.forEach((value, key) => map.set(key, value.id)); + + if (copyStatus.copyEntity instanceof Board && copyStatus.originalEntity instanceof Board) { + map.set(copyStatus.originalEntity.course.id, copyStatus.copyEntity.course.id); + } + + const elements = copyStatus.elements ?? []; + const updatedElements = await Promise.all( + elements.map(async (el) => { + if (el.type === CopyElementType.COLUMNBOARD && el.copyEntity) { + el.copyEntity = await this.columnBoardCopyService.swapLinkedIds(el.copyEntity?.id, map); + } + return el; + }) + ); + + copyStatus.elements = updatedElements; + return copyStatus; + } + private sortByOriginalOrder(resolved: [number, CopyStatus][]): CopyStatus[] { const sortByPos = sortBy(resolved, ([pos]) => pos); const statuses = sortByPos.map(([, status]) => status); diff --git a/apps/server/src/modules/files-storage/helper/promise.spec.ts b/apps/server/src/shared/common/utils/promise.spec.ts similarity index 100% rename from apps/server/src/modules/files-storage/helper/promise.spec.ts rename to apps/server/src/shared/common/utils/promise.spec.ts diff --git a/apps/server/src/modules/files-storage/helper/promise.ts b/apps/server/src/shared/common/utils/promise.ts similarity index 100% rename from apps/server/src/modules/files-storage/helper/promise.ts rename to apps/server/src/shared/common/utils/promise.ts