From 0b4dacc041c7f2fb7489c00b1b5650b2e34ad288 Mon Sep 17 00:00:00 2001 From: Martin Schuhmacher <55735359+MartinSchuhmacher@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:44:45 +0100 Subject: [PATCH] BC-8193 - Expanding and registering BBB (#5354) * implementing board videoconf in VC service with tests * recent state for implementing board authorization in VC * implementing VC element and room + VC element scope * adjusting VC scope to board element * adding VC response and node * add school id indexes * adding yellow to room color selector * seting type alias for better maintainability * implementing FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED for FE usage --------- Co-authored-by: Uwe Ilgenstein --- .../board/controller/card.controller.ts | 5 +- .../controller/dto/card/card.response.ts | 5 +- .../element/any-content-element.response.ts | 4 +- .../board/controller/dto/element/index.ts | 1 + .../update-element-content.body.params.ts | 23 +- .../video-conference-element.response.ts | 33 + .../board/controller/element.controller.ts | 6 +- ...ive-text-editor-element-response.mapper.ts | 2 +- .../content-element-response.factory.spec.ts | 10 + .../content-element-response.factory.ts | 2 + .../mapper/drawing-element-response.mapper.ts | 2 +- .../external-tool-element-response.mapper.ts | 2 +- .../mapper/file-element-response.mapper.ts | 2 +- .../modules/board/controller/mapper/index.ts | 1 + .../mapper/link-element-response.mapper.ts | 2 +- .../rich-text-element-response.mapper.ts | 2 +- ...ssion-container-element-response.mapper.ts | 2 +- ...ideo-conference-element-response.mapper.ts | 30 + .../board/domain/board-node.factory.ts | 7 + apps/server/src/modules/board/domain/index.ts | 1 + .../modules/board/domain/type-mapping.spec.ts | 2 + .../src/modules/board/domain/type-mapping.ts | 2 + .../board/domain/types/any-content-element.ts | 7 +- .../board/domain/types/board-node-props.ts | 5 + .../domain/types/board-node-type.enum.ts | 1 + .../domain/types/content-element-type.enum.ts | 1 + .../video-conference-element.do.spec.ts | 44 + .../domain/video-conference-element.do.ts | 19 + .../board-node-copy-general.service.spec.ts | 14 + .../board-node-copy-specific.service.spec.ts | 26 + .../internal/board-node-copy.service.ts | 14 + .../content-element-update.service.spec.ts | 13 + .../content-element-update.service.ts | 9 + .../src/modules/board/testing/entity/index.ts | 1 + ...video-conference-element-entity.factory.ts | 19 + .../server/src/modules/board/testing/index.ts | 1 + .../video-conference-element.factory.ts | 19 + .../cards-api-client/.openapi-generator/FILES | 2 + .../models/content-element-type.ts | 26 +- .../cards-api-client/models/index.ts | 2 + .../video-conference-element-content.ts | 27 + .../video-conference-element-response.ts | 55 + .../video-conference-element-content.dto.ts | 7 + .../video-conference-element-response.dto.ts | 25 + .../enums/content-element-type.enum.ts | 1 + .../mapper/card-response.mapper.spec.ts | 8 +- .../mapper/card-response.mapper.ts | 15 + .../types/card-content-elements-inner.type.ts | 2 + .../card-response-elements-inner.type.ts | 4 +- .../modules/copy-helper/types/copy.types.ts | 1 + .../repo/entity/room-membership.entity.ts | 3 +- .../room/domain/type/room-color.enum.ts | 1 + .../modules/room/repo/entity/room.entity.ts | 3 +- .../modules/server/api/dto/config.response.ts | 4 + .../server/api/test/server.api.spec.ts | 1 + .../src/modules/server/server.config.ts | 4 + ...ts => video-conference-course.api.spec.ts} | 2 +- .../video-conference-room.api.spec.ts | 1123 ++++++++++++++ ...rence-video-conference-element.api.spec.ts | 1302 +++++++++++++++++ .../service/video-conference.service.spec.ts | 614 +++++++- .../service/video-conference.service.ts | 109 +- .../uc/dto/scope-info.interface.ts | 3 +- .../uc/video-conference-create.uc.spec.ts | 4 +- .../uc/video-conference-create.uc.ts | 4 +- .../uc/video-conference-deprecated.uc.spec.ts | 4 +- .../uc/video-conference-deprecated.uc.ts | 4 +- .../uc/video-conference-end.uc.spec.ts | 4 +- .../uc/video-conference-end.uc.ts | 4 +- .../uc/video-conference-info.uc.spec.ts | 10 +- .../uc/video-conference-info.uc.ts | 4 +- .../video-conference.module.ts | 8 + .../domain/entity/video-conference.entity.ts | 2 + .../interface/video-conference-scope.enum.ts | 2 + .../videoconference/video-conference.repo.ts | 4 + config/default.schema.json | 5 + config/development.json | 1 + 76 files changed, 3661 insertions(+), 82 deletions(-) create mode 100644 apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts create mode 100644 apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts create mode 100644 apps/server/src/modules/board/domain/video-conference-element.do.spec.ts create mode 100644 apps/server/src/modules/board/domain/video-conference-element.do.ts create mode 100644 apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts create mode 100644 apps/server/src/modules/board/testing/video-conference-element.factory.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts create mode 100644 apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts rename apps/server/src/modules/video-conference/controller/api-test/{video-conference.api.spec.ts => video-conference-course.api.spec.ts} (99%) create mode 100644 apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts create mode 100644 apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index 639a5175a3b..9ddd7190855 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -31,6 +31,7 @@ import { RenameBodyParams, RichTextElementResponse, SubmissionContainerElementResponse, + VideoConferenceElementResponse, } from './dto'; import { SetHeightBodyParams } from './dto/board/set-height.body.params'; import { CardResponseMapper, ContentElementResponseFactory } from './mapper'; @@ -124,7 +125,8 @@ export class CardController { RichTextElementResponse, SubmissionContainerElementResponse, DrawingElementResponse, - DeletedElementResponse + DeletedElementResponse, + VideoConferenceElementResponse ) @ApiResponse({ status: 201, @@ -137,6 +139,7 @@ export class CardController { { $ref: getSchemaPath(SubmissionContainerElementResponse) }, { $ref: getSchemaPath(DrawingElementResponse) }, { $ref: getSchemaPath(DeletedElementResponse) }, + { $ref: getSchemaPath(VideoConferenceElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/dto/card/card.response.ts b/apps/server/src/modules/board/controller/dto/card/card.response.ts index aa641fdd736..9e9be2b7262 100644 --- a/apps/server/src/modules/board/controller/dto/card/card.response.ts +++ b/apps/server/src/modules/board/controller/dto/card/card.response.ts @@ -10,6 +10,7 @@ import { LinkElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, + VideoConferenceElementResponse, } from '../element'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; @@ -22,7 +23,8 @@ import { VisibilitySettingsResponse } from './visibility-settings.response'; DrawingElementResponse, SubmissionContainerElementResponse, CollaborativeTextEditorElementResponse, - DeletedElementResponse + DeletedElementResponse, + VideoConferenceElementResponse ) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { @@ -58,6 +60,7 @@ export class CardResponse { { $ref: getSchemaPath(DrawingElementResponse) }, { $ref: getSchemaPath(CollaborativeTextEditorElementResponse) }, { $ref: getSchemaPath(DeletedElementResponse) }, + { $ref: getSchemaPath(VideoConferenceElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts index dbe2adc1e01..24dacde8196 100644 --- a/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/any-content-element.response.ts @@ -6,6 +6,7 @@ import { FileElementResponse } from './file-element.response'; import { LinkElementResponse } from './link-element.response'; import { RichTextElementResponse } from './rich-text-element.response'; import { SubmissionContainerElementResponse } from './submission-container-element.response'; +import { VideoConferenceElementResponse } from './video-conference-element.response'; export type AnyContentElementResponse = | FileElementResponse @@ -15,7 +16,8 @@ export type AnyContentElementResponse = | ExternalToolElementResponse | DrawingElementResponse | CollaborativeTextEditorElementResponse - | DeletedElementResponse; + | DeletedElementResponse + | VideoConferenceElementResponse; export const isFileElementResponse = (element: AnyContentElementResponse): element is FileElementResponse => element instanceof FileElementResponse; diff --git a/apps/server/src/modules/board/controller/dto/element/index.ts b/apps/server/src/modules/board/controller/dto/element/index.ts index 0a85fb2c699..3246aee6bed 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -8,4 +8,5 @@ export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; export * from './update-element-content.body.params'; +export * from './video-conference-element.response'; export * from './deleted-element.response'; 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 efebfda510c..1a662dabd3c 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 @@ -136,13 +136,29 @@ export class ExternalToolElementContentBody extends ElementContentBody { content!: ExternalToolContentBody; } +export class VideoConferenceContentBody { + @IsString() + @ApiProperty() + title!: string; +} + +export class VideoConferenceElementContentBody extends ElementContentBody { + @ApiProperty({ type: ContentElementType.VIDEO_CONFERENCE }) + type!: ContentElementType.VIDEO_CONFERENCE; + + @ValidateNested() + @ApiProperty() + content!: VideoConferenceContentBody; +} + export type AnyElementContentBody = | FileContentBody | DrawingContentBody | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody - | ExternalToolContentBody; + | ExternalToolContentBody + | VideoConferenceContentBody; export class UpdateElementContentBodyParams { @ValidateNested() @@ -156,6 +172,7 @@ export class UpdateElementContentBodyParams { { value: SubmissionContainerElementContentBody, name: ContentElementType.SUBMISSION_CONTAINER }, { value: ExternalToolElementContentBody, name: ContentElementType.EXTERNAL_TOOL }, { value: DrawingElementContentBody, name: ContentElementType.DRAWING }, + { value: VideoConferenceElementContentBody, name: ContentElementType.VIDEO_CONFERENCE }, ], }, keepDiscriminatorProperty: true, @@ -168,6 +185,7 @@ export class UpdateElementContentBodyParams { { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, { $ref: getSchemaPath(ExternalToolElementContentBody) }, { $ref: getSchemaPath(DrawingElementContentBody) }, + { $ref: getSchemaPath(VideoConferenceElementContentBody) }, ], }) data!: @@ -176,5 +194,6 @@ export class UpdateElementContentBodyParams { | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody - | DrawingElementContentBody; + | DrawingElementContentBody + | VideoConferenceElementContentBody; } diff --git a/apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts b/apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts new file mode 100644 index 00000000000..8eb6495e710 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/video-conference-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '../../../domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class VideoConferenceElementContent { + constructor({ title }: VideoConferenceElementContent) { + this.title = title; + } + + @ApiProperty() + title: string; +} + +export class VideoConferenceElementResponse { + constructor({ id, content, timestamps, type }: VideoConferenceElementResponse) { + this.id = id; + this.timestamps = timestamps; + this.type = type; + this.content = content; + } + + @ApiProperty({ pattern: '[a-f0-9]{24}' }) + id: string; + + @ApiProperty({ enum: ContentElementType, enumName: 'ContentElementType' }) + type: ContentElementType.VIDEO_CONFERENCE; + + @ApiProperty() + timestamps: TimestampsResponse; + + @ApiProperty() + content: VideoConferenceElementContent; +} diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index a03bcea0126..0e7d8cf750a 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -35,6 +35,8 @@ import { SubmissionContainerElementResponse, SubmissionItemResponse, UpdateElementContentBodyParams, + VideoConferenceElementContentBody, + VideoConferenceElementResponse, } from './dto'; import { ContentElementResponseFactory, SubmissionItemResponseMapper } from './mapper'; @@ -71,7 +73,8 @@ export class ElementController { SubmissionContainerElementContentBody, ExternalToolElementContentBody, LinkElementContentBody, - DrawingElementContentBody + DrawingElementContentBody, + VideoConferenceElementContentBody ) @ApiResponse({ status: 200, @@ -83,6 +86,7 @@ export class ElementController { { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, { $ref: getSchemaPath(DrawingElementResponse) }, + { $ref: getSchemaPath(VideoConferenceElementResponse) }, ], }, }) diff --git a/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts index d6dca93b18f..9b70877f522 100644 --- a/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/collaborative-text-editor-element-response.mapper.ts @@ -25,7 +25,7 @@ export class CollaborativeTextEditorElementResponseMapper implements BaseRespons return result; } - canMap(element: CollaborativeTextEditorElement): boolean { + canMap(element: unknown): boolean { return element instanceof CollaborativeTextEditorElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts index d5c4942a777..90237757717 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.spec.ts @@ -6,6 +6,7 @@ import { linkElementFactory, richTextElementFactory, submissionContainerElementFactory, + videoConferenceElementFactory, } from '../../testing'; import { DeletedElementResponse, @@ -14,6 +15,7 @@ import { LinkElementResponse, RichTextElementResponse, SubmissionContainerElementResponse, + VideoConferenceElementResponse, } from '../dto'; import { ContentElementResponseFactory } from './content-element-response.factory'; @@ -65,6 +67,14 @@ describe(ContentElementResponseFactory.name, () => { expect(result).toBeInstanceOf(DeletedElementResponse); }); + it('should return instance of VideoConferenceElementResponse', () => { + const videoConferenceElement = videoConferenceElementFactory.build(); + + const result = ContentElementResponseFactory.mapToResponse(videoConferenceElement); + + expect(result).toBeInstanceOf(VideoConferenceElementResponse); + }); + it('should throw NotImplementedException', () => { // @ts-expect-error check unknown type expect(() => ContentElementResponseFactory.mapToResponse('UNKNOWN')).toThrow(NotImplementedException); diff --git a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts index dec7e12420e..c2eafa55de8 100644 --- a/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts +++ b/apps/server/src/modules/board/controller/mapper/content-element-response.factory.ts @@ -16,6 +16,7 @@ import { FileElementResponseMapper } from './file-element-response.mapper'; import { LinkElementResponseMapper } from './link-element-response.mapper'; import { RichTextElementResponseMapper } from './rich-text-element-response.mapper'; import { SubmissionContainerElementResponseMapper } from './submission-container-element-response.mapper'; +import { VideoConferenceElementResponseMapper } from './video-conference-element-response.mapper'; export class ContentElementResponseFactory { private static mappers: BaseResponseMapper[] = [ @@ -27,6 +28,7 @@ export class ContentElementResponseFactory { ExternalToolElementResponseMapper.getInstance(), CollaborativeTextEditorElementResponseMapper.getInstance(), DeletedElementResponseMapper.getInstance(), + VideoConferenceElementResponseMapper.getInstance(), ]; static mapToResponse(element: AnyBoardNode): AnyContentElementResponse { diff --git a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts index 089740fe731..1829f4d5cd4 100644 --- a/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/drawing-element-response.mapper.ts @@ -25,7 +25,7 @@ export class DrawingElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: DrawingElement): boolean { + canMap(element: unknown): boolean { return element instanceof DrawingElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts index 226eb65f907..36dd6706ab3 100644 --- a/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/external-tool-element-response.mapper.ts @@ -24,7 +24,7 @@ export class ExternalToolElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: ExternalToolElement): boolean { + canMap(element: unknown): boolean { return element instanceof ExternalToolElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts index 6bf2eb5d8da..778a48cbbe1 100644 --- a/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/file-element-response.mapper.ts @@ -24,7 +24,7 @@ export class FileElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: FileElement): boolean { + canMap(element: unknown): boolean { return element instanceof FileElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index 980c5be1e45..318260d4276 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -10,4 +10,5 @@ export * from './link-element-response.mapper'; export * from './rich-text-element-response.mapper'; export * from './submission-container-element-response.mapper'; export * from './submission-item-response.mapper'; +export * from './video-conference-element-response.mapper'; export * from './deleted-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts index 3c3cb0cce0b..cde8ef1372f 100644 --- a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -30,7 +30,7 @@ export class LinkElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: LinkElement): boolean { + canMap(element: unknown): boolean { return element instanceof LinkElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts index c845bc63346..bb9ab6877ca 100644 --- a/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/rich-text-element-response.mapper.ts @@ -25,7 +25,7 @@ export class RichTextElementResponseMapper implements BaseResponseMapper { return result; } - canMap(element: RichTextElement): boolean { + canMap(element: unknown): boolean { return element instanceof RichTextElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts index b65a1b5654e..6ef2a91e601 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-container-element-response.mapper.ts @@ -30,7 +30,7 @@ export class SubmissionContainerElementResponseMapper implements BaseResponseMap return result; } - canMap(element: SubmissionContainerElement): boolean { + canMap(element: unknown): boolean { return element instanceof SubmissionContainerElement; } } diff --git a/apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts new file mode 100644 index 00000000000..1f02aefb4d0 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/video-conference-element-response.mapper.ts @@ -0,0 +1,30 @@ +import { ContentElementType, VideoConferenceElement } from '../../domain'; +import { TimestampsResponse, VideoConferenceElementContent, VideoConferenceElementResponse } from '../dto'; +import { BaseResponseMapper } from './base-mapper.interface'; + +export class VideoConferenceElementResponseMapper implements BaseResponseMapper { + private static instance: VideoConferenceElementResponseMapper; + + public static getInstance(): VideoConferenceElementResponseMapper { + if (!VideoConferenceElementResponseMapper.instance) { + VideoConferenceElementResponseMapper.instance = new VideoConferenceElementResponseMapper(); + } + + return VideoConferenceElementResponseMapper.instance; + } + + mapToResponse(element: VideoConferenceElement): VideoConferenceElementResponse { + const result = new VideoConferenceElementResponse({ + id: element.id, + timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), + type: ContentElementType.VIDEO_CONFERENCE, + content: new VideoConferenceElementContent({ title: element.title }), + }); + + return result; + } + + canMap(element: unknown): boolean { + return element instanceof VideoConferenceElement; + } +} diff --git a/apps/server/src/modules/board/domain/board-node.factory.ts b/apps/server/src/modules/board/domain/board-node.factory.ts index ff59355567e..54a640f6104 100644 --- a/apps/server/src/modules/board/domain/board-node.factory.ts +++ b/apps/server/src/modules/board/domain/board-node.factory.ts @@ -15,6 +15,7 @@ import { SubmissionContainerElement } from './submission-container-element.do'; import { SubmissionItem } from './submission-item.do'; import { handleNonExhaustiveSwitch } from './type-mapping'; import { AnyContentElement, BoardExternalReference, BoardLayout, BoardNodeProps, ContentElementType } from './types'; +import { VideoConferenceElement } from './video-conference-element.do'; @Injectable() export class BoardNodeFactory { @@ -86,6 +87,12 @@ export class BoardNodeFactory { ...this.getBaseProps(), }); break; + case ContentElementType.VIDEO_CONFERENCE: + element = new VideoConferenceElement({ + ...this.getBaseProps(), + title: '', + }); + break; default: handleNonExhaustiveSwitch(type); } diff --git a/apps/server/src/modules/board/domain/index.ts b/apps/server/src/modules/board/domain/index.ts index a052556cf7f..e2c2cd5895c 100644 --- a/apps/server/src/modules/board/domain/index.ts +++ b/apps/server/src/modules/board/domain/index.ts @@ -16,4 +16,5 @@ export * from './submission-item.do'; export * from './path-utils'; export * from './types'; export * from './type-mapping'; +export * from './video-conference-element.do'; export * from './deleted-element.do'; diff --git a/apps/server/src/modules/board/domain/type-mapping.spec.ts b/apps/server/src/modules/board/domain/type-mapping.spec.ts index 55c1917f681..b01fcd2cdd3 100644 --- a/apps/server/src/modules/board/domain/type-mapping.spec.ts +++ b/apps/server/src/modules/board/domain/type-mapping.spec.ts @@ -17,6 +17,7 @@ import { richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, + videoConferenceElementFactory, } from '../testing'; describe('getBoardNodeType', () => { @@ -37,6 +38,7 @@ describe('getBoardNodeType', () => { BoardNodeType.SUBMISSION_CONTAINER_ELEMENT ); expect(getBoardNodeType(submissionItemFactory.build())).toBe(BoardNodeType.SUBMISSION_ITEM); + expect(getBoardNodeType(videoConferenceElementFactory.build())).toBe(BoardNodeType.VIDEO_CONFERENCE_ELEMENT); }); it('should throw error for unknown type', () => { diff --git a/apps/server/src/modules/board/domain/type-mapping.ts b/apps/server/src/modules/board/domain/type-mapping.ts index a4a3b08ab8e..c22feec4d40 100644 --- a/apps/server/src/modules/board/domain/type-mapping.ts +++ b/apps/server/src/modules/board/domain/type-mapping.ts @@ -14,6 +14,7 @@ import { SubmissionContainerElement } from './submission-container-element.do'; import { SubmissionItem } from './submission-item.do'; import type { AnyBoardNode } from './types/any-board-node'; import { BoardNodeType } from './types/board-node-type.enum'; +import { VideoConferenceElement } from './video-conference-element.do'; // register node types const BoardNodeTypeToConstructor = { @@ -31,6 +32,7 @@ const BoardNodeTypeToConstructor = { [BoardNodeType.RICH_TEXT_ELEMENT]: RichTextElement, [BoardNodeType.SUBMISSION_CONTAINER_ELEMENT]: SubmissionContainerElement, [BoardNodeType.SUBMISSION_ITEM]: SubmissionItem, + [BoardNodeType.VIDEO_CONFERENCE_ELEMENT]: VideoConferenceElement, [BoardNodeType.DELETED_ELEMENT]: DeletedElement, } as const; diff --git a/apps/server/src/modules/board/domain/types/any-content-element.ts b/apps/server/src/modules/board/domain/types/any-content-element.ts index c8f6cae8bb3..e48b7c965bf 100644 --- a/apps/server/src/modules/board/domain/types/any-content-element.ts +++ b/apps/server/src/modules/board/domain/types/any-content-element.ts @@ -6,6 +6,7 @@ import { type FileElement, isFileElement } from '../file-element.do'; import { isLinkElement, type LinkElement } from '../link-element.do'; import { isRichTextElement, type RichTextElement } from '../rich-text-element.do'; import { isSubmissionContainerElement, type SubmissionContainerElement } from '../submission-container-element.do'; +import { isVideoConferenceElement, VideoConferenceElement } from '../video-conference-element.do'; import { type AnyBoardNode } from './any-board-node'; export type AnyContentElement = @@ -16,7 +17,8 @@ export type AnyContentElement = | LinkElement | RichTextElement | SubmissionContainerElement - | DeletedElement; + | DeletedElement + | VideoConferenceElement; export const isContentElement = (boardNode: AnyBoardNode): boardNode is AnyContentElement => { const result = @@ -27,7 +29,8 @@ export const isContentElement = (boardNode: AnyBoardNode): boardNode is AnyConte isLinkElement(boardNode) || isRichTextElement(boardNode) || isSubmissionContainerElement(boardNode) || - isDeletedElement(boardNode); + isDeletedElement(boardNode) || + isVideoConferenceElement(boardNode); return result; }; diff --git a/apps/server/src/modules/board/domain/types/board-node-props.ts b/apps/server/src/modules/board/domain/types/board-node-props.ts index 492584f77de..1c681a84135 100644 --- a/apps/server/src/modules/board/domain/types/board-node-props.ts +++ b/apps/server/src/modules/board/domain/types/board-node-props.ts @@ -67,6 +67,10 @@ export interface SubmissionItemProps extends BoardNodeProps { userId: EntityId; } +export interface VideoConferenceElementProps extends BoardNodeProps { + title: string; +} + export interface DeletedElementProps extends BoardNodeProps { title: string; deletedElementType: ContentElementType; @@ -105,4 +109,5 @@ export type AnyBoardNodeProps = | RichTextElementProps | SubmissionContainerElementProps | SubmissionItemProps + | VideoConferenceElementProps | MediaBoardNodeProps; diff --git a/apps/server/src/modules/board/domain/types/board-node-type.enum.ts b/apps/server/src/modules/board/domain/types/board-node-type.enum.ts index 71523c41749..af924b3cecb 100644 --- a/apps/server/src/modules/board/domain/types/board-node-type.enum.ts +++ b/apps/server/src/modules/board/domain/types/board-node-type.enum.ts @@ -11,6 +11,7 @@ export enum BoardNodeType { EXTERNAL_TOOL = 'external-tool', COLLABORATIVE_TEXT_EDITOR = 'collaborative-text-editor', DELETED_ELEMENT = 'deleted-element', + VIDEO_CONFERENCE_ELEMENT = 'video-conference-element', MEDIA_BOARD = 'media-board', MEDIA_LINE = 'media-line', diff --git a/apps/server/src/modules/board/domain/types/content-element-type.enum.ts b/apps/server/src/modules/board/domain/types/content-element-type.enum.ts index 773c63fdbe9..57bae8eb996 100644 --- a/apps/server/src/modules/board/domain/types/content-element-type.enum.ts +++ b/apps/server/src/modules/board/domain/types/content-element-type.enum.ts @@ -6,5 +6,6 @@ export enum ContentElementType { SUBMISSION_CONTAINER = 'submissionContainer', EXTERNAL_TOOL = 'externalTool', COLLABORATIVE_TEXT_EDITOR = 'collaborativeTextEditor', + VIDEO_CONFERENCE = 'videoConference', DELETED = 'deleted', } diff --git a/apps/server/src/modules/board/domain/video-conference-element.do.spec.ts b/apps/server/src/modules/board/domain/video-conference-element.do.spec.ts new file mode 100644 index 00000000000..dfe7fb324ee --- /dev/null +++ b/apps/server/src/modules/board/domain/video-conference-element.do.spec.ts @@ -0,0 +1,44 @@ +import { VideoConferenceElement, isVideoConferenceElement } from './video-conference-element.do'; +import { BoardNodeProps } from './types/board-node-props'; + +describe('VideoConferenceElement', () => { + let videoConferenceElement: VideoConferenceElement; + + const boardNodeProps: BoardNodeProps = { + id: '1', + path: '', + level: 1, + position: 1, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + videoConferenceElement = new VideoConferenceElement({ + ...boardNodeProps, + title: 'Example', + }); + }); + + it('should be instance of VideoConferenceElement', () => { + expect(isVideoConferenceElement(videoConferenceElement)).toBe(true); + }); + + it('should not be instance of VideoConferenceElement', () => { + expect(isVideoConferenceElement({})).toBe(false); + }); + + it('should return title', () => { + expect(videoConferenceElement.title).toBe('Example'); + }); + + it('should set title', () => { + videoConferenceElement.title = 'New title'; + expect(videoConferenceElement.title).toBe('New title'); + }); + + it('should not have child', () => { + expect(videoConferenceElement.canHaveChild()).toBe(false); + }); +}); diff --git a/apps/server/src/modules/board/domain/video-conference-element.do.ts b/apps/server/src/modules/board/domain/video-conference-element.do.ts new file mode 100644 index 00000000000..de8a5b714b9 --- /dev/null +++ b/apps/server/src/modules/board/domain/video-conference-element.do.ts @@ -0,0 +1,19 @@ +import { BoardNode } from './board-node.do'; +import type { VideoConferenceElementProps } from './types'; + +export class VideoConferenceElement extends BoardNode { + get title(): string { + return this.props.title; + } + + set title(value: string) { + this.props.title = value; + } + + canHaveChild(): boolean { + return false; + } +} + +export const isVideoConferenceElement = (reference: unknown): reference is VideoConferenceElement => + reference instanceof VideoConferenceElement; diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts index 2a3b6b4c8f1..3d6b27b7ec5 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-general.service.spec.ts @@ -25,6 +25,7 @@ import { richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, + videoConferenceElementFactory, } from '../../testing'; import { BoardNodeCopyContext, BoardNodeCopyContextProps } from './board-node-copy-context'; import { BoardNodeCopyService } from './board-node-copy.service'; @@ -100,6 +101,7 @@ describe(BoardNodeCopyService.name, () => { jest.spyOn(service, 'copyMediaLine').mockResolvedValue(mockStatus); jest.spyOn(service, 'copyMediaExternalToolElement').mockResolvedValue(mockStatus); jest.spyOn(service, 'copyDeletedElement').mockResolvedValue(mockStatus); + jest.spyOn(service, 'copyVideoConferenceElement').mockResolvedValue(mockStatus); return { copyContext, mockStatus }; }; @@ -283,6 +285,18 @@ describe(BoardNodeCopyService.name, () => { expect(result).toEqual(mockStatus); }); }); + + describe('when called with video conference element', () => { + it('should copy deleted element', async () => { + const { copyContext, mockStatus } = setup(); + const node = videoConferenceElementFactory.build(); + + const result = await service.copy(node, copyContext); + + expect(service.copyVideoConferenceElement).toHaveBeenCalledWith(node, copyContext); + expect(result).toEqual(mockStatus); + }); + }); }); }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts index a2400688f7b..80ce46a5082 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy-specific.service.spec.ts @@ -42,6 +42,7 @@ import { richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, + videoConferenceElementFactory, } from '../../testing'; import { BoardNodeCopyContext, BoardNodeCopyContextProps } from './board-node-copy-context'; import { BoardNodeCopyService } from './board-node-copy.service'; @@ -676,4 +677,29 @@ describe(BoardNodeCopyService.name, () => { expect(result.copyEntity).toBeInstanceOf(DeletedElement); }); }); + + describe('copy video conference element', () => { + const setup = () => { + const { copyContext } = setupContext(); + const videoConferenceElement = videoConferenceElementFactory.build(); + + return { + copyContext, + videoConferenceElement, + }; + }; + + it('should copy the node', async () => { + const { copyContext, videoConferenceElement } = setup(); + + const result = await service.copyVideoConferenceElement(videoConferenceElement, copyContext); + + const expectedStatus: CopyStatus = { + type: CopyElementType.VIDEO_CONFERENCE_ELEMENT, + status: CopyStatusEnum.NOT_DOING, + }; + + expect(result).toEqual(expectedStatus); + }); + }); }); diff --git a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts index 1f8fcc1e4a6..5c30de96d48 100644 --- a/apps/server/src/modules/board/service/internal/board-node-copy.service.ts +++ b/apps/server/src/modules/board/service/internal/board-node-copy.service.ts @@ -28,6 +28,7 @@ import { RichTextElement, SubmissionContainerElement, SubmissionItem, + VideoConferenceElement, } from '../../domain'; export interface CopyContext { @@ -82,6 +83,9 @@ export class BoardNodeCopyService { case BoardNodeType.COLLABORATIVE_TEXT_EDITOR: result = await this.copyCollaborativeTextEditorElement(boardNode as CollaborativeTextEditorElement, context); break; + case BoardNodeType.VIDEO_CONFERENCE_ELEMENT: + result = await this.copyVideoConferenceElement(boardNode as VideoConferenceElement, context); + break; case BoardNodeType.DELETED_ELEMENT: result = await this.copyDeletedElement(boardNode as DeletedElement, context); break; @@ -362,6 +366,16 @@ export class BoardNodeCopyService { return Promise.resolve(result); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async copyVideoConferenceElement(original: VideoConferenceElement, context: CopyContext): Promise { + const result: CopyStatus = { + type: CopyElementType.VIDEO_CONFERENCE_ELEMENT, + status: CopyStatusEnum.NOT_DOING, + }; + + return Promise.resolve(result); + } + async copyMediaBoard(original: MediaBoard, context: CopyContext): Promise { const childrenResults = await this.copyChildrenOf(original, context); diff --git a/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts b/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts index db30e2e7322..105d2726db2 100644 --- a/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts +++ b/apps/server/src/modules/board/service/internal/content-element-update.service.spec.ts @@ -8,6 +8,7 @@ import { LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, + VideoConferenceContentBody, } from '../../controller/dto'; import { BoardNodeRepo } from '../../repo'; import { @@ -17,6 +18,7 @@ import { linkElementFactory, richTextElementFactory, submissionContainerElementFactory, + videoConferenceElementFactory, } from '../../testing'; import { ContentElementUpdateService } from './content-element-update.service'; @@ -124,6 +126,17 @@ describe('ContentElementUpdateService', () => { expect(repo.save).toHaveBeenCalledWith(element); }); + it('should update VideoConferenceElement', async () => { + const element = videoConferenceElementFactory.build(); + const content = new VideoConferenceContentBody(); + content.title = 'vc title'; + + await service.updateContent(element, content); + + expect(element.title).toBe('vc title'); + expect(repo.save).toHaveBeenCalledWith(element); + }); + it('should throw error for unknown element type', async () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const element = {} as any; diff --git a/apps/server/src/modules/board/service/internal/content-element-update.service.ts b/apps/server/src/modules/board/service/internal/content-element-update.service.ts index 085078f9696..4fe1d2c4eea 100644 --- a/apps/server/src/modules/board/service/internal/content-element-update.service.ts +++ b/apps/server/src/modules/board/service/internal/content-element-update.service.ts @@ -9,6 +9,7 @@ import { LinkContentBody, RichTextContentBody, SubmissionContainerContentBody, + VideoConferenceContentBody, } from '../../controller/dto'; import { AnyContentElement, @@ -21,9 +22,11 @@ import { isLinkElement, isRichTextElement, isSubmissionContainerElement, + isVideoConferenceElement, LinkElement, RichTextElement, SubmissionContainerElement, + VideoConferenceElement, } from '../../domain'; import { BoardNodeRepo } from '../../repo'; @@ -45,6 +48,8 @@ export class ContentElementUpdateService { this.updateSubmissionContainerElement(element, content); } else if (isExternalToolElement(element) && content instanceof ExternalToolContentBody) { this.updateExternalToolElement(element, content); + } else if (isVideoConferenceElement(element) && content instanceof VideoConferenceContentBody) { + this.updateVideoConferenceElement(element, content); } else { throw new Error(`Cannot update element of type: '${element.constructor.name}'`); } @@ -95,4 +100,8 @@ export class ContentElementUpdateService { element.contextExternalToolId = content.contextExternalToolId; } } + + updateVideoConferenceElement(element: VideoConferenceElement, content: VideoConferenceContentBody): void { + element.title = content.title; + } } diff --git a/apps/server/src/modules/board/testing/entity/index.ts b/apps/server/src/modules/board/testing/entity/index.ts index d5cb04e7d62..5305c950360 100644 --- a/apps/server/src/modules/board/testing/entity/index.ts +++ b/apps/server/src/modules/board/testing/entity/index.ts @@ -12,3 +12,4 @@ export * from './media-line-entity.factory'; export * from './rich-text-element-entity.factory'; export * from './submission-container-element-entity.factory'; export * from './submission-item-entity.factory'; +export * from './video-conference-element-entity.factory'; diff --git a/apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts b/apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts new file mode 100644 index 00000000000..f744a89fa82 --- /dev/null +++ b/apps/server/src/modules/board/testing/entity/video-conference-element-entity.factory.ts @@ -0,0 +1,19 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BoardNodeEntityFactory, PropsWithType } from './board-node-entity.factory'; +import { BoardNodeType, ROOT_PATH, VideoConferenceElementProps } from '../../domain'; + +export const videoConferenceElementEntityFactory = BoardNodeEntityFactory.define< + PropsWithType +>(({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: ROOT_PATH, + level: 0, + title: `video conference element #${sequence}`, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + type: BoardNodeType.VIDEO_CONFERENCE_ELEMENT, + }; +}); diff --git a/apps/server/src/modules/board/testing/index.ts b/apps/server/src/modules/board/testing/index.ts index 898a9b9f965..9a1a17999d6 100644 --- a/apps/server/src/modules/board/testing/index.ts +++ b/apps/server/src/modules/board/testing/index.ts @@ -16,4 +16,5 @@ export * from './media-line.factory'; export * from './rich-text-element.factory'; export * from './submission-container-element.factory'; export * from './submission-item.factory'; +export * from './video-conference-element.factory'; export * from './deleted-element.factory'; diff --git a/apps/server/src/modules/board/testing/video-conference-element.factory.ts b/apps/server/src/modules/board/testing/video-conference-element.factory.ts new file mode 100644 index 00000000000..3a6e146ed3f --- /dev/null +++ b/apps/server/src/modules/board/testing/video-conference-element.factory.ts @@ -0,0 +1,19 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { ROOT_PATH, VideoConferenceElement, VideoConferenceElementProps } from '../domain'; + +export const videoConferenceElementFactory = BaseFactory.define( + VideoConferenceElement, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: ROOT_PATH, + level: 0, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + title: `video conference element #${sequence}`, + }; + } +); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES index 87cd4d4af4d..101580959b7 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/.openapi-generator/FILES @@ -35,3 +35,5 @@ models/submission-container-element-content.ts models/submission-container-element-response.ts models/timestamps-response.ts models/visibility-settings-response.ts +models/video-conference-element-content.ts +models/video-conference-element-response.ts diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts index 391ec522db3..29c4989aa62 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/content-element-type.ts @@ -5,33 +5,29 @@ * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. * * The version of the OpenAPI document: 3.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ - - /** - * + * * @export * @enum {string} */ export const ContentElementType = { - FILE: 'file', - DRAWING: 'drawing', - LINK: 'link', - RICH_TEXT: 'richText', - SUBMISSION_CONTAINER: 'submissionContainer', - EXTERNAL_TOOL: 'externalTool', - COLLABORATIVE_TEXT_EDITOR: 'collaborativeTextEditor', - DELETED: 'deleted' + FILE: 'file', + DRAWING: 'drawing', + LINK: 'link', + RICH_TEXT: 'richText', + SUBMISSION_CONTAINER: 'submissionContainer', + EXTERNAL_TOOL: 'externalTool', + COLLABORATIVE_TEXT_EDITOR: 'collaborativeTextEditor', + DELETED: 'deleted', + VIDEO_CONFERENCE: 'videoConference', } as const; export type ContentElementType = typeof ContentElementType[keyof typeof ContentElementType]; - - - diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts index c68fe9f28ba..7100b3a7b72 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/index.ts @@ -24,4 +24,6 @@ export * from './set-height-body-params'; export * from './submission-container-element-content'; export * from './submission-container-element-response'; export * from './timestamps-response'; +export * from './video-conference-element-content'; +export * from './video-conference-element-response'; export * from './visibility-settings-response'; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts new file mode 100644 index 00000000000..b9a4bdc58d5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-content.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface VideoConferenceElementContent + */ +export interface VideoConferenceElementContent { + /** + * + * @type {string} + * @memberof VideoConferenceElementContent + */ + title: string; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts new file mode 100644 index 00000000000..b2cab72ea79 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/cards-api-client/models/video-conference-element-response.ts @@ -0,0 +1,55 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +// May contain unused imports in some cases +// @ts-ignore +import type { ContentElementType } from './content-element-type'; +// May contain unused imports in some cases +// @ts-ignore +import type { VideoConferenceElementContent } from './video-conference-element-content'; +// May contain unused imports in some cases +// @ts-ignore +import type { TimestampsResponse } from './timestamps-response'; + +/** + * + * @export + * @interface VideoConferenceElementResponse + */ +export interface VideoConferenceElementResponse { + /** + * + * @type {string} + * @memberof VideoConferenceElementResponse + */ + id: string; + /** + * + * @type {ContentElementType} + * @memberof VideoConferenceElementResponse + */ + type: ContentElementType; + /** + * + * @type {TimestampsResponse} + * @memberof VideoConferenceElementResponse + */ + timestamps: TimestampsResponse; + /** + * + * @type {VideoConferenceElementContent} + * @memberof VideoConferenceElementResponse + */ + content: VideoConferenceElementContent; +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts new file mode 100644 index 00000000000..9f909f21498 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-content.dto.ts @@ -0,0 +1,7 @@ +export class VideoConferenceElementContentDto { + title: string; + + constructor(title: string) { + this.title = title; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts new file mode 100644 index 00000000000..83b387fafed --- /dev/null +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/dto/video-conference-element-response.dto.ts @@ -0,0 +1,25 @@ +import { ContentElementType } from '../enums/content-element-type.enum'; +import { TimestampResponseDto } from './timestamp-response.dto'; +import { VideoConferenceElementContentDto } from './video-conference-element-content.dto'; + +export class VideoConferenceElementResponseDto { + id: string; + + type: ContentElementType; + + timestamps: TimestampResponseDto; + + content: VideoConferenceElementContentDto; + + constructor( + id: string, + type: ContentElementType, + content: VideoConferenceElementContentDto, + timestamps: TimestampResponseDto + ) { + this.id = id; + this.type = type; + this.timestamps = timestamps; + this.content = content; + } +} diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts index 773c63fdbe9..83c424aa136 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/enums/content-element-type.enum.ts @@ -7,4 +7,5 @@ export enum ContentElementType { EXTERNAL_TOOL = 'externalTool', COLLABORATIVE_TEXT_EDITOR = 'collaborativeTextEditor', DELETED = 'deleted', + VIDEO_CONFERENCE = 'videoConference', } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts index 29052e30911..e99cbecbc24 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.spec.ts @@ -11,6 +11,7 @@ import { RichTextElementResponse, CollaborativeTextEditorElementResponse, CardResponseElementsInner, + VideoConferenceElementResponse, } from '../cards-api-client'; import { ContentElementType } from '../enums/content-element-type.enum'; import { CardContentElementInner } from '../types/card-content-elements-inner.type'; @@ -99,6 +100,10 @@ describe('CardResponseMapper', () => { inputFormat: faker.internet.domainName(), }) as RichTextElementResponse, + createMockElement(faker.string.uuid(), ContentElementType.VIDEO_CONFERENCE, { + title: faker.lorem.word(), + }) as VideoConferenceElementResponse, + createMockElement(faker.string.uuid(), 'UNKNOWN_TYPE' as ContentElementType, {}) as CardResponseElementsInner, ]); @@ -113,7 +118,7 @@ describe('CardResponseMapper', () => { expect(cardResponseDto.height).toBe(100); expect(cardResponseDto.visibilitySettings.publishedAt).toBe('2024-10-03T12:00:00Z'); expect(cardResponseDto.timeStamps.lastUpdatedAt).toBe('2024-10-03T11:00:00Z'); - expect(cardResponseDto.elements).toHaveLength(8); + expect(cardResponseDto.elements).toHaveLength(9); expect(cardResponseDto.elements[0].type).toBe(ContentElementType.COLLABORATIVE_TEXT_EDITOR); expect(cardResponseDto.elements[1].type).toBe(ContentElementType.DELETED); expect(cardResponseDto.elements[2].type).toBe(ContentElementType.SUBMISSION_CONTAINER); @@ -122,6 +127,7 @@ describe('CardResponseMapper', () => { expect(cardResponseDto.elements[5].type).toBe(ContentElementType.FILE); expect(cardResponseDto.elements[6].type).toBe(ContentElementType.LINK); expect(cardResponseDto.elements[7].type).toBe(ContentElementType.RICH_TEXT); + expect(cardResponseDto.elements[8].type).toBe(ContentElementType.VIDEO_CONFERENCE); }); }); diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts index ecf86629756..cf8b228cb9f 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/mapper/card-response.mapper.ts @@ -11,6 +11,7 @@ import { FileElementContent, LinkElementContent, RichTextElementContent, + VideoConferenceElementContent, } from '../cards-api-client'; import { CardResponseDto } from '../dto/card-response.dto'; import { CollaborativeTextEditorElementResponseDto } from '../dto/collaborative-text-editor-element-response.dto'; @@ -33,6 +34,8 @@ import { VisibilitySettingsResponseDto } from '../dto/visibility-settings-respon import { TimestampResponseDto } from '../dto/timestamp-response.dto'; import { CardResponseElementsInnerDto } from '../types/card-response-elements-inner.type'; import { CardListResponseDto } from '../dto/card-list-response.dto'; +import { VideoConferenceElementResponseDto } from '../dto/video-conference-element-response.dto'; +import { VideoConferenceElementContentDto } from '../dto/video-conference-element-content.dto'; export class CardResponseMapper { public static mapToCardListResponseDto(cardListResponse: CardListResponse): CardListResponseDto { @@ -156,6 +159,18 @@ export class CardResponseMapper { ); break; } + case ContentElementType.VIDEO_CONFERENCE: { + const content: VideoConferenceElementContent = element.content as VideoConferenceElementContent; + elements.push( + new VideoConferenceElementResponseDto( + element.id, + ContentElementType.VIDEO_CONFERENCE, + new VideoConferenceElementContentDto(content.title), + this.mapToTimestampDto(element.timestamps) + ) + ); + break; + } default: break; } diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts index b8a32bc9479..8bf6b91e95b 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-content-elements-inner.type.ts @@ -5,6 +5,7 @@ import { FileElementContentDto } from '../dto/file-element-content.dto'; import { LinkElementContentDto } from '../dto/link-element-content.dto'; import { RichTextElementContentDto } from '../dto/rich-text-element-content.dto'; import { SubmissionContainerElementContentDto } from '../dto/submission-container-element-content.dto'; +import { VideoConferenceElementContentDto } from '../dto/video-conference-element-content.dto'; export type CardContentElementInner = | LinkElementContentDto @@ -14,4 +15,5 @@ export type CardContentElementInner = | FileElementContentDto | RichTextElementContentDto | SubmissionContainerElementContentDto + | VideoConferenceElementContentDto | object; diff --git a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts index 7b4c77dafd8..3ebb58fc88c 100644 --- a/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts +++ b/apps/server/src/modules/common-cartridge/common-cartridge-client/card-client/types/card-response-elements-inner.type.ts @@ -6,6 +6,7 @@ import { FileElementResponseDto } from '../dto/file-element-response.dto'; import { LinkElementResponseDto } from '../dto/link-element-response.dto'; import { RichTextElementResponseDto } from '../dto/rich-text-element-response.dto'; import { SubmissionContainerElementResponseDto } from '../dto/submission-container-element-response.dto'; +import { VideoConferenceElementResponseDto } from '../dto/video-conference-element-response.dto'; export type CardResponseElementsInnerDto = | CollaborativeTextEditorElementResponseDto @@ -15,4 +16,5 @@ export type CardResponseElementsInnerDto = | FileElementResponseDto | LinkElementResponseDto | RichTextElementResponseDto - | SubmissionContainerElementResponseDto; + | SubmissionContainerElementResponseDto + | VideoConferenceElementResponseDto; 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 703ab28aa56..733b898112d 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -52,6 +52,7 @@ export enum CopyElementType { TASK_GROUP = 'TASK_GROUP', TIME_GROUP = 'TIME_GROUP', USER_GROUP = 'USER_GROUP', + VIDEO_CONFERENCE_ELEMENT = 'VIDEO_CONFERENCE_ELEMENT', } export enum CopyStatusEnum { diff --git a/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts b/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts index eafbfd3aeab..cec9df1ad1a 100644 --- a/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts +++ b/apps/server/src/modules/room-membership/repo/entity/room-membership.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Property, Unique } from '@mikro-orm/core'; +import { Entity, Index, Property, Unique } from '@mikro-orm/core'; import { ObjectIdType } from '@shared/repo/types/object-id.type'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { EntityId } from '@shared/domain/types'; @@ -14,6 +14,7 @@ export class RoomMembershipEntity extends BaseEntityWithTimestamps implements Ro @Property({ type: ObjectIdType, fieldName: 'userGroup' }) userGroupId!: EntityId; + @Index() @Property({ type: ObjectIdType, fieldName: 'school' }) schoolId!: EntityId; diff --git a/apps/server/src/modules/room/domain/type/room-color.enum.ts b/apps/server/src/modules/room/domain/type/room-color.enum.ts index 5ee92c572f2..f1ceaae9fb8 100644 --- a/apps/server/src/modules/room/domain/type/room-color.enum.ts +++ b/apps/server/src/modules/room/domain/type/room-color.enum.ts @@ -3,6 +3,7 @@ export enum RoomColor { PINK = 'pink', RED = 'red', ORANGE = 'orange', + YELLOW = 'yellow', OLIVE = 'olive', GREEN = 'green', TURQUOISE = 'turquoise', diff --git a/apps/server/src/modules/room/repo/entity/room.entity.ts b/apps/server/src/modules/room/repo/entity/room.entity.ts index 0539f7c469c..ebb0a66d26f 100644 --- a/apps/server/src/modules/room/repo/entity/room.entity.ts +++ b/apps/server/src/modules/room/repo/entity/room.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Property } from '@mikro-orm/core'; +import { Entity, Index, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { EntityId } from '@shared/domain/types'; import { ObjectIdType } from '@shared/repo/types/object-id.type'; @@ -13,6 +13,7 @@ export class RoomEntity extends BaseEntityWithTimestamps implements RoomProps { @Property({ nullable: false }) color!: RoomColor; + @Index() @Property({ type: ObjectIdType, fieldName: 'school', nullable: false }) schoolId!: EntityId; diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts index 40113bcb8bc..558b50ea0c6 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -113,6 +113,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_COLUMN_BOARD_SOCKET_ENABLED: boolean; + @ApiProperty() + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: boolean; + @ApiProperty() FEATURE_COURSE_SHARE: boolean; @@ -249,6 +252,7 @@ export class ConfigResponse { this.FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED = config.FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED; this.FEATURE_COLUMN_BOARD_SHARE = config.FEATURE_COLUMN_BOARD_SHARE; this.FEATURE_COLUMN_BOARD_SOCKET_ENABLED = config.FEATURE_COLUMN_BOARD_SOCKET_ENABLED; + this.FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED = config.FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED; this.FEATURE_COURSE_SHARE = config.FEATURE_COURSE_SHARE; this.FEATURE_LOGIN_LINK_ENABLED = config.FEATURE_LOGIN_LINK_ENABLED; this.FEATURE_LESSON_SHARE = config.FEATURE_LESSON_SHARE; diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts index 6371109be84..1fc0a678700 100644 --- a/apps/server/src/modules/server/api/test/server.api.spec.ts +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -49,6 +49,7 @@ describe('Server Controller (API)', () => { 'FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED', 'FEATURE_COLUMN_BOARD_SHARE', 'FEATURE_COLUMN_BOARD_SOCKET_ENABLED', + 'FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED', 'FEATURE_BOARD_LAYOUT_ENABLED', 'FEATURE_CONSENT_NECESSARY', 'FEATURE_COPY_SERVICE_ENABLED', diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 078662d11f4..859e15a551f 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -96,6 +96,7 @@ export interface ServerConfig FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED: boolean; FEATURE_COLUMN_BOARD_SHARE: boolean; FEATURE_COLUMN_BOARD_SOCKET_ENABLED: boolean; + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: boolean; FEATURE_BOARD_LAYOUT_ENABLED: boolean; FEATURE_CONSENT_NECESSARY: boolean; FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED: boolean; @@ -151,6 +152,9 @@ const config: ServerConfig = { ) as boolean, FEATURE_COLUMN_BOARD_SHARE: Configuration.get('FEATURE_COLUMN_BOARD_SHARE') as boolean, FEATURE_COLUMN_BOARD_SOCKET_ENABLED: Configuration.get('FEATURE_COLUMN_BOARD_SOCKET_ENABLED') as boolean, + FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED: Configuration.get( + 'FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED' + ) as boolean, FEATURE_COURSE_SHARE: Configuration.get('FEATURE_COURSE_SHARE') as boolean, FEATURE_LESSON_SHARE: Configuration.get('FEATURE_LESSON_SHARE') as boolean, FEATURE_TASK_SHARE: Configuration.get('FEATURE_TASK_SHARE') as boolean, diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference-course.api.spec.ts similarity index 99% rename from apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts rename to apps/server/src/modules/video-conference/controller/api-test/video-conference-course.api.spec.ts index 0417ecbaa6f..b7c8ca7c4bc 100644 --- a/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference-course.api.spec.ts @@ -271,7 +271,7 @@ describe('VideoConferenceController (API)', () => { }); }); - describe('when user has the required permission', () => { + describe('when user has the required permission in course scope', () => { const setup = async () => { const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts new file mode 100644 index 00000000000..a9eae2a7741 --- /dev/null +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference-room.api.spec.ts @@ -0,0 +1,1123 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, SchoolEntity, TargetModels, User, VideoConference } from '@shared/domain/entity'; +import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; +import { SchoolFeature } from '@shared/domain/types'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; +import { videoConferenceFactory } from '@shared/testing/factory/video-conference.factory'; +import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; +import { accountFactory } from '@src/modules/account/testing'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Response } from 'supertest'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { VideoConferenceCreateParams, VideoConferenceJoinResponse } from '../dto'; + +describe('VideoConferenceController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let axiosMock: MockAdapter; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + axiosMock = new MockAdapter(axios); + testApiClient = new TestApiClient(app, 'videoconference2'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + axiosMock = new MockAdapter(axios); + }); + + const mockBbbMeetingInfoFailed = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.INTERNAL_SERVER_ERROR, + '\n' + + '\n' + + ' FAILED\n' + + ' notFound\n' + + ' We could not find a meeting with that meeting ID - perhaps the meeting is not yet running?\n' + + '' + ); + }; + + const mockBbbMeetingInfoSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'Mathe\n' + + `${meetingId}\n` + + 'c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686648423698\n' + + '1686648423698\n' + + 'Tue Jun 13 11:27:03 CEST 2023\n' + + '17878\n' + + '613-555-1234\n' + + 'VIEWER\n' + + 'MODERATOR\n' + + 'false\n' + + '0\n' + + 'false\n' + + 'false\n' + + 'false\n' + + '1686648423709\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '\n' + + '\n' + + '\n' + + 'localhost\n' + + '\n' + + 'false\n' + + '\n' + ); + }; + + const mockBbbCreateSuccess = (meetingId: string) => { + axiosMock + .onPost(new RegExp(`.*/bigbluebutton/api/create?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + ' SUCCESS\n' + + ` ${meetingId}\n` + + ' c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686646947283\n' + + ' bbb-none\n' + + ' 1686646947283\n' + + ' 37466\n' + + ' 613-555-1234\n' + + ' Tue Jun 13 11:02:27 CEST 2023\n' + + ' false\n' + + ' 0\n' + + ' false\n' + + ' messageKey\n' + + ' message\n' + + '' + ); + }; + + const mockBbbEndSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/end?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'sentEndMeetingRequest\n' + + 'A request to end the meeting was sent. Please wait a few seconds, and then use the getMeetingInfo or isMeetingRunning API calls to verify that it was ended.\n' + + '\n' + ); + }; + + describe('[PUT] /videoconference2/:scope/:scopeId/start', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.put('/anyScope/anyId/start'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the logoutUrl is from a wrong origin', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + logoutUrl: 'http://from.other.origin/', + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + params, + }; + }; + + it('should return bad request', async () => { + const { loggedInClient, params } = await setup(); + + const response: Response = await loggedInClient.put( + `${VideoConferenceScope.ROOM}/${new ObjectId().toHexString()}/start`, + params + ); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when conference params are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has not the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2025-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([ + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission in room scope', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + mockBbbCreateSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should create the conference successfully and return with ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + + describe('when conference is for scope and scopeId is already running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/join', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/join'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return the conference', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + url: expect.any(String), + }); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return internal server error', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/info', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/info'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('when guest want meeting info of conference without waiting room', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const expertRole: Role = roleFactory.buildWithId({ + name: RoleName.EXPERT, + permissions: [Permission.JOIN_MEETING], + }); + + const expertUser: User = userFactory.buildWithId({ school, roles: [expertRole] }); + const expertAccount: AccountEntity = accountFactory.buildWithId({ userId: expertUser.id }); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: expertRole, user: expertUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + options: { moderatorMustApproveJoinRequests: false }, + }); + + await em.persistAndFlush([ + expertAccount, + expertUser, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(expertAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + + describe('[PUT] /videoconference2/:scope/:scopeId/end', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/end'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user without required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user with required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.ROOMS, + target: room.id, + }); + + await em.persistAndFlush([ + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.ROOM; + const scopeId: string = room.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbEndSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts new file mode 100644 index 00000000000..a622a760219 --- /dev/null +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference-video-conference-element.api.spec.ts @@ -0,0 +1,1302 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, SchoolEntity, TargetModels, User, VideoConference } from '@shared/domain/entity'; +import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; +import { SchoolFeature } from '@shared/domain/types'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; +import { videoConferenceFactory } from '@shared/testing/factory/video-conference.factory'; +import { AccountEntity } from '@src/modules/account/domain/entity/account.entity'; +import { accountFactory } from '@src/modules/account/testing'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Response } from 'supertest'; +import { roomEntityFactory } from '@src/modules/room/testing'; +import { roomMembershipEntityFactory } from '@src/modules/room-membership/testing'; +import { BoardExternalReferenceType } from '@src/modules/board'; +import { + columnBoardEntityFactory, + columnEntityFactory, + cardEntityFactory, + videoConferenceElementEntityFactory, +} from '@src/modules/board/testing'; +import { VideoConferenceCreateParams, VideoConferenceJoinResponse } from '../dto'; + +describe('VideoConferenceController (API)', () => { + let app: INestApplication; + let em: EntityManager; + let axiosMock: MockAdapter; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + axiosMock = new MockAdapter(axios); + testApiClient = new TestApiClient(app, 'videoconference2'); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + axiosMock = new MockAdapter(axios); + }); + + const mockBbbMeetingInfoFailed = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.INTERNAL_SERVER_ERROR, + '\n' + + '\n' + + ' FAILED\n' + + ' notFound\n' + + ' We could not find a meeting with that meeting ID - perhaps the meeting is not yet running?\n' + + '' + ); + }; + + const mockBbbMeetingInfoSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/getMeetingInfo?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'Mathe\n' + + `${meetingId}\n` + + 'c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686648423698\n' + + '1686648423698\n' + + 'Tue Jun 13 11:27:03 CEST 2023\n' + + '17878\n' + + '613-555-1234\n' + + 'VIEWER\n' + + 'MODERATOR\n' + + 'false\n' + + '0\n' + + 'false\n' + + 'false\n' + + 'false\n' + + '1686648423709\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '0\n' + + '\n' + + '\n' + + '\n' + + 'localhost\n' + + '\n' + + 'false\n' + + '\n' + ); + }; + + const mockBbbCreateSuccess = (meetingId: string) => { + axiosMock + .onPost(new RegExp(`.*/bigbluebutton/api/create?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + ' SUCCESS\n' + + ` ${meetingId}\n` + + ' c7ae0ac13ace99c8b2239ce3919c28e47d5bbd2a-1686646947283\n' + + ' bbb-none\n' + + ' 1686646947283\n' + + ' 37466\n' + + ' 613-555-1234\n' + + ' Tue Jun 13 11:02:27 CEST 2023\n' + + ' false\n' + + ' 0\n' + + ' false\n' + + ' messageKey\n' + + ' message\n' + + '' + ); + }; + + const mockBbbEndSuccess = (meetingId: string) => { + axiosMock + .onGet(new RegExp(`.*/bigbluebutton/api/end?.*meetingID=${meetingId}.*`)) + .replyOnce( + HttpStatus.OK, + '\n' + + '\n' + + 'SUCCESS\n' + + 'sentEndMeetingRequest\n' + + 'A request to end the meeting was sent. Please wait a few seconds, and then use the getMeetingInfo or isMeetingRunning API calls to verify that it was ended.\n' + + '\n' + ); + }; + + describe('[PUT] /videoconference2/:scope/:scopeId/start', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.put('/anyScope/anyId/start'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when the logoutUrl is from a wrong origin', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + logoutUrl: 'http://from.other.origin/', + }; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + params, + }; + }; + + it('should return bad request', async () => { + const { loggedInClient, params } = await setup(); + + const response: Response = await loggedInClient.put( + `${VideoConferenceScope.ROOM}/${new ObjectId().toHexString()}/start`, + params + ); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + }); + }); + + describe('when conference params are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has not the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2025-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission in room scope', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + mockBbbCreateSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should create the conference successfully and return with ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + + describe('when conference is for scope and scopeId is already running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + ]); + em.clear(); + + const params: VideoConferenceCreateParams = { + everyAttendeeJoinsMuted: true, + everybodyJoinsAsModerator: true, + moderatorMustApproveJoinRequests: true, + }; + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId, params }; + }; + + it('should return ok', async () => { + const { loggedInClient, params, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.put(`${scope}/${scopeId}/start`, params); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/join', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/join'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return the conference', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + url: expect.any(String), + }); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return internal server error', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/join`); + + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); + }); + }); + + describe('[GET] /videoconference2/:scope/:scopeId/info', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/info'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user has the required permission', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('when guest want meeting info of conference without waiting room', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const expertRole: Role = roleFactory.buildWithId({ + name: RoleName.EXPERT, + permissions: [Permission.JOIN_MEETING], + }); + + const expertUser: User = userFactory.buildWithId({ school, roles: [expertRole] }); + const expertAccount: AccountEntity = accountFactory.buildWithId({ userId: expertUser.id }); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: expertRole, user: expertUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + options: { moderatorMustApproveJoinRequests: false }, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + expertAccount, + expertUser, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(expertAccount); + + mockBbbMeetingInfoSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when conference is not running', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/info`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + + describe('[PUT] /videoconference2/:scope/:scopeId/end', () => { + describe('when user is unauthorized', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.get('/anyScope/anyId/end'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when scope and scopeId are given', () => { + describe('when school has not enabled the school feature videoconference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbMeetingInfoFailed(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user without required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleViewer, user: studentUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + studentAccount, + studentUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(studentAccount); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return forbidden', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when a user with required permission wants to end a conference', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const roleViewer = roleFactory.buildWithId({ + name: RoleName.ROOMVIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const userGroup = groupEntityFactory.buildWithId({ + organization: school, + users: [{ role: roleEditor, user: teacherUser }], + }); + const roomMembership = roomMembershipEntityFactory.build({ + roomId: room.id, + userGroupId: userGroup.id, + }); + + const columnBoardNode = columnBoardEntityFactory.build({ + context: { id: room.id, type: BoardExternalReferenceType.Room }, + }); + const columnNode = columnEntityFactory.withParent(columnBoardNode).build(); + const cardNode = cardEntityFactory.withParent(columnNode).build(); + const elementNode = videoConferenceElementEntityFactory.withParent(cardNode).build(); + + const videoConference: VideoConference = videoConferenceFactory.buildWithId({ + targetModel: TargetModels.VIDEO_CONFERENCE_ELEMENTS, + target: elementNode.id, + }); + + await em.persistAndFlush([ + columnBoardNode, + columnNode, + cardNode, + elementNode, + room, + roomMembership, + school, + teacherAccount, + teacherUser, + userGroup, + roleEditor, + roleViewer, + videoConference, + ]); + em.clear(); + + const scope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const scopeId: string = elementNode.id; + + const loggedInClient: TestApiClient = await testApiClient.login(teacherAccount); + + mockBbbEndSuccess(scopeId); + + return { loggedInClient, scope, scopeId }; + }; + + it('should return ok', async () => { + const { loggedInClient, scope, scopeId } = await setup(); + + const response: Response = await loggedInClient.get(`${scope}/${scopeId}/end`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts index 3bc50b9f20f..1ee7f27ddb0 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts @@ -14,10 +14,18 @@ import { Course, TeamUserEntity } from '@shared/domain/entity'; import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; -import { courseFactory, roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; +import { courseFactory, groupFactory, roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { teamFactory } from '@shared/testing/factory/team.factory'; import { teamUserFactory } from '@shared/testing/factory/teamuser.factory'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; +import { BoardNodeAuthorizable, BoardNodeAuthorizableService, BoardNodeService, BoardRoles } from '@src/modules/board'; +import { RoomService } from '@src/modules/room'; +import { RoomMembershipService } from '@src/modules/room-membership'; +import { GroupTypes } from '@src/modules/group'; +import { roomMembershipFactory } from '@src/modules/room-membership/testing'; +import { roomFactory } from '@src/modules/room/testing'; +import { columnBoardFactory, videoConferenceElementFactory } from '@src/modules/board/testing'; +import { VideoConferenceElement } from '@src/modules/board/domain'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; import { VideoConferenceOptions } from '../interface'; @@ -27,9 +35,13 @@ import { VideoConferenceService } from './video-conference.service'; describe(VideoConferenceService.name, () => { let service: DeepMocked; + let boardNodeAuthorizableService: DeepMocked; + let boardNodeService: DeepMocked; let courseService: DeepMocked; let calendarService: DeepMocked; let authorizationService: DeepMocked; + let roomMembershipService: DeepMocked; + let roomService: DeepMocked; let schoolService: DeepMocked; let teamsRepo: DeepMocked; let userService: DeepMocked; @@ -40,6 +52,14 @@ describe(VideoConferenceService.name, () => { const module: TestingModule = await Test.createTestingModule({ providers: [ VideoConferenceService, + { + provide: BoardNodeAuthorizableService, + useValue: createMock(), + }, + { + provide: BoardNodeService, + useValue: createMock(), + }, { provide: ConfigService, useValue: createMock>(), @@ -60,6 +80,14 @@ describe(VideoConferenceService.name, () => { provide: LegacySchoolService, useValue: createMock(), }, + { + provide: RoomMembershipService, + useValue: createMock(), + }, + { + provide: RoomService, + useValue: createMock(), + }, { provide: TeamsRepo, useValue: createMock(), @@ -76,9 +104,13 @@ describe(VideoConferenceService.name, () => { }).compile(); service = module.get(VideoConferenceService); + boardNodeAuthorizableService = module.get(BoardNodeAuthorizableService); + boardNodeService = module.get(BoardNodeService); courseService = module.get(CourseService); calendarService = module.get(CalendarService); authorizationService = module.get(AuthorizationService); + roomMembershipService = module.get(RoomMembershipService); + roomService = module.get(RoomService); schoolService = module.get(LegacySchoolService); teamsRepo = module.get(TeamsRepo); userService = module.get(UserService); @@ -169,6 +201,78 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when user has EXPERT role for a room', () => { + const setup = () => { + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.EXPERT }]) + .build({ id: new ObjectId().toHexString() }); + const userId = user.id as EntityId; + const scopeId = new ObjectId().toHexString(); + + configService.get.mockReturnValueOnce('https://api.example.com'); + userService.findById.mockResolvedValueOnce(user); + + return { + user, + userId, + conferenceScope: VideoConferenceScope.ROOM, + scopeId, + }; + }; + + it('should return true', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + const result = await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(result).toBe(true); + }); + + it('should call userService.findById', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + }); + + describe('when user has EXPERT role for a video conference element', () => { + const setup = () => { + const user: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.EXPERT }]) + .build({ id: new ObjectId().toHexString() }); + const userId = user.id as EntityId; + const scopeId = new ObjectId().toHexString(); + + configService.get.mockReturnValueOnce('https://api.example.com'); + userService.findById.mockResolvedValueOnce(user); + + return { + user, + userId, + conferenceScope: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, + scopeId, + }; + }; + + it('should return true', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + const result = await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(result).toBe(true); + }); + + it('should call userService.findById', async () => { + const { conferenceScope, userId, scopeId } = setup(); + + await service.hasExpertRole(userId, conferenceScope, scopeId); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + }); + describe('when user does not have the EXPERT role for a course conference', () => { const setup = () => { const user: UserDO = userDoFactory @@ -364,6 +468,99 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when user has room editor role in room scope', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roleEditor = roleFactory.buildWithId({ + name: RoleName.ROOMEDITOR, + permissions: [Permission.ROOM_EDIT], + }); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: roleEditor.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable.mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [roleEditor] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, roomId } = setup(); + + await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(roomMembershipService.getRoomMembershipAuthorizable).toHaveBeenCalledWith(roomId); + }); + + it('should return BBBRole.MODERATOR', async () => { + const { userId, conferenceScope, roomId } = setup(); + + const result = await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(result).toBe(BBBRole.MODERATOR); + }); + }); + + describe('when user has editor role in video conference node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, element, elementId } = setup(); + + await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(boardNodeAuthorizableService.getBoardAuthorizable).toHaveBeenCalledWith(element); + }); + + it('should return BBBRole.MODERATOR', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const result = await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(result).toBe(BBBRole.MODERATOR); + }); + }); + // can be removed when team / course / user is passed from UC // missing when course / team loading throw an error, but also not nessasary if it is passed to UC. describe('when user has START_MEETING permission and is in team(event) scope', () => { @@ -406,7 +603,7 @@ describe(VideoConferenceService.name, () => { }); }); - describe('when user has JOIN_MEETING permission', () => { + describe('when user has JOIN_MEETING permission and is in course scope', () => { const setup = () => { const user = userFactory.buildWithId(); const entity = courseFactory.buildWithId(); @@ -453,7 +650,107 @@ describe(VideoConferenceService.name, () => { }); }); - describe('when user has neither START_MEETING nor JOIN_MEETING permission', () => { + describe('when user has room viewer role in room scope', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roleViewer = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW] }); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: roleViewer.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [roleViewer] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [roleViewer] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, roomId } = setup(); + + await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(roomMembershipService.getRoomMembershipAuthorizable).toHaveBeenCalledWith(roomId); + }); + + it('should return BBBRole.VIEWER', async () => { + jest.restoreAllMocks(); + const { userId, conferenceScope, roomId } = setup(); + + const result = await service.determineBbbRole(userId, roomId, conferenceScope); + + expect(result).toBe(BBBRole.VIEWER); + }); + }); + + describe('when user has reader role in video conference node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should call the correct service', async () => { + const { userId, conferenceScope, element, elementId } = setup(); + + await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(boardNodeAuthorizableService.getBoardAuthorizable).toHaveBeenCalledWith(element); + }); + + it('should return BBBRole.VIEWER', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const result = await service.determineBbbRole(userId, elementId, conferenceScope); + + expect(result).toBe(BBBRole.VIEWER); + }); + }); + + describe('when user has neither START_MEETING nor JOIN_MEETING permission in course scope', () => { const setup = () => { const user = userFactory.buildWithId(); const entity = courseFactory.buildWithId(); @@ -480,6 +777,172 @@ describe(VideoConferenceService.name, () => { await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); }); }); + + describe('when user has neither editor nor viewer role in room scope and is not authorized for the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.buildWithId(); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: role.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: 'anotherUserId', roles: [] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: 'anotherUserId', roles: [] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, roomId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, roomId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); + + describe('when user has neither editor nor viewer role in room scope but is authorized for the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const role = roleFactory.buildWithId(); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: user.id, roleId: role.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const conferenceScope = VideoConferenceScope.ROOM; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: user.id, roles: [] }], + schoolId: room.schoolId, + }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + return { + user, + userId: user.id, + room, + roomId: room.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, roomId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, roomId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); + + describe('when user has neither editor nor reader role in video conference node and is not authorized for the node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: 'anotherUserId', roles: [] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, elementId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); + + describe('when user has neither editor nor reader role in video conference node but is authorized for the node', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const element = videoConferenceElementFactory.build(); + const conferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: user.id, roles: [] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + return { + user, + userId: user.id, + element, + elementId: element.id, + conferenceScope, + }; + }; + + it('should throw a ForbiddenException', async () => { + const { userId, conferenceScope, elementId } = setup(); + + const callDetermineBbbRole = () => service.determineBbbRole(userId, elementId, conferenceScope); + + await expect(callDetermineBbbRole).rejects.toThrow(new ForbiddenException(ErrorStatus.INSUFFICIENT_PERMISSION)); + }); + }); }); describe('throwOnFeaturesDisabled', () => { @@ -541,21 +1004,22 @@ describe(VideoConferenceService.name, () => { describe('getScopeInfo', () => { const setup = () => { const userId = 'user-id'; - const conferenceScope: VideoConferenceScope = VideoConferenceScope.COURSE; + const scopeId = new ObjectId().toHexString(); configService.get.mockReturnValue('https://api.example.com'); return { userId, - conferenceScope, + scopeId, }; }; describe('when conference scope is VideoConferenceScope.COURSE', () => { it('should return scope information for a course', async () => { - const { userId, conferenceScope, scopeId } = setup(); + const { userId, scopeId } = setup(); + const conferenceScope: VideoConferenceScope = VideoConferenceScope.COURSE; const course: Course = courseFactory.buildWithId({ name: 'Course' }); course.id = scopeId; courseService.findById.mockResolvedValue(course); @@ -564,7 +1028,7 @@ describe(VideoConferenceService.name, () => { expect(result).toEqual({ scopeId, - scopeName: 'courses', + scopeName: VideoConferenceScope.COURSE, logoutUrl: `${service.hostUrl}/courses/${scopeId}?activeTab=tools`, title: course.name, }); @@ -572,6 +1036,44 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when conference scope is VideoConferenceScope.ROOM', () => { + it('should return scope information for a room', async () => { + const { userId } = setup(); + const conferenceScope: VideoConferenceScope = VideoConferenceScope.ROOM; + const room = roomFactory.build({ name: 'Room' }); + roomService.getSingleRoom.mockResolvedValueOnce(room); + + const result: ScopeInfo = await service.getScopeInfo(userId, room.id, conferenceScope); + + expect(result).toEqual({ + scopeId: room.id, + scopeName: VideoConferenceScope.ROOM, + logoutUrl: `${service.hostUrl}/rooms/${room.id}`, + title: room.name, + }); + expect(roomService.getSingleRoom).toHaveBeenCalledWith(room.id); + }); + }); + + describe('when conference scope is VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT', () => { + it('should return scope information for a video conference element', async () => { + const { userId } = setup(); + const conferenceScope: VideoConferenceScope = VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT; + const element = videoConferenceElementFactory.build({ title: 'Element' }); + boardNodeService.findByClassAndId.mockResolvedValueOnce(element); + + const result: ScopeInfo = await service.getScopeInfo(userId, element.id, conferenceScope); + + expect(result).toEqual({ + scopeId: element.id, + scopeName: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, + logoutUrl: `${service.hostUrl}/boards/${element.id}`, + title: element.title, + }); + expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(VideoConferenceElement, element.id); + }); + }); + describe('when conference scope is VideoConferenceScope.EVENT', () => { it('should return scope information for a event', async () => { const { userId, scopeId } = setup(); @@ -583,7 +1085,7 @@ describe(VideoConferenceService.name, () => { expect(result).toEqual({ scopeId: teamId, - scopeName: 'teams', + scopeName: VideoConferenceScope.EVENT, logoutUrl: `${service.hostUrl}/teams/${teamId}?activeTab=events`, title: event.title, }); @@ -602,14 +1104,46 @@ describe(VideoConferenceService.name, () => { }); }); - describe('getUserRoleAndGuestStatusByUserId', () => { + describe('getUserRoleAndGuestStatusByUserIdForBbb', () => { const setup = (conferenceScope: VideoConferenceScope) => { const user: UserDO = userDoFactory.buildWithId(); const userId = user.id as EntityId; + const roomUser = userFactory.buildWithId(); const scopeId = new ObjectId().toHexString(); const team = teamFactory .withRoleAndUserId(roleFactory.build({ name: RoleName.EXPERT }), new ObjectId().toHexString()) .build(); + const roleEditor = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT] }); + const group = groupFactory.build({ + type: GroupTypes.ROOM, + users: [{ userId: roomUser.id, roleId: roleEditor.id }], + }); + const room = roomFactory.build(); + roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + roomMembershipService.getRoomMembershipAuthorizable + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: roomUser.id, roles: [roleEditor] }], + schoolId: room.schoolId, + }) + .mockResolvedValueOnce({ + id: 'foo', + roomId: room.id, + members: [{ userId: roomUser.id, roles: [roleEditor] }], + schoolId: room.schoolId, + }); + + const element = videoConferenceElementFactory.build(); + const boardNodeAuthorizable = new BoardNodeAuthorizable({ + users: [{ userId: roomUser.id, roles: [BoardRoles.READER] }], + id: element.id, + boardNode: element, + rootNode: columnBoardFactory.build(), + }); + boardNodeAuthorizableService.getBoardAuthorizable + .mockResolvedValueOnce(boardNodeAuthorizable) + .mockResolvedValueOnce(boardNodeAuthorizable); configService.get.mockReturnValue('https://api.example.com'); @@ -617,6 +1151,7 @@ describe(VideoConferenceService.name, () => { user, userId, conferenceScope, + roomUser, scopeId, team, }; @@ -652,6 +1187,67 @@ describe(VideoConferenceService.name, () => { }); }); + describe('when conference scope is VideoConferenceScope.ROOM', () => { + it('should call roomService.getSingleRoom', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.ROOM); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(roomService.getSingleRoom).toHaveBeenCalledWith(scopeId); + }); + + it('should call userService.findById', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.ROOM); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + + it('should return the user role and guest status for a room conference', async () => { + const { user, userId, conferenceScope, roomUser, scopeId } = setup(VideoConferenceScope.ROOM); + roomService.getSingleRoom.mockResolvedValue(roomFactory.build({ name: 'Room' })); + userService.findById.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(roomUser); + + const result = await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(result).toEqual({ role: BBBRole.MODERATOR, isGuest: false }); + }); + }); + + describe('when conference scope is VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT', () => { + it('should call boardNodeService.findByClassAndId', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(boardNodeService.findByClassAndId).toHaveBeenCalledWith(VideoConferenceElement, scopeId); + }); + + it('should call userService.findById', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT); + userService.findById.mockResolvedValue(user); + + await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(userService.findById).toHaveBeenCalledWith(userId); + }); + + it('should return the user role and guest status for a video conference element conference', async () => { + const { user, userId, conferenceScope, scopeId } = setup(VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT); + courseService.findById.mockResolvedValue(courseFactory.buildWithId({ name: 'Course' })); + userService.findById.mockResolvedValue(user); + + const result = await service.getUserRoleAndGuestStatusByUserIdForBbb(userId, scopeId, conferenceScope); + + expect(result).toEqual({ role: BBBRole.MODERATOR, isGuest: false }); + }); + }); + describe('when conference scope is VideoConferenceScope.EVENT', () => { it('should throw a ForbiddenException if the user is not an expert for an event conference', async () => { const { userId, scopeId, team } = setup(VideoConferenceScope.EVENT); diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.ts b/apps/server/src/modules/video-conference/service/video-conference.service.ts index f910e9e215b..68dd4f1a6f8 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.ts @@ -10,19 +10,29 @@ import { Course, TeamEntity, TeamUserEntity, User } from '@shared/domain/entity' import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { TeamsRepo, VideoConferenceRepo } from '@shared/repo'; +import { BoardNodeAuthorizableService, BoardNodeService, BoardRoles } from '@src/modules/board'; +import { VideoConferenceElement } from '@src/modules/board/domain'; +import { Room, RoomService } from '@src/modules/room'; +import { RoomMembershipService } from '@src/modules/room-membership'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; import { VideoConferenceOptions } from '../interface'; import { ScopeInfo, VideoConferenceState } from '../uc/dto'; import { VideoConferenceConfig } from '../video-conference-config'; +type ConferenceResource = Course | Room | TeamEntity | VideoConferenceElement; + @Injectable() export class VideoConferenceService { constructor( + private readonly boardNodeAuthorizableService: BoardNodeAuthorizableService, + private readonly boardNodeService: BoardNodeService, private readonly configService: ConfigService, private readonly courseService: CourseService, private readonly calendarService: CalendarService, private readonly authorizationService: AuthorizationService, + private readonly roomMembershipService: RoomMembershipService, + private readonly roomService: RoomService, private readonly schoolService: LegacySchoolService, private readonly teamsRepo: TeamsRepo, private readonly userService: UserService, @@ -51,7 +61,9 @@ export class VideoConferenceService { ): Promise { let isExpert = false; switch (conferenceScope) { - case VideoConferenceScope.COURSE: { + case VideoConferenceScope.COURSE: + case VideoConferenceScope.ROOM: + case VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT: { const user: UserDO = await this.userService.findById(userId); isExpert = this.existsOnlyExpertRole(user.roles); @@ -88,35 +100,76 @@ export class VideoConferenceService { } // should be public to expose ressources to UC for passing it to authrisation and improve performance - private async loadScopeRessources( - scopeId: EntityId, - scope: VideoConferenceScope - ): Promise { - let scopeRessource: Course | TeamEntity | null = null; + private async loadScopeResources(scopeId: EntityId, scope: VideoConferenceScope): Promise { + let scopeResource: ConferenceResource | null = null; if (scope === VideoConferenceScope.COURSE) { - scopeRessource = await this.courseService.findById(scopeId); + scopeResource = await this.courseService.findById(scopeId); } else if (scope === VideoConferenceScope.EVENT) { - scopeRessource = await this.teamsRepo.findById(scopeId); + scopeResource = await this.teamsRepo.findById(scopeId); + } else if (scope === VideoConferenceScope.ROOM) { + scopeResource = await this.roomService.getSingleRoom(scopeId); + } else if (scope === VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT) { + scopeResource = await this.boardNodeService.findByClassAndId(VideoConferenceElement, scopeId); } else { // Need to be solve the null with throw by it self. } - return scopeRessource; + return scopeResource; } private isNullOrUndefined(value: unknown): value is null { return !value; } - private hasStartMeetingAndCanRead(authorizableUser: User, entity: Course | TeamEntity): boolean { + private async hasStartMeetingAndCanRead(authorizableUser: User, entity: ConferenceResource): Promise { + if (entity instanceof Room) { + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(entity.id); + const roomMember = roomMembershipAuthorizable.members.find((member) => member.userId === authorizableUser.id); + + if (roomMember) { + return roomMember.roles.some((role) => role.name === RoleName.ROOMEDITOR); + } + + return false; + } + if (entity instanceof VideoConferenceElement) { + const boardDoAuthorizable = await this.boardNodeAuthorizableService.getBoardAuthorizable(entity); + const boardAuthorisedUser = boardDoAuthorizable.users.find((user) => user.userId === authorizableUser.id); + + if (boardAuthorisedUser) { + return boardAuthorisedUser?.roles.includes(BoardRoles.EDITOR); + } + + return false; + } const context = AuthorizationContextBuilder.read([Permission.START_MEETING]); const hasPermission = this.authorizationService.hasPermission(authorizableUser, entity, context); return hasPermission; } - private hasJoinMeetingAndCanRead(authorizableUser: User, entity: Course | TeamEntity): boolean { + private async hasJoinMeetingAndCanRead(authorizableUser: User, entity: ConferenceResource): Promise { + if (entity instanceof Room) { + const roomMembershipAuthorizable = await this.roomMembershipService.getRoomMembershipAuthorizable(entity.id); + const roomMember = roomMembershipAuthorizable.members.find((member) => member.userId === authorizableUser.id); + + if (roomMember) { + return roomMember.roles.some((role) => role.name === RoleName.ROOMVIEWER); + } + + return false; + } + if (entity instanceof VideoConferenceElement) { + const boardDoAuthorizable = await this.boardNodeAuthorizableService.getBoardAuthorizable(entity); + const boardAuthorisedUser = boardDoAuthorizable.users.find((user) => user.userId === authorizableUser.id); + + if (boardAuthorisedUser) { + return boardAuthorisedUser?.roles.includes(BoardRoles.READER); + } + + return false; + } const context = AuthorizationContextBuilder.read([Permission.JOIN_MEETING]); const hasPermission = this.authorizationService.hasPermission(authorizableUser, entity, context); @@ -125,16 +178,16 @@ export class VideoConferenceService { async determineBbbRole(userId: EntityId, scopeId: EntityId, scope: VideoConferenceScope): Promise { // ressource loading need to be move to uc - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course | null] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, ConferenceResource | null] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.loadScopeRessources(scopeId, scope), + this.loadScopeResources(scopeId, scope), ]); - if (!this.isNullOrUndefined(scopeRessource)) { - if (this.hasStartMeetingAndCanRead(authorizableUser, scopeRessource)) { + if (!this.isNullOrUndefined(scopeResource)) { + if (await this.hasStartMeetingAndCanRead(authorizableUser, scopeResource)) { return BBBRole.MODERATOR; } - if (this.hasJoinMeetingAndCanRead(authorizableUser, scopeRessource)) { + if (await this.hasJoinMeetingAndCanRead(authorizableUser, scopeResource)) { return BBBRole.VIEWER; } } @@ -167,7 +220,7 @@ export class VideoConferenceService { return { scopeId, - scopeName: 'courses', + scopeName: VideoConferenceScope.COURSE, logoutUrl: `${this.hostUrl}/courses/${scopeId}?activeTab=tools`, title: course.name, }; @@ -177,11 +230,31 @@ export class VideoConferenceService { return { scopeId: event.teamId, - scopeName: 'teams', + scopeName: VideoConferenceScope.EVENT, logoutUrl: `${this.hostUrl}/teams/${event.teamId}?activeTab=events`, title: event.title, }; } + case VideoConferenceScope.ROOM: { + const room: Room = await this.roomService.getSingleRoom(scopeId); + + return { + scopeId: room.id, + scopeName: VideoConferenceScope.ROOM, + logoutUrl: `${this.hostUrl}/rooms/${room.id}`, + title: room.name, + }; + } + case VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT: { + const element = await this.boardNodeService.findByClassAndId(VideoConferenceElement, scopeId); + + return { + scopeId: element.id, + scopeName: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, + logoutUrl: `${this.hostUrl}/boards/${element.rootId}`, + title: element.title, + }; + } default: throw new BadRequestException('Unknown scope name'); } diff --git a/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts b/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts index 5ec0decd414..3f113faad68 100644 --- a/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts +++ b/apps/server/src/modules/video-conference/uc/dto/scope-info.interface.ts @@ -1,9 +1,10 @@ +import { VideoConferenceScope } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; export interface ScopeInfo { scopeId: EntityId; - scopeName: string; + scopeName: VideoConferenceScope; title: string; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts index 5af0215ba79..a2781a75a47 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts @@ -91,7 +91,7 @@ describe('VideoConferenceCreateUc', () => { const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -166,7 +166,7 @@ describe('VideoConferenceCreateUc', () => { const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts index e67412b567d..6ff2b00d63f 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.ts @@ -40,9 +40,9 @@ export class VideoConferenceCreateUc { private async create(currentUserId: EntityId, scope: ScopeRef, options: VideoConferenceOptions): Promise { /* need to be replace with - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, TeamEntity | Course] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.videoConferenceService.loadScopeRessources(scopeId, scope), + this.videoConferenceService.loadScopeResources(scopeId, scope), ]); */ const user: UserDO = await this.userService.findById(currentUserId); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts index fc31febf442..2225a0ffbda 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.spec.ts @@ -196,7 +196,7 @@ describe('VideoConferenceUc', () => { // Assert expect(result.scopeInfo.scopeId).toEqual(course.id); expect(result.scopeInfo.logoutUrl).toEqual(`${hostUrl}/courses/${course.id}?activeTab=tools`); - expect(result.scopeInfo.scopeName).toEqual('courses'); + expect(result.scopeInfo.scopeName).toEqual(VideoConferenceScope.COURSE); expect(result.scopeInfo.title).toEqual(course.name); expect(result.object).toEqual(course); }); @@ -209,7 +209,7 @@ describe('VideoConferenceUc', () => { expect(result.scopeInfo.scopeId).toEqual(event.teamId); expect(result.scopeInfo.title).toEqual(event.title); expect(result.scopeInfo.logoutUrl).toEqual(`${hostUrl}/teams/${event.teamId}?activeTab=events`); - expect(result.scopeInfo.scopeName).toEqual('teams'); + expect(result.scopeInfo.scopeName).toEqual(VideoConferenceScope.EVENT); expect(result.object).toEqual(team); }); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts index c9a162036b9..8eede135f7b 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-deprecated.uc.ts @@ -337,7 +337,7 @@ export class VideoConferenceDeprecatedUc { return { scopeInfo: { scopeId: refId, - scopeName: 'courses', + scopeName: VideoConferenceScope.COURSE, logoutUrl: `${this.hostURL}/courses/${refId}?activeTab=tools`, title: course.name, }, @@ -351,7 +351,7 @@ export class VideoConferenceDeprecatedUc { return { scopeInfo: { scopeId: event.teamId, - scopeName: 'teams', + scopeName: VideoConferenceScope.EVENT, logoutUrl: `${this.hostURL}/teams/${event.teamId}?activeTab=events`, title: event.title, }, diff --git a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts index ee552b35c17..2c640f3980a 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts @@ -61,7 +61,7 @@ describe('VideoConferenceEndUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -97,7 +97,7 @@ describe('VideoConferenceEndUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts index 211ade03e79..063e8382936 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.ts @@ -18,9 +18,9 @@ export class VideoConferenceEndUc { async end(currentUserId: EntityId, scope: ScopeRef): Promise> { /* need to be replace with - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, TeamEntity | Course] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.videoConferenceService.loadScopeRessources(scopeId, scope), + this.videoConferenceService.loadScopeResources(scopeId, scope), ]); */ const user: UserDO = await this.userService.findById(currentUserId); diff --git a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts index 4a9c7b77591..fd7a4e2ceb8 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts @@ -80,7 +80,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -121,7 +121,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -239,7 +239,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -287,7 +287,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; @@ -331,7 +331,7 @@ describe('VideoConferenceInfoUc', () => { const scope = { scope: VideoConferenceScope.COURSE, id: new ObjectId().toHexString() }; const scopeInfo: ScopeInfo = { scopeId: scope.id, - scopeName: 'scopeName', + scopeName: VideoConferenceScope.COURSE, title: 'title', logoutUrl: 'logoutUrl', }; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts index a490fa6f83a..1aaebb99858 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.ts @@ -19,9 +19,9 @@ export class VideoConferenceInfoUc { async getMeetingInfo(currentUserId: EntityId, scope: ScopeRef): Promise { /* need to be replace with - const [authorizableUser, scopeRessource]: [User, TeamEntity | Course] = await Promise.all([ + const [authorizableUser, scopeResource]: [User, TeamEntity | Course] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), - this.videoConferenceService.loadScopeRessources(scopeId, scope), + this.videoConferenceService.loadScopeResources(scopeId, scope), ]); */ const user: UserDO = await this.userService.findById(currentUserId); diff --git a/apps/server/src/modules/video-conference/video-conference.module.ts b/apps/server/src/modules/video-conference/video-conference.module.ts index 769af5e953f..f2c71237320 100644 --- a/apps/server/src/modules/video-conference/video-conference.module.ts +++ b/apps/server/src/modules/video-conference/video-conference.module.ts @@ -8,20 +8,28 @@ import { Module } from '@nestjs/common'; import { TeamsRepo } from '@shared/repo'; import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { LoggerModule } from '@src/core/logger'; +import { BoardModule } from '../board'; import { LearnroomModule } from '../learnroom'; import { BBBService } from './bbb'; import { VideoConferenceDeprecatedController } from './controller'; import { VideoConferenceService } from './service'; import { VideoConferenceDeprecatedUc } from './uc'; +import { RoleModule } from '../role'; +import { RoomMembershipModule } from '../room-membership'; +import { RoomModule } from '../room'; @Module({ imports: [ AuthorizationModule, AuthorizationReferenceModule, // can be removed wenn video-conference-deprecated is removed + BoardModule, CalendarModule, HttpModule, LegacySchoolModule, LoggerModule, + RoleModule, + RoomMembershipModule, + RoomModule, UserModule, LearnroomModule, UserModule, diff --git a/apps/server/src/shared/domain/entity/video-conference.entity.ts b/apps/server/src/shared/domain/entity/video-conference.entity.ts index eb30214b660..49eef001600 100644 --- a/apps/server/src/shared/domain/entity/video-conference.entity.ts +++ b/apps/server/src/shared/domain/entity/video-conference.entity.ts @@ -4,6 +4,8 @@ import { BaseEntityWithTimestamps } from './base.entity'; export enum TargetModels { COURSES = 'courses', EVENTS = 'events', + ROOMS = 'rooms', + VIDEO_CONFERENCE_ELEMENTS = 'video-conference-elements', } export class VideoConferenceOptions { diff --git a/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts b/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts index 3bf8f44e930..625f65e99f2 100644 --- a/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts +++ b/apps/server/src/shared/domain/interface/video-conference-scope.enum.ts @@ -1,4 +1,6 @@ export enum VideoConferenceScope { COURSE = 'course', EVENT = 'event', + ROOM = 'room', + VIDEO_CONFERENCE_ELEMENT = 'video-conference-element', } diff --git a/apps/server/src/shared/repo/videoconference/video-conference.repo.ts b/apps/server/src/shared/repo/videoconference/video-conference.repo.ts index e16878b7071..0c21483c37a 100644 --- a/apps/server/src/shared/repo/videoconference/video-conference.repo.ts +++ b/apps/server/src/shared/repo/videoconference/video-conference.repo.ts @@ -8,11 +8,15 @@ import { BaseDORepo } from '@shared/repo/base.do.repo'; const TargetModelsMapping = { [VideoConferenceScope.EVENT]: TargetModels.EVENTS, [VideoConferenceScope.COURSE]: TargetModels.COURSES, + [VideoConferenceScope.ROOM]: TargetModels.ROOMS, + [VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT]: TargetModels.VIDEO_CONFERENCE_ELEMENTS, }; const VideoConferencingScopeMapping = { [TargetModels.EVENTS]: VideoConferenceScope.EVENT, [TargetModels.COURSES]: VideoConferenceScope.COURSE, + [TargetModels.ROOMS]: VideoConferenceScope.ROOM, + [TargetModels.VIDEO_CONFERENCE_ELEMENTS]: VideoConferenceScope.VIDEO_CONFERENCE_ELEMENT, }; @Injectable() diff --git a/config/default.schema.json b/config/default.schema.json index 4c51ff2338c..196edcfc6b9 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1209,6 +1209,11 @@ "default": false, "description": "Enable link elements in column board." }, + "FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enable video conference elements in column board." + }, "COLUMN_BOARD_HELP_LINK": { "type": "string", "default": "https://docs.dbildungscloud.de/pages/viewpage.action?pageId=270827606", diff --git a/config/development.json b/config/development.json index c79872b8dbf..1f7f5b7b770 100644 --- a/config/development.json +++ b/config/development.json @@ -77,6 +77,7 @@ "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, "FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED": true, + "FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED": true, "FEATURE_BOARD_LAYOUT_ENABLED": true, "SCHULCONNEX_CLIENT": { "API_URL": "http://localhost:8888/v1/",