diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 722ba933dc1..a002766b56d 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -4,6 +4,7 @@ import { ConsoleWriterModule } from '@shared/infra/console'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { FilesStorageClientModule } from '../files-storage-client'; +import { UserModule } from '../user'; import { BoardDoRepo, BoardNodeRepo } from './repo'; import { RecursiveDeleteVisitor } from './repo/recursive-delete.vistor'; import { @@ -15,9 +16,11 @@ import { ContentElementService, SubmissionItemService, } from './service'; +import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './service/board-do-copy-service'; +import { ColumnBoardCopyService } from './service/column-board-copy.service'; @Module({ - imports: [ConsoleWriterModule, FilesStorageClientModule, LoggerModule], + imports: [ConsoleWriterModule, FilesStorageClientModule, LoggerModule, UserModule], providers: [ BoardDoAuthorizableService, BoardDoRepo, @@ -31,6 +34,9 @@ import { CourseRepo, // TODO: import learnroom module instead. This is currently not possible due to dependency cycle with authorisation service RecursiveDeleteVisitor, SubmissionItemService, + BoardDoCopyService, + ColumnBoardCopyService, + SchoolSpecificFileCopyServiceFactory, ], exports: [ BoardDoAuthorizableService, @@ -39,6 +45,7 @@ import { ColumnService, ContentElementService, SubmissionItemService, + ColumnBoardCopyService, ], }) export class BoardModule {} diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts new file mode 100644 index 00000000000..4f9fbd6aa28 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -0,0 +1,648 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + Card, + Column, + ColumnBoard, + FileElement, + isCard, + isColumn, + isColumnBoard, + isFileElement, + isRichTextElement, + isSubmissionContainerElement, + RichTextElement, + SubmissionContainerElement, +} from '@shared/domain'; +import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { + cardFactory, + columnBoardFactory, + columnFactory, + fileElementFactory, + richTextElementFactory, + setupEntities, + submissionContainerElementFactory, + submissionItemFactory, +} from '@shared/testing'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; +import { ObjectId } from 'bson'; +import { BoardDoCopyService } from './board-do-copy.service'; +import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; + +describe('recursive board copy visitor', () => { + let module: TestingModule; + let service: BoardDoCopyService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [BoardDoCopyService], + }).compile(); + + service = module.get(BoardDoCopyService); + + await setupEntities(); + }); + + const setupfileCopyService = () => { + const fileCopyService = createMock(); + + return { fileCopyService }; + }; + + describe('copying column boards', () => { + const getColumnBoardCopyFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isColumnBoard(copy)).toEqual(true); + return copy as ColumnBoard; + }; + + describe('when copying empty column board', () => { + const setup = () => { + const original = columnBoardFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + it('should return a columnboard as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isColumnBoard(result.copyEntity)).toEqual(true); + }); + + it('should copy title', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnBoardCopyFromStatus(result); + + expect(copy.title).toEqual(original.title); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnBoardCopyFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should copy the context', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnBoardCopyFromStatus(result); + + expect(copy.context).toEqual(original.context); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type Columnboard', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.COLUMNBOARD); + }); + }); + + describe('when copying a columnboard with children', () => { + const setup = () => { + const children = columnFactory.buildList(5); + + const original = columnBoardFactory.build({ children }); + + return { original, ...setupfileCopyService() }; + }; + + it('should add children to copy of columnboard', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnBoardCopyFromStatus(result); + + expect(copy.children.length).toEqual(original.children.length); + }); + + it('should create copies of children', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnBoardCopyFromStatus(result); + + const originalChildIds = original.children.map((child) => child.id); + const copyChildrenIds = copy.children.map((child) => child.id); + + originalChildIds.forEach((id) => { + const index = copyChildrenIds.findIndex((value) => value === id); + expect(index).toEqual(-1); + }); + }); + + it('should add status of child copies to copystatus', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.elements?.length).toEqual(original.children.length); + }); + }); + }); + + describe('copying a column', () => { + const getColumnCopyFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isColumn(copy)).toEqual(true); + return copy as Column; + }; + + describe('when copying an empty column', () => { + const setup = () => { + const original = columnFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + it('should return a column as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isColumn(result.copyEntity)).toEqual(true); + }); + + it('should copy title', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnCopyFromStatus(result); + + expect(copy.title).toEqual(original.title); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnCopyFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type Column', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.COLUMN); + }); + }); + + describe('when copying a column with children', () => { + const setup = () => { + const children = cardFactory.buildList(2); + const original = columnFactory.build({ children }); + + return { original, ...setupfileCopyService() }; + }; + + it('should add children to copy of columnboard', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnCopyFromStatus(result); + + expect(copy.children.length).toEqual(original.children.length); + }); + + it('should create copies of children', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnCopyFromStatus(result); + + const originalChildIds = original.children.map((child) => child.id); + const copyChildrenIds = copy.children.map((child) => child.id); + + originalChildIds.forEach((id) => { + const index = copyChildrenIds.findIndex((value) => value === id); + expect(index).toEqual(-1); + }); + }); + + it('should add status of child copies to copystatus', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.elements?.length).toEqual(original.children.length); + }); + }); + }); + + describe('copying cards', () => { + const getCardCopyFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isCard(copy)).toEqual(true); + return copy as Card; + }; + + describe('when copying an empty card', () => { + const setup = () => { + const original = cardFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + it('should return a richtext element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isCard(result.copyEntity)).toEqual(true); + }); + + it('should copy title', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getCardCopyFromStatus(result); + + expect(copy.title).toEqual(original.title); + }); + + it('should copy height', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getCardCopyFromStatus(result); + + expect(copy.height).toEqual(original.height); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getCardCopyFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type Card', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.CARD); + }); + }); + + describe('when copying a card with children', () => { + const setup = () => { + const children = [...richTextElementFactory.buildList(4), ...submissionContainerElementFactory.buildList(3)]; + const original = cardFactory.build({ children }); + + return { original, ...setupfileCopyService() }; + }; + + it('should add children to copy of columnboard', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getCardCopyFromStatus(result); + + expect(copy.children.length).toEqual(original.children.length); + }); + + it('should create copies of children', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getCardCopyFromStatus(result); + + const originalChildIds = original.children.map((child) => child.id); + const copyChildrenIds = copy.children.map((child) => child.id); + + originalChildIds.forEach((id) => { + const index = copyChildrenIds.findIndex((value) => value === id); + expect(index).toEqual(-1); + }); + }); + + it('should add status of child copies to copystatus', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.elements?.length).toEqual(original.children.length); + }); + }); + }); + + describe('when copying a richtext element', () => { + const setup = () => { + const original = richTextElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + const getRichTextFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isRichTextElement(copy)).toEqual(true); + return copy as RichTextElement; + }; + + it('should return a richtext element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isRichTextElement(result.copyEntity)).toEqual(true); + }); + + it('should copy text', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getRichTextFromStatus(result); + + expect(copy.text).toEqual(original.text); + }); + + it('should copy text', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getRichTextFromStatus(result); + + expect(copy.inputFormat).toEqual(original.inputFormat); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getRichTextFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type RichTextElement', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.RICHTEXT_ELEMENT); + }); + }); + + describe('when copying a file element', () => { + const setup = () => { + const original = fileElementFactory.build(); + + const visitorSetup = setupfileCopyService(); + + visitorSetup.fileCopyService.copyFilesOfParent.mockResolvedValueOnce([ + { id: new ObjectId().toHexString(), sourceId: original.id, name: original.caption }, + ]); + + return { original, ...visitorSetup }; + }; + + const getFileElementFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isFileElement(copy)).toEqual(true); + return copy as FileElement; + }; + + it('should return a file element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isFileElement(result.copyEntity)).toEqual(true); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getFileElementFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should copy caption', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getFileElementFromStatus(result); + + expect(copy.caption).toEqual(original.caption); + }); + + it('should copy alternative text', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getFileElementFromStatus(result); + + expect(copy.alternativeText).toEqual(original.alternativeText); + }); + + it('should call fileCopy Service', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getFileElementFromStatus(result); + + expect(fileCopyService.copyFilesOfParent).toHaveBeenCalledWith({ + sourceParentId: original.id, + targetParentId: copy.id, + parentType: FileRecordParentType.BoardNode, + }); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type FILE_ELEMENT', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.FILE_ELEMENT); + }); + + it('should include file copy status', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + const fileCopyStatus = result.elements?.at(0); + + expect(fileCopyStatus).toEqual( + expect.objectContaining({ + status: CopyStatusEnum.SUCCESS, + type: CopyElementType.FILE, + }) + ); + }); + }); + + describe('copying submission container', () => { + const getSubmissionContainerFromStatus = (status: CopyStatus) => { + const copy = status.copyEntity; + expect(isSubmissionContainerElement(copy)).toEqual(true); + return copy as SubmissionContainerElement; + }; + + describe('when copying an empty submission container element', () => { + const setup = () => { + const original = submissionContainerElementFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + it('should return a submission container element as copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(isSubmissionContainerElement(result.copyEntity)).toEqual(true); + }); + + it('should copy dueDate', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getSubmissionContainerFromStatus(result); + + expect(copy.dueDate).toEqual(original.dueDate); + }); + + it('should create new id', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getSubmissionContainerFromStatus(result); + + expect(copy.id).not.toEqual(original.id); + }); + + it('should show status successful', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.SUCCESS); + }); + + it('should show type SUBMISSION_CONTAINER', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.SUBMISSION_CONTAINER_ELEMENT); + }); + }); + + describe('when copying a card with children', () => { + const setup = () => { + const children = [...submissionItemFactory.buildList(4)]; + const original = submissionContainerElementFactory.build({ children }); + + return { original, ...setupfileCopyService() }; + }; + + it('should NOT add children to copy of columnboard', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getSubmissionContainerFromStatus(result); + + expect(copy.children.length).toEqual(0); + }); + + it('should add status of child copies to copystatus', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.elements?.length).toEqual(original.children.length); + }); + }); + }); + + describe('when copying a submission item', () => { + const setup = () => { + const original = submissionItemFactory.build(); + + return { original, ...setupfileCopyService() }; + }; + + it('should NOT make a copy', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.copyEntity).toBeUndefined(); + }); + + it('should show status not-doing', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.status).toEqual(CopyStatusEnum.NOT_DOING); + }); + + it('should show type SUBMISSION_ITEM', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + + expect(result.type).toEqual(CopyElementType.SUBMISSION_ITEM); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts new file mode 100644 index 00000000000..0d457436f44 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { AnyBoardDo } from '@shared/domain'; +import { CopyStatus } from '@src/modules/copy-helper'; +import { RecursiveCopyVisitor } from './recursive-copy.visitor'; +import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; + +export type BoardDoCopyParams = { + original: AnyBoardDo; + fileCopyService: SchoolSpecificFileCopyService; +}; + +@Injectable() +export class BoardDoCopyService { + public async copy(params: BoardDoCopyParams): Promise { + const visitor = new RecursiveCopyVisitor(params.fileCopyService); + + const result = await visitor.copy(params.original); + + return result; + } +} diff --git a/apps/server/src/modules/board/service/board-do-copy-service/index.ts b/apps/server/src/modules/board/service/board-do-copy-service/index.ts new file mode 100644 index 00000000000..6abe888af24 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/index.ts @@ -0,0 +1,3 @@ +export * from './board-do-copy.service'; +export * from './school-specific-file-copy-service.factory'; +export * from './school-specific-file-copy.interface'; diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts new file mode 100644 index 00000000000..6f30a40b510 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -0,0 +1,198 @@ +import { + AnyBoardDo, + BoardCompositeVisitorAsync, + Card, + Column, + ColumnBoard, + EntityId, + FileElement, + RichTextElement, + SubmissionContainerElement, + SubmissionItem, +} from '@shared/domain'; +import { FileRecordParentType } from '@shared/infra/rabbitmq'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; +import { ObjectId } from 'bson'; +import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; + +export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { + resultMap = new Map(); + + copyMap = new Map(); + + constructor(private readonly fileCopyService: SchoolSpecificFileCopyService) {} + + async copy(original: AnyBoardDo): Promise { + await original.acceptAsync(this); + + const result = this.resultMap.get(original.id); + /* istanbul ignore next */ + if (result === undefined) { + throw new Error('nothing copied'); + } + return result; + } + + async visitColumnBoardAsync(original: ColumnBoard): Promise { + await this.visitChildrenOf(original); + + const copy = new ColumnBoard({ + id: new ObjectId().toHexString(), + title: original.title, + context: original.context, + createdAt: new Date(), + updatedAt: new Date(), + children: this.getCopiesForChildrenOf(original), + }); + + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + elements: this.getCopyStatusesForChildrenOf(original), + }); + this.copyMap.set(original.id, copy); + } + + async visitColumnAsync(original: Column): Promise { + await this.visitChildrenOf(original); + const copy = new Column({ + id: new ObjectId().toHexString(), + title: original.title, + children: this.getCopiesForChildrenOf(original), + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.COLUMN, + status: CopyStatusEnum.SUCCESS, + elements: this.getCopyStatusesForChildrenOf(original), + }); + this.copyMap.set(original.id, copy); + } + + async visitCardAsync(original: Card): Promise { + await this.visitChildrenOf(original); + const copy = new Card({ + id: new ObjectId().toHexString(), + title: original.title, + height: original.height, + children: this.getCopiesForChildrenOf(original), + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.CARD, + status: CopyStatusEnum.SUCCESS, + elements: this.getCopyStatusesForChildrenOf(original), + }); + this.copyMap.set(original.id, copy); + } + + async visitFileElementAsync(original: FileElement): Promise { + const copy = new FileElement({ + id: new ObjectId().toHexString(), + caption: original.caption, + alternativeText: original.alternativeText, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + const fileCopy = await this.fileCopyService.copyFilesOfParent({ + sourceParentId: original.id, + targetParentId: copy.id, + parentType: FileRecordParentType.BoardNode, + }); + const fileCopyStatus = fileCopy.map((copyFileDto) => { + return { + type: CopyElementType.FILE, + status: copyFileDto.id ? CopyStatusEnum.SUCCESS : CopyStatusEnum.FAIL, + title: copyFileDto.name ?? `(old fileid: ${copyFileDto.sourceId})`, + }; + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.FILE_ELEMENT, + status: CopyStatusEnum.SUCCESS, + elements: fileCopyStatus, + }); + this.copyMap.set(original.id, copy); + } + + async visitRichTextElementAsync(original: RichTextElement): Promise { + const copy = new RichTextElement({ + id: new ObjectId().toHexString(), + text: original.text, + inputFormat: original.inputFormat, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.RICHTEXT_ELEMENT, + status: CopyStatusEnum.SUCCESS, + }); + this.copyMap.set(original.id, copy); + + return Promise.resolve(); + } + + async visitSubmissionContainerElementAsync(original: SubmissionContainerElement): Promise { + await this.visitChildrenOf(original); + const copy = new SubmissionContainerElement({ + id: new ObjectId().toHexString(), + dueDate: original.dueDate, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + this.resultMap.set(original.id, { + copyEntity: copy, + type: CopyElementType.SUBMISSION_CONTAINER_ELEMENT, + status: CopyStatusEnum.SUCCESS, + elements: this.getCopyStatusesForChildrenOf(original), + }); + this.copyMap.set(original.id, copy); + } + + async visitSubmissionItemAsync(original: SubmissionItem): Promise { + this.resultMap.set(original.id, { + type: CopyElementType.SUBMISSION_ITEM, + status: CopyStatusEnum.NOT_DOING, + }); + + return Promise.resolve(); + } + + async visitChildrenOf(boardDo: AnyBoardDo) { + return Promise.allSettled(boardDo.children.map((child) => child.acceptAsync(this))); + } + + getCopyStatusesForChildrenOf(original: AnyBoardDo) { + const childstatusses: CopyStatus[] = []; + + original.children.forEach((child) => { + const childStatus = this.resultMap.get(child.id); + if (childStatus) { + childstatusses.push(childStatus); + } + }); + + return childstatusses; + } + + getCopiesForChildrenOf(original: AnyBoardDo) { + const copies: AnyBoardDo[] = []; + original.children.forEach((child) => { + const childCopy = this.copyMap.get(child.id); + if (childCopy) { + copies.push(childCopy); + } + }); + + return copies; + } +} diff --git a/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts new file mode 100644 index 00000000000..9d6eaf1b24e --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts @@ -0,0 +1,112 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; +import { FileRecordParentType } from '@src/modules/files-storage/entity'; +import { ObjectId } from 'bson'; +import { SchoolSpecificFileCopyServiceFactory } from './school-specific-file-copy-service.factory'; +import { SchoolSpecificFileCopyServiceImpl } from './school-specific-file-copy.service'; + +describe('school specific file copy service factory', () => { + let module: TestingModule; + let factory: SchoolSpecificFileCopyServiceFactory; + let adapter: DeepMocked; + + afterAll(async () => { + await module.close(); + }); + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + SchoolSpecificFileCopyServiceFactory, + { + provide: FilesStorageClientAdapterService, + useValue: createMock(), + }, + ], + }).compile(); + factory = module.get(SchoolSpecificFileCopyServiceFactory); + adapter = module.get(FilesStorageClientAdapterService); + }); + + it('should build a school specific file copy service', () => { + const service = factory.build({ + targetSchoolId: new ObjectId().toHexString(), + sourceSchoolId: new ObjectId().toHexString(), + userId: new ObjectId().toHexString(), + }); + + expect(service).toBeInstanceOf(SchoolSpecificFileCopyServiceImpl); + }); + + describe('using created service', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const sourceParentId = new ObjectId().toHexString(); + const targetParentId = new ObjectId().toHexString(); + const parentType = FileRecordParentType.BoardNode; + const sourceSchoolId = new ObjectId().toHexString(); + const targetSchoolId = new ObjectId().toHexString(); + + const service = factory.build({ + targetSchoolId, + sourceSchoolId, + userId, + }); + + const mockResult = [ + { id: new ObjectId().toHexString(), sourceId: new ObjectId().toHexString(), name: 'filename' }, + ]; + adapter.copyFilesOfParent.mockResolvedValue(mockResult); + + return { + service, + mockResult, + userId, + sourceParentId, + targetParentId, + parentType, + sourceSchoolId, + targetSchoolId, + }; + }; + + it('should call FilesStorageClientAdapterService with user and schoolIds', async () => { + const { service, userId, sourceParentId, targetParentId, parentType, sourceSchoolId, targetSchoolId } = setup(); + + await service.copyFilesOfParent({ + sourceParentId, + targetParentId, + parentType, + }); + + expect(adapter.copyFilesOfParent).toHaveBeenCalledWith({ + source: { + parentId: sourceParentId, + parentType, + schoolId: sourceSchoolId, + }, + target: { + parentId: targetParentId, + parentType, + schoolId: targetSchoolId, + }, + userId, + }); + }); + + it('should return result of adapter operation', async () => { + const { service, sourceParentId, targetParentId, parentType, mockResult } = setup(); + + const result = await service.copyFilesOfParent({ + sourceParentId, + targetParentId, + parentType, + }); + + expect(result).toEqual(mockResult); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.ts b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.ts new file mode 100644 index 00000000000..dda07c8c1f9 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; +import { + SchoolSpecificFileCopyService, + SchoolSpecificFileCopyServiceProps, +} from './school-specific-file-copy.interface'; +import { SchoolSpecificFileCopyServiceImpl } from './school-specific-file-copy.service'; + +@Injectable() +export class SchoolSpecificFileCopyServiceFactory { + constructor(private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService) {} + + build(props: SchoolSpecificFileCopyServiceProps): SchoolSpecificFileCopyService { + return new SchoolSpecificFileCopyServiceImpl(this.filesStorageClientAdapterService, props); + } +} diff --git a/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy.interface.ts b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy.interface.ts new file mode 100644 index 00000000000..e3b03d0b059 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy.interface.ts @@ -0,0 +1,19 @@ +import { EntityId } from '@shared/domain'; +import { CopyFileDto } from '@src/modules/files-storage-client/dto'; +import { FileRecordParentType } from '@src/modules/files-storage/entity'; + +export type SchoolSpecificFileCopyServiceCopyParams = { + sourceParentId: EntityId; + targetParentId: EntityId; + parentType: FileRecordParentType; +}; + +export type SchoolSpecificFileCopyServiceProps = { + sourceSchoolId: EntityId; + targetSchoolId: EntityId; + userId: EntityId; +}; + +export interface SchoolSpecificFileCopyService { + copyFilesOfParent(params: SchoolSpecificFileCopyServiceCopyParams): Promise; +} diff --git a/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy.service.ts b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy.service.ts new file mode 100644 index 00000000000..c162dbafdc7 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy.service.ts @@ -0,0 +1,30 @@ +import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; +import { CopyFileDto } from '@src/modules/files-storage-client/dto'; +import { + SchoolSpecificFileCopyService, + SchoolSpecificFileCopyServiceCopyParams, + SchoolSpecificFileCopyServiceProps, +} from './school-specific-file-copy.interface'; + +export class SchoolSpecificFileCopyServiceImpl implements SchoolSpecificFileCopyService { + constructor( + private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, + private readonly props: SchoolSpecificFileCopyServiceProps + ) {} + + public async copyFilesOfParent(params: SchoolSpecificFileCopyServiceCopyParams): Promise { + return this.filesStorageClientAdapterService.copyFilesOfParent({ + source: { + parentId: params.sourceParentId, + parentType: params.parentType, + schoolId: this.props.sourceSchoolId, + }, + target: { + parentId: params.targetParentId, + parentType: params.parentType, + schoolId: this.props.targetSchoolId, + }, + userId: this.props.userId, + }); + } +} 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 new file mode 100644 index 00000000000..6824b82e133 --- /dev/null +++ b/apps/server/src/modules/board/service/column-board-copy.service.spec.ts @@ -0,0 +1,171 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType, ColumnBoard, UserDO } from '@shared/domain'; +import { CourseRepo } from '@shared/repo'; +import { columnBoardFactory, courseFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; +import { UserService } from '@src/modules/user'; +import { BoardDoRepo } from '../repo'; +import { + BoardDoCopyService, + SchoolSpecificFileCopyService, + SchoolSpecificFileCopyServiceFactory, +} from './board-do-copy-service'; +import { ColumnBoardCopyService } from './column-board-copy.service'; + +describe('column board copy service', () => { + let module: TestingModule; + let service: ColumnBoardCopyService; + let doCopyService: DeepMocked; + let boardRepo: DeepMocked; + let userService: DeepMocked; + let courseRepo: DeepMocked; + let fileCopyServiceFactory: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: BoardDoCopyService, + useValue: createMock(), + }, + { + provide: BoardDoRepo, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: CourseRepo, + useValue: createMock(), + }, + { + provide: SchoolSpecificFileCopyServiceFactory, + useValue: createMock(), + }, + ColumnBoardCopyService, + ], + }).compile(); + + service = module.get(ColumnBoardCopyService); + doCopyService = module.get(BoardDoCopyService); + boardRepo = module.get(BoardDoRepo); + userService = module.get(UserService); + courseRepo = module.get(CourseRepo); + fileCopyServiceFactory = module.get(SchoolSpecificFileCopyServiceFactory); + + await setupEntities(); + }); + + describe('when copying a column board', () => { + const setup = () => { + const originalSchool = schoolFactory.buildWithId(); + const targetSchool = schoolFactory.buildWithId(); + const course = courseFactory.buildWithId({ school: originalSchool }); + const originalExternalReference = { + id: course.id, + type: BoardExternalReferenceType.Course, + }; + const originalBoard = columnBoardFactory.build({ + context: originalExternalReference, + }); + + const targetCourse = courseFactory.buildWithId(); + const destinationExternalReference = { + id: targetCourse.id, + type: BoardExternalReferenceType.Course, + }; + + const boardCopy = columnBoardFactory.build({ + context: originalExternalReference, + }); + + const expectedBoardCopy = { + ...boardCopy, + props: { ...boardCopy.getProps(), context: destinationExternalReference }, + }; + + const resultCopyStatus: CopyStatus = { + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + copyEntity: boardCopy, + }; + + const expectedCopyStatus = { + ...resultCopyStatus, + copyEntity: expectedBoardCopy, + }; + + const user = userFactory.buildWithId({ school: targetSchool }); + + const fileCopyServiceMock = createMock(); + fileCopyServiceFactory.build.mockReturnValue(fileCopyServiceMock); + + boardRepo.findByClassAndId.mockResolvedValue(originalBoard); + courseRepo.findById.mockResolvedValue(course); + userService.findById.mockResolvedValue({ schoolId: user.school.id } as UserDO); + doCopyService.copy.mockResolvedValue(resultCopyStatus); + + return { + course, + originalBoard, + destinationExternalReference, + user, + resultCopyStatus, + boardCopy, + expectedBoardCopy, + expectedCopyStatus, + fileCopyServiceMock, + }; + }; + + it('should get column board do', async () => { + const { originalBoard, destinationExternalReference, user } = setup(); + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(boardRepo.findByClassAndId).toHaveBeenCalledWith(ColumnBoard, originalBoard.id); + }); + + it('should call copyService with column board do', async () => { + const { fileCopyServiceMock, originalBoard, destinationExternalReference, user } = setup(); + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(doCopyService.copy).toHaveBeenCalledWith({ + fileCopyService: fileCopyServiceMock, + original: originalBoard, + }); + }); + + it('should persist copy of board, with replaced externalReference', async () => { + const { originalBoard, destinationExternalReference, user, expectedBoardCopy } = setup(); + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(boardRepo.save).toHaveBeenCalledWith(expectedBoardCopy); + }); + + it('should return copyStatus', async () => { + const { originalBoard, destinationExternalReference, user, expectedCopyStatus } = setup(); + const result = await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(result).toEqual(expectedCopyStatus); + }); + }); +}); 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 new file mode 100644 index 00000000000..c57e01d6c26 --- /dev/null +++ b/apps/server/src/modules/board/service/column-board-copy.service.ts @@ -0,0 +1,57 @@ +import { Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; +import { + BoardExternalReference, + BoardExternalReferenceType, + ColumnBoard, + EntityId, + isColumnBoard, +} from '@shared/domain'; +import { CourseRepo } from '@shared/repo'; +import { CopyStatus } from '@src/modules/copy-helper'; +import { UserService } from '@src/modules/user'; +import { BoardDoRepo } from '../repo'; +import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './board-do-copy-service'; + +@Injectable() +export class ColumnBoardCopyService { + constructor( + private readonly boardDoRepo: BoardDoRepo, + private readonly courseRepo: CourseRepo, + private readonly userService: UserService, + private readonly boardDoCopyService: BoardDoCopyService, + private readonly fileCopyServiceFactory: SchoolSpecificFileCopyServiceFactory + ) {} + + async copyColumnBoard(props: { + originalColumnBoardId: EntityId; + destinationExternalReference: BoardExternalReference; + userId: EntityId; + }): Promise { + const originalBoard = await this.boardDoRepo.findByClassAndId(ColumnBoard, props.originalColumnBoardId); + + const user = await this.userService.findById(props.userId); + /* istanbul ignore next */ + if (originalBoard.context.type !== BoardExternalReferenceType.Course) { + throw new NotImplementedException('only courses are supported as board parents'); + } + const course = await this.courseRepo.findById(originalBoard.context.id); // TODO: get rid of this + + const fileCopyService = this.fileCopyServiceFactory.build({ + sourceSchoolId: course.school.id, + targetSchoolId: user.schoolId, + userId: props.userId, + }); + + const copyStatus = await this.boardDoCopyService.copy({ original: originalBoard, fileCopyService }); + + /* istanbul ignore next */ + if (!isColumnBoard(copyStatus.copyEntity)) { + throw new InternalServerErrorException('expected copy of columnboard to be a columnboard'); + } + + copyStatus.copyEntity.context = props.destinationExternalReference; + await this.boardDoRepo.save(copyStatus.copyEntity); + + return copyStatus; + } +} diff --git a/apps/server/src/modules/copy-helper/service/copy-helper.service.ts b/apps/server/src/modules/copy-helper/service/copy-helper.service.ts index e330ab4fd01..b3a42dac7df 100644 --- a/apps/server/src/modules/copy-helper/service/copy-helper.service.ts +++ b/apps/server/src/modules/copy-helper/service/copy-helper.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { BaseEntity } from '@shared/domain/entity'; +import { AuthorizableObject } from '@shared/domain/domain-object'; import { EntityId } from '@shared/domain/types'; -import { CopyStatus, CopyStatusEnum } from '../types/copy.types'; +import { CopyDictionary, CopyStatus, CopyStatusEnum } from '../types/copy.types'; const isAtLeastPartialSuccessfull = (status) => status === CopyStatusEnum.PARTIAL || status === CopyStatusEnum.SUCCESS; @@ -32,7 +32,7 @@ export class CopyHelperService { let num = 1; const matches = name.match(/^(?.*) \((?\d+)\)$/); if (matches && matches.groups) { - name = matches.groups.name; + ({ name } = matches.groups); num = Number(matches.groups.number) + 1; } const composedName = `${name} (${num})`; @@ -42,8 +42,8 @@ export class CopyHelperService { return composedName; } - buildCopyEntityDict(status: CopyStatus): Map { - const map = new Map(); + buildCopyEntityDict(status: CopyStatus): CopyDictionary { + const map = new Map(); status.elements?.forEach((elementStatus: CopyStatus) => { this.buildCopyEntityDict(elementStatus).forEach((el, key) => map.set(key, el)); }); diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index 33456356743..551aad0a010 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -1,4 +1,5 @@ -import { BaseEntity } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain'; +import { AuthorizableObject } from '@shared/domain/domain-object'; export type CopyStatus = { id?: string; @@ -6,16 +7,20 @@ export type CopyStatus = { type: CopyElementType; status: CopyStatusEnum; elements?: CopyStatus[]; - copyEntity?: BaseEntity; - originalEntity?: BaseEntity; + copyEntity?: AuthorizableObject; + originalEntity?: AuthorizableObject; }; export enum CopyElementType { 'BOARD' = 'BOARD', + 'CARD' = 'CARD', + 'COLUMN' = 'COLUMN', + 'COLUMNBOARD' = 'COLUMNBOARD', 'CONTENT' = 'CONTENT', 'COURSE' = 'COURSE', 'COURSEGROUP_GROUP' = 'COURSEGROUP_GROUP', 'FILE' = 'FILE', + 'FILE_ELEMENT' = 'FILE_ELEMENT', 'FILE_GROUP' = 'FILE_GROUP', 'LEAF' = 'LEAF', 'LESSON' = 'LESSON', @@ -30,6 +35,9 @@ export enum CopyElementType { 'LERNSTORE_MATERIAL_GROUP' = 'LERNSTORE_MATERIAL_GROUP', 'LTITOOL_GROUP' = 'LTITOOL_GROUP', 'METADATA' = 'METADATA', + 'RICHTEXT_ELEMENT' = 'RICHTEXT_ELEMENT', + 'SUBMISSION_CONTAINER_ELEMENT' = 'SUBMISSION_CONTAINER_ELEMENT', + 'SUBMISSION_ITEM' = 'SUBMISSION_ITEM', 'SUBMISSION_GROUP' = 'SUBMISSION_GROUP', 'TASK' = 'TASK', 'TASK_GROUP' = 'TASK_GROUP', @@ -44,3 +52,5 @@ export enum CopyStatusEnum { 'NOT_IMPLEMENTED' = 'not-implemented', // might be implemented in the future 'PARTIAL' = 'partial', // parent is partial successful } + +export type CopyDictionary = Map; 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 20bdfb20ad3..87542f7efb2 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,9 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Board } from '@shared/domain'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; import { BoardRepo } from '@shared/repo'; import { boardFactory, + columnboardBoardElementFactory, + columnBoardFactory, + columnBoardTargetFactory, courseFactory, lessonBoardElementFactory, lessonFactory, @@ -13,6 +17,7 @@ import { userFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { ColumnBoardCopyService } from '@src/modules/board/service/column-board-copy.service'; import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { LessonCopyService } from '@src/modules/lesson/service'; import { TaskCopyService } from '@src/modules/task/service'; @@ -23,6 +28,7 @@ describe('board copy service', () => { let copyService: BoardCopyService; let taskCopyService: DeepMocked; let lessonCopyService: DeepMocked; + let columnBoardCopyService: DeepMocked; let copyHelperService: DeepMocked; let boardRepo: DeepMocked; @@ -43,6 +49,10 @@ describe('board copy service', () => { provide: LessonCopyService, useValue: createMock(), }, + { + provide: ColumnBoardCopyService, + useValue: createMock(), + }, { provide: CopyHelperService, useValue: createMock(), @@ -62,6 +72,7 @@ describe('board copy service', () => { taskCopyService = module.get(TaskCopyService); lessonCopyService = module.get(LessonCopyService); copyHelperService = module.get(CopyHelperService); + columnBoardCopyService = module.get(ColumnBoardCopyService); boardRepo = module.get(BoardRepo); boardRepo.save = jest.fn(); }); @@ -224,6 +235,63 @@ describe('board copy service', () => { }); }); + describe('when board contains column board', () => { + const setup = () => { + const originalColumnBoard = columnBoardFactory.build(); + const columnBoardTarget = columnBoardTargetFactory.build({ + columnBoardId: originalColumnBoard.id, + title: originalColumnBoard.title, + }); + const columBoardElement = columnboardBoardElementFactory.build({ target: columnBoardTarget }); + const destinationCourse = courseFactory.buildWithId(); + const originalBoard = boardFactory.buildWithId({ references: [columBoardElement], course: destinationCourse }); + const user = userFactory.buildWithId(); + const copyOfColumnBoard = columnBoardFactory.build(); + + columnBoardCopyService.copyColumnBoard.mockResolvedValue({ + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + copyEntity: copyOfColumnBoard, + originalEntity: originalColumnBoard, + title: copyOfColumnBoard.title, + }); + + return { destinationCourse, originalBoard, user, originalColumnBoard }; + }; + + it('should call columnBoardCopyService with original columnBoard', async () => { + const { destinationCourse, originalBoard, user, originalColumnBoard } = setup(); + + const expected = { + originalColumnBoardId: originalColumnBoard.id, + destinationExternalReference: { + type: BoardExternalReferenceType.Course, + id: destinationCourse.id, + }, + userId: user.id, + }; + + await copyService.copyBoard({ originalBoard, user, destinationCourse }); + expect(columnBoardCopyService.copyColumnBoard).toHaveBeenCalledWith(expected); + }); + + it('should add columnBoard copy to board copy', async () => { + const { destinationCourse, originalBoard, user } = setup(); + const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const board = status.copyEntity as Board; + + expect(board.getElements().length).toEqual(1); + }); + + it('should add status of columnBoard copy to board copy status', async () => { + const { destinationCourse, originalBoard, user } = setup(); + const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + const lessonStatus = status.elements?.find((el) => el.type === CopyElementType.COLUMNBOARD); + + expect(lessonStatus).toBeDefined(); + }); + }); + describe('derive status from elements', () => { const setup = () => { const originalTask = taskFactory.build(); @@ -293,5 +361,35 @@ describe('board copy service', () => { expect(board.references).toHaveLength(0); }); }); + + describe('when persist fails', () => { + const setup = () => { + const originalLesson = lessonFactory.buildWithId(); + const lessonElement = lessonBoardElementFactory.buildWithId({ target: originalLesson }); + const destinationCourse = courseFactory.buildWithId(); + const originalBoard = boardFactory.buildWithId({ references: [lessonElement], course: destinationCourse }); + const user = userFactory.buildWithId(); + 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); + boardRepo.save.mockRejectedValue(new Error()); + + return { destinationCourse, originalBoard, user, originalLesson }; + }; + + it('should return status fail', async () => { + const { destinationCourse, originalBoard, user } = setup(); + + const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); + + expect(status.status).toEqual(CopyStatusEnum.FAIL); + }); + }); }); }); 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 1fc4e981235..da31f4cef4e 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.ts @@ -3,7 +3,12 @@ import { Board, BoardElement, BoardElementType, + BoardExternalReferenceType, + ColumnBoard, + ColumnboardBoardElement, + ColumnBoardTarget, Course, + isColumnBoardTarget, isLesson, isTask, LessonEntity, @@ -14,7 +19,8 @@ import { } from '@shared/domain'; import { BoardRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; -import { CopyElementType, CopyHelperService, CopyStatus } from '@src/modules/copy-helper'; +import { ColumnBoardCopyService } from '@src/modules/board/service/column-board-copy.service'; +import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; import { getResolvedValues } from '@src/modules/files-storage/helper'; import { LessonCopyService } from '@src/modules/lesson/service'; import { TaskCopyService } from '@src/modules/task/service'; @@ -33,6 +39,7 @@ export class BoardCopyService { private readonly boardRepo: BoardRepo, private readonly taskCopyService: TaskCopyService, private readonly lessonCopyService: LessonCopyService, + private readonly columnBoardCopyService: ColumnBoardCopyService, private readonly copyHelperService: CopyHelperService ) {} @@ -57,7 +64,13 @@ export class BoardCopyService { if (status.copyEntity) { boardCopy = status.copyEntity as Board; } - await this.boardRepo.save(boardCopy); + + try { + await this.boardRepo.save(boardCopy); + } catch (err) { + this.logger.warn(err); + status.status = CopyStatusEnum.FAIL; + } return status; } @@ -80,6 +93,10 @@ export class BoardCopyService { return this.copyLesson(element.target, user, destinationCourse).then((status) => [pos, status]); } + if (element.boardElementType === BoardElementType.ColumnBoard && isColumnBoardTarget(element.target)) { + return this.copyColumnBoard(element.target, user, destinationCourse).then((status) => [pos, status]); + } + /* istanbul ignore next */ this.logger.warn(`BoardCopyService unable to handle boardElementType.`); /* istanbul ignore next */ @@ -108,6 +125,21 @@ export class BoardCopyService { }); } + private async copyColumnBoard( + columnBoardTarget: ColumnBoardTarget, + user: User, + destinationCourse: Course + ): Promise { + return this.columnBoardCopyService.copyColumnBoard({ + originalColumnBoardId: columnBoardTarget.columnBoardId, + userId: user.id, + destinationExternalReference: { + id: destinationCourse.id, + type: BoardExternalReferenceType.Course, + }, + }); + } + private extractReferences(statuses: CopyStatus[]): BoardElement[] { const references: BoardElement[] = []; statuses.forEach((status) => { @@ -119,6 +151,12 @@ export class BoardCopyService { const lessonElement = new LessonBoardElement({ target: status.copyEntity }); references.push(lessonElement); } + if (status.copyEntity instanceof ColumnBoard) { + const columnBoardElement = new ColumnboardBoardElement({ + target: new ColumnBoardTarget({ columnBoardId: status.copyEntity.id, title: status.copyEntity.title }), + }); + references.push(columnBoardElement); + } }); return references; } diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts index 18c965b9f56..39f3f07882a 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts @@ -14,6 +14,7 @@ import { LessonEntity, Material, } from '@shared/domain'; +import { AuthorizableObject } from '@shared/domain/domain-object'; import { LessonRepo } from '@shared/repo'; import { courseFactory, @@ -85,7 +86,7 @@ describe('lesson copy service', () => { fileCopyStatus: { type: CopyElementType.FILE_GROUP, status: CopyStatusEnum.SUCCESS }, }); copyHelperService = module.get(CopyHelperService); - const map: Map = new Map(); + const map: Map = new Map(); copyHelperService.buildCopyEntityDict.mockReturnValue(map); etherpadService = module.get(EtherpadService); nexboardService = module.get(NexboardService); @@ -1355,7 +1356,7 @@ describe('lesson copy service', () => { it('should keep original url when task is not in dictionary (has not been copied)', () => { const { copyStatus, originalTask } = setup(); - copyHelperService.buildCopyEntityDict.mockReturnValue(new Map()); + copyHelperService.buildCopyEntityDict.mockReturnValue(new Map()); const copyDict = copyHelperService.buildCopyEntityDict(copyStatus); const updatedCopyStatus = copyService.updateCopiedEmbeddedTasks(copyStatus, copyDict); const lesson = updatedCopyStatus?.copyEntity as LessonEntity; diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.ts index a53c48a8eab..4a6835b05f2 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.ts @@ -1,9 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { Injectable } from '@nestjs/common'; import { - BaseEntity, ComponentType, - EntityId, IComponentEtherpadProperties, IComponentGeogebraProperties, IComponentLernstoreProperties, @@ -14,7 +12,13 @@ import { Material, } from '@shared/domain'; import { LessonRepo } from '@shared/repo'; -import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@src/modules/copy-helper'; +import { + CopyDictionary, + CopyElementType, + CopyHelperService, + CopyStatus, + CopyStatusEnum, +} from '@src/modules/copy-helper'; import { CopyFilesService } from '@src/modules/files-storage-client'; import { FileUrlReplacement } from '@src/modules/files-storage-client/service/copy-files.service'; import { TaskCopyService } from '@src/modules/task/service/task-copy.service'; @@ -104,7 +108,7 @@ export class LessonCopyService { return { status, elements }; } - updateCopiedEmbeddedTasks(lessonStatus: CopyStatus, copyDict: Map): CopyStatus { + updateCopiedEmbeddedTasks(lessonStatus: CopyStatus, copyDict: CopyDictionary): CopyStatus { const copiedLesson = lessonStatus.copyEntity as LessonEntity; if (copiedLesson?.contents === undefined) { @@ -122,7 +126,7 @@ export class LessonCopyService { private updateCopiedEmbeddedTaskId = ( value: IComponentProperties, - copyDict: Map + copyDict: CopyDictionary ): IComponentProperties => { if (value.component !== ComponentType.INTERNAL || value.content === undefined || value.content.url === undefined) { return value; diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index 08e60158989..a80703cf14b 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -42,3 +42,7 @@ export interface CardProps extends BoardCompositeProps { title: string; height: number; } + +export function isCard(reference: unknown): reference is Card { + return reference instanceof Card; +} diff --git a/apps/server/src/shared/domain/domainobject/board/column-board.do.ts b/apps/server/src/shared/domain/domainobject/board/column-board.do.ts index 02f8622c382..decd3c23d6f 100644 --- a/apps/server/src/shared/domain/domainobject/board/column-board.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/column-board.do.ts @@ -37,3 +37,7 @@ export interface ColumnBoardProps extends BoardCompositeProps { title: string; context: BoardExternalReference; } + +export function isColumnBoard(reference: unknown): reference is ColumnBoard { + return reference instanceof ColumnBoard; +} diff --git a/apps/server/src/shared/domain/domainobject/board/column.do.ts b/apps/server/src/shared/domain/domainobject/board/column.do.ts index e26294575af..ffc79078612 100644 --- a/apps/server/src/shared/domain/domainobject/board/column.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/column.do.ts @@ -28,3 +28,7 @@ export class Column extends BoardComposite { export interface ColumnProps extends BoardCompositeProps { title: string; } + +export function isColumn(reference: unknown): reference is Column { + return reference instanceof Column; +} diff --git a/apps/server/src/shared/domain/domainobject/board/file-element.do.ts b/apps/server/src/shared/domain/domainobject/board/file-element.do.ts index 86669f2cfe6..4008c2bf3db 100644 --- a/apps/server/src/shared/domain/domainobject/board/file-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/file-element.do.ts @@ -35,3 +35,7 @@ export interface FileElementProps extends BoardCompositeProps { caption: string; alternativeText: string; } + +export function isFileElement(reference: unknown): reference is FileElement { + return reference instanceof FileElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/rich-text-element.do.ts b/apps/server/src/shared/domain/domainobject/board/rich-text-element.do.ts index 39cc8b1b6fd..0603b384b54 100644 --- a/apps/server/src/shared/domain/domainobject/board/rich-text-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/rich-text-element.do.ts @@ -36,3 +36,7 @@ export interface RichTextElementProps extends BoardCompositeProps { text: string; inputFormat: InputFormat; } + +export function isRichTextElement(reference: unknown): reference is RichTextElement { + return reference instanceof RichTextElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts index 3023e5c9833..3b9a85600c6 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-container-element.do.ts @@ -28,3 +28,7 @@ export class SubmissionContainerElement extends BoardComposite