From 32bc14a6c86ca6e4a0b528452ccd2a804ba70a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:38:57 +0100 Subject: [PATCH 1/5] N21-2313 Improve schulconnex group provisioning runtime (#5394) --- .../schulconnex-client-config.ts | 1 + .../schulconnex-client.module.ts | 3 +- .../schulconnex-rest-client-options.ts | 2 + .../schulconnex-rest-client.spec.ts | 6 +- .../schulconnex-rest-client.ts | 8 ++- .../modules/idp-console/idp-console.config.ts | 13 +++-- .../group-provisioning-info.loggable.spec.ts | 38 +++++++++++++ .../group-provisioning-info.loggable.ts | 22 +++++++ .../modules/provisioning/loggable/index.ts | 1 + .../provisioning/provisioning.config.ts | 1 + .../strategy/schulconnex/sanis.strategy.ts | 7 ++- .../schulconnex-response-mapper.spec.ts | 57 +++++++++++++++++++ .../schulconnex-response-mapper.ts | 19 +++++-- .../schulconnex/schulconnex.strategy.spec.ts | 5 ++ .../schulconnex/schulconnex.strategy.ts | 10 +++- .../testing/external-group-dto.factory.ts | 18 ++++++ .../external-group-user-dto.factory.ts | 12 ++++ .../src/modules/provisioning/testing/index.ts | 2 + .../src/modules/server/server.config.ts | 6 ++ config/default.schema.json | 9 +++ 20 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts create mode 100644 apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts create mode 100644 apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts create mode 100644 apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts index e7d5e6b23b6..709f4a1ea71 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts @@ -1,4 +1,5 @@ export interface SchulconnexClientConfig { + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS: number; SCHULCONNEX_CLIENT__API_URL?: string; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts index b16a7f55458..bff42d9bbdb 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts @@ -8,7 +8,7 @@ import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options' @Module({}) export class SchulconnexClientModule { - static registerAsync(): DynamicModule { + public static registerAsync(): DynamicModule { return { imports: [HttpModule, LoggerModule], module: SchulconnexClientModule, @@ -27,6 +27,7 @@ export class SchulconnexClientModule { tokenEndpoint: configService.get('SCHULCONNEX_CLIENT__TOKEN_ENDPOINT'), clientId: configService.get('SCHULCONNEX_CLIENT__CLIENT_ID'), clientSecret: configService.get('SCHULCONNEX_CLIENT__CLIENT_SECRET'), + personInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS'), personenInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS'), policiesInfoTimeoutInMs: configService.get('SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS'), }; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts index 01391ec207e..5316df7e74a 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts @@ -7,6 +7,8 @@ export interface SchulconnexRestClientOptions { clientSecret?: string; + personInfoTimeoutInMs?: number; + personenInfoTimeoutInMs?: number; policiesInfoTimeoutInMs?: number; diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts index 5af753d8554..49ad5e2fa29 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts @@ -25,8 +25,9 @@ describe(SchulconnexRestClient.name, () => { clientId: 'clientId', clientSecret: 'clientSecret', tokenEndpoint: 'https://schulconnex.url/token', - personenInfoTimeoutInMs: 30000, - policiesInfoTimeoutInMs: 30000, + personInfoTimeoutInMs: 30001, + personenInfoTimeoutInMs: 30002, + policiesInfoTimeoutInMs: 30003, }; beforeAll(() => { @@ -100,6 +101,7 @@ describe(SchulconnexRestClient.name, () => { Authorization: `Bearer ${accessToken}`, 'Accept-Encoding': 'gzip', }, + timeout: options.personInfoTimeoutInMs, }); }); diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts index 820668c16ce..d9a3b829cd3 100644 --- a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts @@ -30,10 +30,14 @@ export class SchulconnexRestClient implements SchulconnexApiInterface { this.SCHULCONNEX_API_BASE_URL = options.apiUrl || ''; } - public async getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { + public getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { const url: URL = new URL(options?.overrideUrl ?? `${this.SCHULCONNEX_API_BASE_URL}/person-info`); - const response: Promise = this.getRequest(url, accessToken); + const response: Promise = this.getRequest( + url, + accessToken, + this.options.personInfoTimeoutInMs + ); return response; } diff --git a/apps/server/src/modules/idp-console/idp-console.config.ts b/apps/server/src/modules/idp-console/idp-console.config.ts index 08a1e9fe301..30b14264858 100644 --- a/apps/server/src/modules/idp-console/idp-console.config.ts +++ b/apps/server/src/modules/idp-console/idp-console.config.ts @@ -1,12 +1,12 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; import { ConsoleWriterConfig } from '@infra/console'; -import { LoggerConfig } from '@src/core/logger'; +import { RabbitMqConfig } from '@infra/rabbitmq'; +import { SchulconnexClientConfig } from '@infra/schulconnex-client'; import { AccountConfig } from '@modules/account'; -import { UserConfig } from '@modules/user'; import { SynchronizationConfig } from '@modules/synchronization'; -import { SchulconnexClientConfig } from '@infra/schulconnex-client'; -import { Configuration } from '@hpi-schul-cloud/commons'; +import { UserConfig } from '@modules/user'; import { LanguageType } from '@shared/domain/interface'; -import { RabbitMqConfig } from '@infra/rabbitmq'; +import { LoggerConfig } from '@src/core/logger'; export interface IdpConsoleConfig extends ConsoleWriterConfig, @@ -33,6 +33,9 @@ const config: IdpConsoleConfig = { TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION: Configuration.get( 'TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION' ) as string, + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, diff --git a/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts new file mode 100644 index 00000000000..fb67f57ea03 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.spec.ts @@ -0,0 +1,38 @@ +import { externalGroupDtoFactory, externalGroupUserDtoFactory } from '../testing'; +import { GroupProvisioningInfoLoggable } from './group-provisioning-info.loggable'; + +describe(GroupProvisioningInfoLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const groupCount = 2; + const otherUserCount = 5; + const totalUserCount = groupCount * otherUserCount + groupCount; + const externalGroups = externalGroupDtoFactory.buildList(groupCount, { + otherUsers: externalGroupUserDtoFactory.buildList(otherUserCount), + }); + + const loggable = new GroupProvisioningInfoLoggable(externalGroups, 100); + + return { + loggable, + totalUserCount, + groupCount, + }; + }; + + it('should return a loggable message', () => { + const { loggable, totalUserCount, groupCount } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Group provisioning has finished.', + data: { + groupCount, + userCount: totalUserCount, + durationMs: 100, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts new file mode 100644 index 00000000000..537a31e7855 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/group-provisioning-info.loggable.ts @@ -0,0 +1,22 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ExternalGroupDto } from '../dto'; + +export class GroupProvisioningInfoLoggable implements Loggable { + constructor(private readonly groups: ExternalGroupDto[], private readonly durationMs: number) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + const userCount = this.groups.reduce( + (count: number, group: ExternalGroupDto) => count + (group.otherUsers?.length ?? 0), + this.groups.length + ); + + return { + message: 'Group provisioning has finished.', + data: { + groupCount: this.groups.length, + userCount, + durationMs: this.durationMs, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts index 01e7c2ae5cd..93010e22353 100644 --- a/apps/server/src/modules/provisioning/loggable/index.ts +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -8,3 +8,4 @@ export { FetchingPoliciesInfoFailedLoggable } from './fetching-policies-info-fai export { PoliciesInfoErrorResponseLoggable } from './policies-info-error-response-loggable'; export { UserRoleUnknownLoggableException } from './user-role-unknown.loggable-exception'; export { SchoolMissingLoggableException } from './school-missing.loggable-exception'; +export { GroupProvisioningInfoLoggable } from './group-provisioning-info.loggable'; diff --git a/apps/server/src/modules/provisioning/provisioning.config.ts b/apps/server/src/modules/provisioning/provisioning.config.ts index 0314bf8b277..9ba480fbcea 100644 --- a/apps/server/src/modules/provisioning/provisioning.config.ts +++ b/apps/server/src/modules/provisioning/provisioning.config.ts @@ -2,6 +2,7 @@ export interface ProvisioningConfig { FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: boolean; FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED: boolean; PROVISIONING_SCHULCONNEX_POLICIES_INFO_URL: string; + PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT?: number; FEATURE_SANIS_GROUP_PROVISIONING_ENABLED: boolean; FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: boolean; } diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts index bc57f6fee50..6a441c35909 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/sanis.strategy.ts @@ -46,9 +46,9 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, protected readonly configService: ConfigService, + protected readonly logger: Logger, private readonly responseMapper: SchulconnexResponseMapper, - private readonly schulconnexRestClient: SchulconnexRestClient, - private readonly logger: Logger + private readonly schulconnexRestClient: SchulconnexRestClient ) { super( schulconnexSchoolProvisioningService, @@ -58,7 +58,8 @@ export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { schulconnexLicenseProvisioningService, schulconnexToolProvisioningService, groupService, - configService + configService, + logger ); } diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts index 36ad4321943..1d413fd4aad 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.spec.ts @@ -47,6 +47,11 @@ describe(SchulconnexResponseMapper.name, () => { mapper = module.get(SchulconnexResponseMapper); }); + beforeEach(() => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = false; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = undefined; + }); + describe('mapToExternalSchoolDto', () => { describe('when a schulconnex response is provided', () => { const setup = () => { @@ -316,6 +321,8 @@ describe(SchulconnexResponseMapper.name, () => { describe('when other participants have unknown roles', () => { const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); schulconnexResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = [ { @@ -514,6 +521,56 @@ describe(SchulconnexResponseMapper.name, () => { ); }); }); + + describe('when there are too many users in groups', () => { + const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 1; + + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + + return { + schulconnexResponse, + }; + }; + + it('should not map other group users', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).toEqual([ + expect.objectContaining>({ + otherUsers: undefined, + }), + ]); + }); + }); + + describe('when there are not too many users in groups', () => { + const setup = () => { + config.FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED = true; + config.PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT = 10; + + const schulconnexResponse: SchulconnexResponse = schulconnexResponseFactory.build(); + + return { + schulconnexResponse, + }; + }; + + it('should not map other group users', () => { + const { schulconnexResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(schulconnexResponse); + + expect(result).not.toEqual([ + expect.objectContaining({ + otherUsers: undefined, + }), + ]); + }); + }); }); describe('mapLernperiode', () => { diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts index 4a7543cac70..07ce885a1b9 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex-response-mapper.ts @@ -120,14 +120,25 @@ export class SchulconnexResponseMapper { return undefined; } + const usersInGroupsCount: number = groups.reduce( + (count: number, group: SchulconnexGruppenResponse) => count + (group.sonstige_gruppenzugehoerige?.length ?? 0), + groups.length + ); + const limit: number | undefined = this.configService.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT'); + const shouldProvisionOtherUsers: boolean = limit === undefined || usersInGroupsCount < limit; + const mapped: ExternalGroupDto[] = groups - .map((group) => this.mapExternalGroup(source, group)) - .filter((group): group is ExternalGroupDto => group !== null); + .map((group: SchulconnexGruppenResponse) => this.mapExternalGroup(source, group, shouldProvisionOtherUsers)) + .filter((group: ExternalGroupDto | null): group is ExternalGroupDto => group !== null); return mapped; } - private mapExternalGroup(source: SchulconnexResponse, group: SchulconnexGruppenResponse): ExternalGroupDto | null { + private mapExternalGroup( + source: SchulconnexResponse, + group: SchulconnexGruppenResponse, + shouldProvisionOtherUsers: boolean + ): ExternalGroupDto | null { const groupType: GroupTypes | undefined = GroupTypeMapping[group.gruppe.typ]; if (!groupType) { @@ -144,7 +155,7 @@ export class SchulconnexResponseMapper { } let otherUsers: ExternalGroupUserDto[] | undefined; - if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED')) { + if (this.configService.get('FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED') && shouldProvisionOtherUsers) { otherUsers = group.sonstige_gruppenzugehoerige ? group.sonstige_gruppenzugehoerige .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts index 26fbc0202df..f86346d37eb 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.spec.ts @@ -14,6 +14,7 @@ import { legacySchoolDoFactory, userDoFactory, } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, ExternalSchoolDto, @@ -98,6 +99,10 @@ describe(SchulconnexProvisioningStrategy.name, () => { get: jest.fn().mockImplementation((key: keyof ProvisioningConfig) => config[key]), }, }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts index b965aabebcd..1c3737a6877 100644 --- a/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/schulconnex/schulconnex.strategy.ts @@ -2,7 +2,9 @@ import { Group, GroupService } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; +import { Logger } from '@src/core/logger'; import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; +import { GroupProvisioningInfoLoggable } from '../../loggable'; import { ProvisioningConfig } from '../../provisioning.config'; import { ProvisioningStrategy } from '../base.strategy'; import { @@ -24,7 +26,8 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate protected readonly schulconnexLicenseProvisioningService: SchulconnexLicenseProvisioningService, protected readonly schulconnexToolProvisioningService: SchulconnexToolProvisioningService, protected readonly groupService: GroupService, - protected readonly configService: ConfigService + protected readonly configService: ConfigService, + protected readonly logger: Logger ) { super(); } @@ -61,6 +64,8 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate } private async provisionGroups(data: OauthDataDto, school?: LegacySchoolDo): Promise { + const startTime = performance.now(); + await this.removeUserFromGroups(data); if (data.externalGroups) { @@ -96,6 +101,9 @@ export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrate await Promise.all(groupProvisioningPromises); } + + const endTime = performance.now(); + this.logger.warning(new GroupProvisioningInfoLoggable(data.externalGroups ?? [], endTime - startTime)); } private async removeUserFromGroups(data: OauthDataDto): Promise { diff --git a/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts new file mode 100644 index 00000000000..d33808d811a --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-group-dto.factory.ts @@ -0,0 +1,18 @@ +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { GroupTypes } from '../../group'; +import { ExternalGroupDto } from '../dto'; +import { externalGroupUserDtoFactory } from './external-group-user-dto.factory'; + +export const externalGroupDtoFactory = Factory.define( + ({ sequence }) => + new ExternalGroupDto({ + type: GroupTypes.CLASS, + name: `External Group ${sequence}`, + externalId: new UUID().toString(), + user: externalGroupUserDtoFactory.build(), + otherUsers: externalGroupUserDtoFactory.buildList(2), + from: new Date(), + until: new Date(), + }) +); diff --git a/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts b/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts new file mode 100644 index 00000000000..938eff3e073 --- /dev/null +++ b/apps/server/src/modules/provisioning/testing/external-group-user-dto.factory.ts @@ -0,0 +1,12 @@ +import { RoleName } from '@shared/domain/interface'; +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { ExternalGroupUserDto } from '../dto'; + +export const externalGroupUserDtoFactory = Factory.define( + () => + new ExternalGroupUserDto({ + externalUserId: new UUID().toString(), + roleName: RoleName.TEACHER, + }) +); diff --git a/apps/server/src/modules/provisioning/testing/index.ts b/apps/server/src/modules/provisioning/testing/index.ts index 770f3e74f37..32854894142 100644 --- a/apps/server/src/modules/provisioning/testing/index.ts +++ b/apps/server/src/modules/provisioning/testing/index.ts @@ -1 +1,3 @@ export { externalUserDtoFactory } from './external-user-dto.factory'; +export { externalGroupDtoFactory } from './external-group-dto.factory'; +export { externalGroupUserDtoFactory } from './external-group-user-dto.factory'; diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 4f09ff5fe30..078662d11f4 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -255,12 +255,18 @@ const config: ServerConfig = { SCHULCONNEX_CLIENT__CLIENT_SECRET: Configuration.has('SCHULCONNEX_CLIENT__CLIENT_SECRET') ? (Configuration.get('SCHULCONNEX_CLIENT__CLIENT_SECRET') as string) : undefined, + SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSON_INFO_TIMEOUT_IN_MS' + ) as number, SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' ) as number, SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS: Configuration.get( 'SCHULCONNEX_CLIENT__POLICIES_INFO_TIMEOUT_IN_MS' ) as number, + PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT: Configuration.has('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT') + ? (Configuration.get('PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT') as number) + : undefined, FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, FEATURE_MEDIA_SHELF_ENABLED: Configuration.get('FEATURE_MEDIA_SHELF_ENABLED') as boolean, FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: Configuration.get( diff --git a/config/default.schema.json b/config/default.schema.json index 38af2238ce2..4c51ff2338c 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1574,6 +1574,11 @@ "type": "string", "description": "Client secret for accessing the schulconnex API (from server vault)" }, + "PERSON_INFO_TIMEOUT_IN_MS": { + "type": "integer", + "description": "Timeout in milliseconds for fetching person info from schulconnex", + "default": 3000 + }, "PERSONEN_INFO_TIMEOUT_IN_MS": { "type": "integer", "description": "Timeout in milliseconds for fetching personen info from schulconnex", @@ -1637,6 +1642,10 @@ "description": "URL for fetching policies info from moin.schule schulconnex", "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/policies-info"] }, + "PROVISIONING_SCHULCONNEX_GROUP_USERS_LIMIT": { + "type": "number", + "description": "Maximum number of users in group that still get processed during schulconnex provisioning" + }, "BOARD_COLLABORATION_URI": { "type": "string", "default": "ws://localhost:4450", From 9737e90c4b258604f4a330cfb55dd5744a95eea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:52:54 +0100 Subject: [PATCH 2/5] N21-2317 Skip failed migrations in migration wizard (#5398) --- .../src/modules/user-import/loggable/index.ts | 1 + .../user-migration-failed.loggable.spec.ts | 38 +++++++++++ .../user-migration-failed.loggable.ts | 20 ++++++ .../user-import/uc/user-import.uc.spec.ts | 67 ++++++++++++++++++- .../modules/user-import/uc/user-import.uc.ts | 26 ++++--- 5 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts create mode 100644 apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts diff --git a/apps/server/src/modules/user-import/loggable/index.ts b/apps/server/src/modules/user-import/loggable/index.ts index 5866aa22e61..3d18659984b 100644 --- a/apps/server/src/modules/user-import/loggable/index.ts +++ b/apps/server/src/modules/user-import/loggable/index.ts @@ -12,3 +12,4 @@ export { UserMigrationIsNotEnabledLoggableException } from './user-migration-not export { UserMigrationCanceledLoggable } from './user-migration-canceled.loggable'; export { UserAlreadyMigratedLoggable } from './user-already-migrated.loggable'; export { UserLoginMigrationNotActiveLoggableException } from './user-login-migration-not-active.loggable-exception'; +export { UserMigrationFailedLoggable } from './user-migration-failed.loggable'; diff --git a/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts new file mode 100644 index 00000000000..96363bb1ec9 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.spec.ts @@ -0,0 +1,38 @@ +import { NotFoundException } from '@nestjs/common'; +import { importUserFactory, setupEntities } from '@shared/testing'; +import { UserMigrationFailedLoggable } from './user-migration-failed.loggable'; + +describe(UserMigrationFailedLoggable.name, () => { + describe('getLogMessage', () => { + const setup = async () => { + await setupEntities(); + const importUser = importUserFactory.build(); + const error = new NotFoundException('user not found'); + const loggable = new UserMigrationFailedLoggable(importUser, error); + + return { + loggable, + importUser, + error, + }; + }; + + it('should return the correct log message', async () => { + const { loggable, importUser, error } = await setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_MIGRATION_FAILED', + message: 'An error occurred while migrating a user with the migration wizard.', + stack: error.stack, + data: { + externalUserId: importUser.externalId, + userId: importUser.user?.id, + errorName: error.name, + errorMsg: error.message, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts new file mode 100644 index 00000000000..8f382e8424e --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-failed.loggable.ts @@ -0,0 +1,20 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ImportUser } from '../entity'; + +export class UserMigrationFailedLoggable implements Loggable { + constructor(private readonly importUser: ImportUser, private readonly error: Error) {} + + public getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_MIGRATION_FAILED', + message: 'An error occurred while migrating a user with the migration wizard.', + stack: this.error.stack, + data: { + externalUserId: this.importUser.externalId, + userId: this.importUser.user?.id, + errorName: this.error.name, + errorMsg: this.error.message, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index af5c6d96fca..b924f67f54f 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -33,7 +33,11 @@ import { import { Logger } from '@src/core/logger'; import { ImportUserFilter, ImportUserMatchCreatorScope } from '../domain/interface'; import { ImportUser, MatchCreator } from '../entity'; -import { SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable } from '../loggable'; +import { + SchoolNotMigratedLoggableException, + UserAlreadyMigratedLoggable, + UserMigrationFailedLoggable, +} from '../loggable'; import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -699,6 +703,7 @@ describe('[ImportUserModule]', () => { ); }); }); + describe('when user is already migrated', () => { const setup = () => { const system = systemEntityFactory.buildWithId(); @@ -762,6 +767,66 @@ describe('[ImportUserModule]', () => { expect(logger.notice).toHaveBeenCalledWith(new UserAlreadyMigratedLoggable(importUser.user!.id)); }); }); + + describe('when a user migration fails', () => { + const setup = () => { + const system = systemEntityFactory.buildWithId(); + const schoolEntity = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ + school: schoolEntity, + }); + const school = legacySchoolDoFactory.build({ + id: schoolEntity.id, + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: true, + inMaintenanceSince: new Date(), + systems: [system.id], + }); + const importUser = importUserFactory.buildWithId({ + school: schoolEntity, + user: userFactory.buildWithId({ + school: schoolEntity, + }), + matchedBy: MatchCreator.AUTO, + system, + externalId: 'externalId', + }); + const importUserWithoutUser = importUserFactory.buildWithId({ + school: schoolEntity, + system, + }); + const error = new Error(); + + userRepo.findById.mockResolvedValueOnce(user); + userService.findByExternalId.mockResolvedValueOnce(null); + schoolService.getSchoolById.mockResolvedValueOnce(school); + importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser, importUserWithoutUser], 2]); + userMigrationService.migrateUser.mockRejectedValueOnce(error); + config.FEATURE_MIGRATION_WIZARD_WITH_USER_LOGIN_MIGRATION = true; + + return { + user, + importUser, + importUserWithoutUser, + error, + }; + }; + + it('should not throw', async () => { + const { user } = setup(); + + await expect(uc.saveAllUsersMatches(user.id)).resolves.not.toThrow(); + }); + + it('should log information for skipped user ', async () => { + const { user, importUser, error } = setup(); + + await uc.saveAllUsersMatches(user.id); + + expect(logger.warning).toHaveBeenCalledWith(new UserMigrationFailedLoggable(importUser, error)); + }); + }); }); describe('when the user does not have an account', () => { diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index b33363583c4..69e8868ce7e 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -15,6 +15,10 @@ import { IFindOptions, Permission } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; +import { isError } from 'lodash'; + +import { ImportUserFilter, ImportUserMatchCreatorScope, ImportUserNameMatchFilter } from '../domain/interface'; +import { ImportUser, MatchCreator } from '../entity'; import { MigrationMayBeCompleted, MigrationMayNotBeCompleted, @@ -23,10 +27,8 @@ import { SchoolInUserMigrationStartLoggable, SchoolNotMigratedLoggableException, UserAlreadyMigratedLoggable, + UserMigrationFailedLoggable, } from '../loggable'; - -import { ImportUserMatchCreatorScope, ImportUserNameMatchFilter, ImportUserFilter } from '../domain/interface'; -import { ImportUser, MatchCreator } from '../entity'; import { ImportUserRepo } from '../repo'; import { UserImportService } from '../service'; import { UserImportConfig } from '../user-import-config'; @@ -200,12 +202,18 @@ export class UserImportUc { }, }); for (const importUser of importUsers) { - // TODO: Find a better solution for this loop - // this needs to be synchronous, because otherwise it was leading to - // server crush when working with larger number of users (e.g. 1000) - // eslint-disable-next-line no-await-in-loop - await this.updateUserAndAccount(importUser, school); - migratedUser += 1; + try { + // TODO: Find a better solution for this loop + // this needs to be synchronous, because otherwise it was leading to + // server crush when working with larger number of users (e.g. 1000) + // eslint-disable-next-line no-await-in-loop + await this.updateUserAndAccount(importUser, school); + migratedUser += 1; + } catch (error: unknown) { + if (isError(error)) { + this.logger.warning(new UserMigrationFailedLoggable(importUser, error)); + } + } } } From 0df7b67d70a1431e35bbfaf5b57e861626d0649b Mon Sep 17 00:00:00 2001 From: hoeppner-dataport <106819770+hoeppner-dataport@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:26:02 +0100 Subject: [PATCH 3/5] BC-8519 - add room owner role (#5393) * add roomadmin and roomowner roles * remove room_delete permission from roomeditor * creating room memberships is now an operation seperate from adding users. ** creating a room memberships adds a user as owner ** adding a user fails if no membership exists yet * fix room membership rule to use room role to check permissions * ensure that room owners can not be removed from a room --------- Co-authored-by: Thomas Feldtkeller --- .../mikro-orm/Migration20241113100535.ts | 4 +- .../mikro-orm/Migration20241209165812.ts | 40 ++++ .../mikro-orm/Migration20241210152600.ts | 35 +++ .../room-membership.rule.spec.ts | 11 + .../authorization/room-membership.rule.ts | 36 ++- .../service/room-membership.service.spec.ts | 212 +++++++++--------- .../service/room-membership.service.ts | 33 +-- .../request/add-room-members.body.params.ts | 4 +- .../src/modules/room/api/room.uc.spec.ts | 2 +- apps/server/src/modules/room/api/room.uc.ts | 28 +-- .../api/test/room-add-members.api.spec.ts | 10 + .../room/api/test/room-create.api.spec.ts | 16 +- .../room/api/test/room-delete.api.spec.ts | 53 ++++- .../api/test/room-remove-members.api.spec.ts | 52 +++-- .../domain/interface/permission.enum.ts | 3 + .../shared/domain/interface/rolename.enum.ts | 9 +- backup/setup/migrations.json | 24 +- backup/setup/roles.json | 29 ++- 18 files changed, 429 insertions(+), 172 deletions(-) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241209165812.ts create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241210152600.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts index d233b984596..4bfb49bbbe2 100644 --- a/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts +++ b/apps/server/src/migrations/mikro-orm/Migration20241113100535.ts @@ -46,7 +46,7 @@ export class Migration20241113100535 extends Migration { ); if (teacherRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_CREATE added to role teacher.'); + console.info('Rollback: Permission ROOM_CREATE removed from role teacher.'); } const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( @@ -61,7 +61,7 @@ export class Migration20241113100535 extends Migration { ); if (roomEditorRoleUpdate.modifiedCount > 0) { - console.info('Rollback: Permission ROOM_DELETE added to role roomeditor.'); + console.info('Rollback: Permission ROOM_DELETE removed from role roomeditor.'); } } } diff --git a/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts new file mode 100644 index 00000000000..ffa54bbc778 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241209165812.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241209165812 extends Migration { + async up(): Promise { + // Add ROOM_OWNER role + await this.getCollection('roles').insertOne({ + name: 'roomowner', + permissions: [ + 'ROOM_VIEW', + 'ROOM_EDIT', + 'ROOM_DELETE', + 'ROOM_MEMBERS_ADD', + 'ROOM_MEMBERS_REMOVE', + 'ROOM_CHANGE_OWNER', + ], + }); + console.info( + 'Added ROOM_OWNER role with ROOM_VIEW, -_EDIT, _DELETE, -_MEMBERS_ADD, -_MEMBERS_REMOVE AND -_CHANGE_OWNER permission' + ); + + // Add ROOM_ADMIN role + await this.getCollection('roles').insertOne({ + name: 'roomadmin', + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_MEMBERS_ADD', 'ROOM_MEMBERS_REMOVE'], + }); + console.info( + 'Added ROOM_ADMIN role with ROOM_VIEW, ROOM_EDIT, ROOM_MEMBERS_ADD AND ROOM_MEMBERS_REMOVE permissions' + ); + } + + async down(): Promise { + // Remove ROOM_OWNER role + await this.getCollection('roles').deleteOne({ name: 'roomowner' }); + console.info('Rollback: Removed ROOM_OWNER role'); + + // Remove ROOM_ADMIN role + await this.getCollection('roles').deleteOne({ name: 'roomadmin' }); + console.info('Rollback: Removed ROOM_ADMIN role'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts new file mode 100644 index 00000000000..4bd331b5057 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241210152600.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241210152600 extends Migration { + async up(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info('Permission ROOM_DELETE removed from role roomeditor.'); + } + } + + async down(): Promise { + const roomEditorRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'roomeditor' }, + { + $set: { + permissions: ['ROOM_VIEW', 'ROOM_EDIT', 'ROOM_DELETE'], + }, + } + ); + + if (roomEditorRoleUpdate.modifiedCount > 0) { + console.info( + 'Rollback: Permissions ROOM_DELETE added to and ROOM_MEMBERS_ADD and ROOM_MEMBERS_REMOVE removed from role roomeditor.' + ); + } + } +} diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts index 0326bb2d02b..24384cd6b2b 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.spec.ts @@ -116,6 +116,17 @@ describe(RoomMembershipRule.name, () => { expect(res).toBe(false); }); + + it('should return false for change owner action', () => { + const { user, roomMembershipAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMembershipAuthorizable, { + action: Action.read, + requiredPermissions: [Permission.ROOM_CHANGE_OWNER], + }); + + expect(res).toBe(false); + }); }); describe('when user is not member of room', () => { diff --git a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts index 3336e93892f..544a8bdfacf 100644 --- a/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts +++ b/apps/server/src/modules/room-membership/authorization/room-membership.rule.ts @@ -10,18 +10,18 @@ export class RoomMembershipRule implements Rule { this.authorisationInjectionService.injectAuthorizationRule(this); } - public isApplicable(user: User, object: unknown): boolean { + public isApplicable(_: User, object: unknown): boolean { const isMatched = object instanceof RoomMembershipAuthorizable; return isMatched; } public hasPermission(user: User, object: RoomMembershipAuthorizable, context: AuthorizationContext): boolean { - const primarySchoolId = user.school.id; - const secondarySchools = user.secondarySchools ?? []; - const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + if (!this.hasAccessToSchool(user, object.schoolId)) { + return false; + } - if (![primarySchoolId, ...secondarySchoolIds].includes(object.schoolId)) { + if (!this.hasRequiredRoomPermissions(user, object, context.requiredPermissions)) { return false; } @@ -36,4 +36,30 @@ export class RoomMembershipRule implements Rule { } return permissionsThisUserHas.includes(Permission.ROOM_EDIT); } + + private hasAccessToSchool(user: User, schoolId: string): boolean { + const primarySchoolId = user.school.id; + const secondarySchools = user.secondarySchools ?? []; + const secondarySchoolIds = secondarySchools.map(({ school }) => school.id); + + return [primarySchoolId, ...secondarySchoolIds].includes(schoolId); + } + + private hasRequiredRoomPermissions( + user: User, + object: RoomMembershipAuthorizable, + requiredPermissions: string[] + ): boolean { + const roomPermissionsOfUser = this.resolveRoomPermissions(user, object); + const missingPermissions = requiredPermissions.filter((permission) => !roomPermissionsOfUser.includes(permission)); + return missingPermissions.length === 0; + } + + private resolveRoomPermissions(user: User, object: RoomMembershipAuthorizable): string[] { + const member = object.members.find((m) => m.userId === user.id); + if (!member) { + return []; + } + return member.roles.flatMap((role) => role.permissions ?? []); + } } diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts index 98763e33358..c6249661dc4 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.spec.ts @@ -87,26 +87,6 @@ describe('RoomMembershipService', () => { }; }; - it('should create new roomMembership when not exists', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalled(); - }); - - it('should save the schoolId of the room in the roomMembership', async () => { - const { user, room } = setup(); - - await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]); - - expect(roomMembershipRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ - schoolId: room.schoolId, - }) - ); - }); - describe('when no user is provided', () => { it('should throw an exception', async () => { const { room } = setup(); @@ -189,118 +169,148 @@ describe('RoomMembershipService', () => { }); describe('when roomMembership exists', () => { - const setup = () => { - const user = userFactory.buildWithId(); + const setupGroupAndRoom = (schoolId: string) => { const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build(); - const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id }); + const room = roomFactory.build({ schoolId }); + const roomMembership = roomMembershipFactory.build({ + roomId: room.id, + userGroupId: group.id, + schoolId, + }); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); groupService.findById.mockResolvedValue(group); - groupService.findGroups.mockResolvedValue({ total: 1, data: [group] }); + roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - return { - user, - room, - roomMembership, - group, - }; + return { group, room, roomMembership }; }; - it('should remove roomMembership', async () => { - const { user, room, group } = setup(); + const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { + groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); + }; - await service.removeMembersFromRoom(room.id, [user.id]); + const setupRoomRoles = () => { + const roomOwnerRole = roleFactory.buildWithId({ name: RoleName.ROOMOWNER }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + roleService.findByName.mockResolvedValue(roomOwnerRole); - expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); - }); - }); + return { roomOwnerRole, roomEditorRole }; + }; - const setupUserWithSecondarySchool = () => { - const secondarySchool = schoolFactory.build(); - const otherSchool = schoolFactory.build(); - const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); - const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); - const externalUser = userDoFactory.buildWithId({ - roles: [role], - secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], - }); + const setupUserWithSecondarySchool = () => { + const secondarySchool = schoolFactory.build(); + const otherSchool = schoolFactory.build(); + const role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); + const externalUser = userDoFactory.buildWithId({ + roles: [role], + secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }], + }); + const externalUserId = externalUser.id as string; - return { secondarySchool, externalUser, otherSchool }; - }; + return { secondarySchool, externalUser, externalUserId, otherSchool }; + }; - const setupGroupAndRoom = (schoolId: string) => { - const group = groupFactory.build({ type: GroupTypes.ROOM }); - const room = roomFactory.build({ schoolId }); - const roomMembership = roomMembershipFactory.build({ - roomId: room.id, - userGroupId: group.id, - schoolId, - }); + describe('when removing user from a different school, with no further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUserId } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); - return { group, room, roomMembership }; - }; + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUserId, roleId: roomEditorRole.id }); - const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => { - groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups }); - }; + mockGroupsAtSchoolAfterRemoval([]); - it('should pass the schoolId of the room', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + return { secondarySchool, externalUserId, room, group }; + }; - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + it('should pass the schoolId of the room', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(groupService.findGroups).toHaveBeenCalledWith( + expect.objectContaining({ schoolId: secondarySchool.id }) + ); + }); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + it('should remove user from room', async () => { + const { group, externalUserId, room } = setup(); - expect(groupService.findGroups).toHaveBeenCalledWith(expect.objectContaining({ schoolId: secondarySchool.id })); - }); + await service.removeMembersFromRoom(room.id, [externalUserId]); + + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [externalUserId]); + }); - describe('when after removal: user is not in any room of that secondary school', () => { - it('should remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + it('should remove user from secondary school', async () => { + const { secondarySchool, externalUserId, room } = setup(); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + await service.removeMembersFromRoom(room.id, [externalUserId]); - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([]); + expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUserId], secondarySchool.id); + }); + }); + + describe('when removing user from a different school, with further groups on host school', () => { + const setup = () => { + const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + const { roomEditorRole } = setupRoomRoles(); + + const { room, group } = setupGroupAndRoom(secondarySchool.id); + group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + const { group: group2 } = setupGroupAndRoom(secondarySchool.id); + group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + + mockGroupsAtSchoolAfterRemoval([group2]); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + return { externalUser, room }; + }; + + it('should not remove user from secondary school', async () => { + const { externalUser, room } = setup(); + + await service.removeMembersFromRoom(room.id, [externalUser.id as string]); - expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUser.id], secondarySchool.id); + expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + }); }); - }); - describe('when after removal: user is still in a room of that secondary school', () => { - it('should not remove user from secondary school', async () => { - const { secondarySchool, externalUser } = setupUserWithSecondarySchool(); + describe('when removing user from the same school', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { roomEditorRole } = setupRoomRoles(); + const { room, group } = setupGroupAndRoom(user.school.id); + group.addUser({ userId: user.id, roleId: roomEditorRole.id }); - const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR }); + mockGroupsAtSchoolAfterRemoval([group]); - const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id); - group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); - const { group: group2 } = setupGroupAndRoom(secondarySchool.id); - group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id }); + return { user, room, group }; + }; - roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership); - groupService.findById.mockResolvedValue(group); - groupService.removeUsersFromGroup.mockResolvedValue(group); - mockGroupsAtSchoolAfterRemoval([group2]); + it('should remove user from room', async () => { + const { user, group, room } = setup(); - await service.removeMembersFromRoom(room.id, [externalUser.id as string]); + await service.removeMembersFromRoom(room.id, [user.id]); - expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled(); + expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]); + }); + }); + + describe('when removing the owner of the room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const { room, group } = setupGroupAndRoom(user.school.id); + const { roomOwnerRole } = setupRoomRoles(); + + group.addUser({ userId: user.id, roleId: roomOwnerRole.id }); + + return { user, room }; + }; + + it('should throw a badrequest exception', async () => { + const { user, room } = setup(); + + await expect(service.removeMembersFromRoom(room.id, [user.id])).rejects.toThrowError(BadRequestException); + }); }); }); }); diff --git a/apps/server/src/modules/room-membership/service/room-membership.service.ts b/apps/server/src/modules/room-membership/service/room-membership.service.ts index 8baa23dd643..fd978721d04 100644 --- a/apps/server/src/modules/room-membership/service/room-membership.service.ts +++ b/apps/server/src/modules/room-membership/service/room-membership.service.ts @@ -20,11 +20,7 @@ export class RoomMembershipService { private readonly userService: UserService ) {} - private async createNewRoomMembership( - roomId: EntityId, - userId: EntityId, - roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER - ): Promise { + public async createNewRoomMembership(roomId: EntityId, ownerUserId: EntityId): Promise { const room = await this.roomService.getSingleRoom(roomId); const group = await this.groupService.createGroup( @@ -32,7 +28,7 @@ export class RoomMembershipService { GroupTypes.ROOM, room.schoolId ); - await this.groupService.addUsersToGroup(group.id, [{ userId, roleName }]); + await this.groupService.addUsersToGroup(group.id, [{ userId: ownerUserId, roleName: RoleName.ROOMOWNER }]); const roomMembership = new RoomMembership({ id: new ObjectId().toHexString(), @@ -79,16 +75,14 @@ export class RoomMembershipService { public async addMembersToRoom( roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId); if (roomMembership === null) { - const firstUser = userIdsAndRoles.shift(); - if (firstUser === undefined) { - throw new BadRequestException('No user provided'); - } - const newRoomMembership = await this.createNewRoomMembership(roomId, firstUser.userId, firstUser.roleName); - return newRoomMembership.id; + throw new Error('Room membership not found'); } await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles); @@ -106,6 +100,8 @@ export class RoomMembershipService { } const group = await this.groupService.findById(roomMembership.userGroupId); + + await this.ensureOwnerIsNotRemoved(group, userIds); await this.groupService.removeUsersFromGroup(group.id, userIds); await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId); @@ -151,6 +147,17 @@ export class RoomMembershipService { return roomMembershipAuthorizable; } + private async ensureOwnerIsNotRemoved(group: Group, userIds: EntityId[]): Promise { + const role = await this.roleService.findByName(RoleName.ROOMOWNER); + const includedOwner = group.users + .filter((groupUser) => userIds.includes(groupUser.userId)) + .find((groupUser) => groupUser.roleId === role.id); + + if (includedOwner) { + throw new BadRequestException('Cannot remove owner from room'); + } + } + private async handleGuestRoleRemoval(userIds: EntityId[], schoolId: EntityId): Promise { const { data: groups } = await this.groupService.findGroups({ userIds, groupTypes: [GroupTypes.ROOM], schoolId }); diff --git a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts index 93cb5556460..9980d106fb9 100644 --- a/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts +++ b/apps/server/src/modules/room/api/dto/request/add-room-members.body.params.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsMongoId, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { RoomRole, RoomRoleArray } from '@shared/domain/interface'; +import { RoleName, RoomRoleArray } from '@shared/domain/interface'; class UserIdAndRole { @ApiProperty({ @@ -17,7 +17,7 @@ class UserIdAndRole { enum: RoomRoleArray, }) @IsString() - roleName!: RoomRole; + roleName!: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; } export class AddRoomMembersBodyParams { diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index 8910130093e..95cd6f7f6cd 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -117,7 +117,7 @@ describe('RoomUc', () => { authorizationService.checkOneOfPermissions.mockReturnValue(undefined); const room = roomFactory.build(); roomService.createRoom.mockResolvedValue(room); - roomMembershipService.addMembersToRoom.mockRejectedValue(new Error('test')); + roomMembershipService.createNewRoomMembership.mockRejectedValue(new Error('test')); return { user, room }; }; diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index a80e2838c66..1de7fd11b5c 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { IFindOptions, Permission, RoleName, RoomRole } from '@shared/domain/interface'; +import { IFindOptions, Permission, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BoardExternalReferenceType, ColumnBoard, ColumnBoardService } from '@modules/board'; import { Room, RoomService } from '../domain'; @@ -40,14 +40,13 @@ export class RoomUc { this.authorizationService.checkOneOfPermissions(user, [Permission.ROOM_CREATE]); - await this.roomMembershipService - .addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]) - .catch(async (err) => { - await this.roomService.deleteRoom(room); - throw err; - }); - - return room; + try { + await this.roomMembershipService.createNewRoomMembership(room.id, userId); + return room; + } catch (err) { + await this.roomService.deleteRoom(room); + throw err; + } } public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise<{ room: Room; permissions: Permission[] }> { @@ -129,14 +128,17 @@ export class RoomUc { public async addMembersToRoom( currentUserId: EntityId, roomId: EntityId, - userIdsAndRoles: Array<{ userId: EntityId; roleName: RoomRole }> + userIdsAndRoles: Array<{ + userId: EntityId; + roleName: RoleName.ROOMADMIN | RoleName.ROOMEDITOR | RoleName.ROOMVIEWER; + }> ): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]); await this.roomMembershipService.addMembersToRoom(roomId, userIdsAndRoles); } - private mapToMember(member: UserWithRoomRoles, user: UserDO) { + private mapToMember(member: UserWithRoomRoles, user: UserDO): RoomMemberResponse { return new RoomMemberResponse({ userId: member.userId, firstName: user.firstName, @@ -148,7 +150,7 @@ export class RoomUc { public async removeMembersFromRoom(currentUserId: EntityId, roomId: EntityId, userIds: EntityId[]): Promise { this.checkFeatureEnabled(); - await this.checkRoomAuthorization(currentUserId, roomId, Action.write); + await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_REMOVE]); await this.roomMembershipService.removeMembersFromRoom(roomId, userIds); } diff --git a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts index ad8f5e6a3b7..d4d5761ad5f 100644 --- a/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-add-members.api.spec.ts @@ -54,6 +54,15 @@ describe('Room Controller (API)', () => { const teacherGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER }); const studentGuestRole = roleFactory.buildWithId({ name: RoleName.GUESTSTUDENT }); const role = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], }); @@ -77,6 +86,7 @@ describe('Room Controller (API)', () => { teacherUser, teacherGuestRole, studentGuestRole, + roomEditorRole, otherTeacherUser, otherTeacherAccount, userGroupEntity, diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts index eeca260725b..47cecf68d32 100644 --- a/apps/server/src/modules/room/api/test/room-create.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -69,10 +69,20 @@ describe('Room Controller (API)', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const role = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_EDIT, Permission.ROOM_VIEW], + name: RoleName.TEACHER, + permissions: [Permission.ROOM_CREATE, Permission.ROOM_EDIT, Permission.ROOM_VIEW], }); - await em.persistAndFlush([teacherAccount, teacherUser, role]); + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_CREATE, + Permission.ROOM_EDIT, + Permission.ROOM_VIEW, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + await em.persistAndFlush([teacherAccount, teacherUser, role, roomOwnerRole]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts index 4e8be194dfe..a088b76b872 100644 --- a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -96,32 +96,50 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const room = roomEntityFactory.build(); - const role = roleFactory.buildWithId({ + const roomOwnerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [Permission.ROOM_EDIT, Permission.ROOM_DELETE], + }); + const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR, permissions: [Permission.ROOM_EDIT], }); const school = schoolEntityFactory.buildWithId(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherOwnerAccount, teacherUser: teacherOwnerUser } = + UserAndAccountTestFactory.buildTeacher({ school }); + const { teacherAccount: teacherEditorAccount, teacherUser: teacherEditorUser } = + UserAndAccountTestFactory.buildTeacher({ school }); const userGroup = groupEntityFactory.buildWithId({ type: GroupEntityTypes.ROOM, - users: [{ role, user: teacherUser }], + users: [ + { role: roomOwnerRole, user: teacherOwnerUser }, + { role: roomEditorRole, user: teacherEditorUser }, + ], }); const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id, - schoolId: teacherUser.school.id, + schoolId: teacherOwnerUser.school.id, }); - await em.persistAndFlush([room, roomMembership, teacherAccount, teacherUser, userGroup, role]); + await em.persistAndFlush([ + room, + roomMembership, + teacherOwnerAccount, + teacherOwnerUser, + teacherEditorAccount, + teacherEditorUser, + userGroup, + roomOwnerRole, + ]); em.clear(); - const loggedInClient = await testApiClient.login(teacherAccount); - - return { loggedInClient, room }; + return { teacherOwnerAccount, teacherEditorAccount, room }; }; describe('when the room exists', () => { it('should delete the room', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const response = await loggedInClient.delete(room.id); expect(response.status).toBe(HttpStatus.NO_CONTENT); @@ -129,7 +147,8 @@ describe('Room Controller (API)', () => { }); it('should delete the roomMembership', async () => { - const { loggedInClient, room } = await setup(); + const { teacherOwnerAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).resolves.not.toThrow(); @@ -137,11 +156,23 @@ describe('Room Controller (API)', () => { expect(response.status).toBe(HttpStatus.NO_CONTENT); await expect(em.findOneOrFail(RoomMembershipEntity, { roomId: room.id })).rejects.toThrow(NotFoundException); }); + + describe('when user is not the roomowner', () => { + it('should fail', async () => { + const { teacherEditorAccount, room } = await setup(); + const loggedInClient = await testApiClient.login(teacherEditorAccount); + + const response = await loggedInClient.delete(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); }); describe('when the room does not exist', () => { it('should return a 404 error', async () => { - const { loggedInClient } = await setup(); + const { teacherOwnerAccount } = await setup(); + const loggedInClient = await testApiClient.login(teacherOwnerAccount); const someId = new ObjectId().toHexString(); const response = await loggedInClient.delete(someId); diff --git a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts index 3810a9f4f39..f52dfc0bf2d 100644 --- a/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-remove-members.api.spec.ts @@ -46,15 +46,30 @@ describe('Room Controller (API)', () => { describe('PATCH /rooms/:roomId/members/remove', () => { const setupRoomRoles = () => { - const editorRole = roleFactory.buildWithId({ - name: RoleName.ROOMEDITOR, - permissions: [Permission.ROOM_VIEW, Permission.ROOM_EDIT], + const ownerRole = roleFactory.buildWithId({ + name: RoleName.ROOMOWNER, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_DELETE, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], + }); + const adminRole = roleFactory.buildWithId({ + name: RoleName.ROOMADMIN, + permissions: [ + Permission.ROOM_VIEW, + Permission.ROOM_EDIT, + Permission.ROOM_MEMBERS_ADD, + Permission.ROOM_MEMBERS_REMOVE, + ], }); const viewerRole = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER, permissions: [Permission.ROOM_VIEW], }); - return { editorRole, viewerRole }; + return { ownerRole, adminRole, viewerRole }; }; const setupRoomWithMembers = async () => { @@ -62,17 +77,17 @@ describe('Room Controller (API)', () => { const room = roomEntityFactory.buildWithId({ schoolId: school.id }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); - const { teacherUser: inRoomEditor2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const { teacherUser: inRoomEditor3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin2 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); + const { teacherUser: inRoomAdmin3 } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: inRoomViewer } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); const { teacherUser: outTeacher } = UserAndAccountTestFactory.buildTeacher({ school: teacherUser.school }); - const users = { teacherUser, inRoomEditor2, inRoomEditor3, inRoomViewer, outTeacher }; + const users = { teacherUser, inRoomAdmin2, inRoomAdmin3, inRoomViewer, outTeacher }; - const { editorRole, viewerRole } = setupRoomRoles(); + const { ownerRole, adminRole, viewerRole } = setupRoomRoles(); - const roomUsers = [teacherUser, inRoomEditor2, inRoomEditor3].map((user) => { - return { role: editorRole, user }; + const roomUsers = [teacherUser, inRoomAdmin2, inRoomAdmin3].map((user) => { + return { role: adminRole, user }; }); roomUsers.push({ role: viewerRole, user: inRoomViewer }); @@ -89,7 +104,14 @@ describe('Room Controller (API)', () => { schoolId: school.id, }); - await em.persistAndFlush([...Object.values(users), room, roomMemberships, teacherAccount, userGroupEntity]); + await em.persistAndFlush([ + ...Object.values(users), + room, + roomMemberships, + teacherAccount, + userGroupEntity, + ownerRole, + ]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -137,9 +159,9 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { describe('when removing a user from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id]; + const userIds = [inRoomAdmin2.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); @@ -148,9 +170,9 @@ describe('Room Controller (API)', () => { describe('when removing several users from the room', () => { it('should return OK', async () => { - const { loggedInClient, room, inRoomEditor2, inRoomEditor3 } = await setupRoomWithMembers(); + const { loggedInClient, room, inRoomAdmin2, inRoomAdmin3 } = await setupRoomWithMembers(); - const userIds = [inRoomEditor2.id, inRoomEditor3.id]; + const userIds = [inRoomAdmin2.id, inRoomAdmin3.id]; const response = await loggedInClient.patch(`/${room.id}/members/remove`, { userIds }); expect(response.status).toBe(HttpStatus.OK); diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index c5bed37ad11..bd1c2b0d255 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -104,6 +104,9 @@ export enum Permission { ROOM_EDIT = 'ROOM_EDIT', ROOM_VIEW = 'ROOM_VIEW', ROOM_DELETE = 'ROOM_DELETE', + ROOM_MEMBERS_ADD = 'ROOM_MEMBERS_ADD', + ROOM_MEMBERS_REMOVE = 'ROOM_MEMBERS_REMOVE', + ROOM_CHANGE_OWNER = 'ROOM_CHANGE_OWNER', SCHOOL_CHAT_MANAGE = 'SCHOOL_CHAT_MANAGE', SCHOOL_CREATE = 'SCHOOL_CREATE', SCHOOL_EDIT = 'SCHOOL_EDIT', diff --git a/apps/server/src/shared/domain/interface/rolename.enum.ts b/apps/server/src/shared/domain/interface/rolename.enum.ts index e354109efd3..310f80cf84f 100644 --- a/apps/server/src/shared/domain/interface/rolename.enum.ts +++ b/apps/server/src/shared/domain/interface/rolename.enum.ts @@ -13,6 +13,8 @@ export enum RoleName { HELPDESK = 'helpdesk', ROOMVIEWER = 'roomviewer', ROOMEDITOR = 'roomeditor', + ROOMADMIN = 'roomadmin', + ROOMOWNER = 'roomowner', STUDENT = 'student', SUPERHERO = 'superhero', TEACHER = 'teacher', @@ -32,7 +34,12 @@ export type IUserRoleName = | RoleName.DEMOSTUDENT | RoleName.DEMOTEACHER; -export const RoomRoleArray = [RoleName.ROOMEDITOR, RoleName.ROOMVIEWER] as const; +export const RoomRoleArray = [ + RoleName.ROOMOWNER, + RoleName.ROOMADMIN, + RoleName.ROOMEDITOR, + RoleName.ROOMVIEWER, +] as const; export type RoomRole = typeof RoomRoleArray[number]; export const GuestRoleArray = [RoleName.GUESTSTUDENT, RoleName.GUESTTEACHER] as const; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index d99686e576e..9babec5e269 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -278,6 +278,15 @@ "$date": "2024-11-13T10:13:12.411Z" } }, + { + "_id": { + "$oid": "673fca34cc4a3264457c8ad1" + }, + "name": "Migration20241120100616", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } + }, { "_id": { "$oid": "674444262ba8186272dc8abd" @@ -298,11 +307,20 @@ }, { "_id": { - "$oid": "673fca34cc4a3264457c8ad1" + "$oid": "675abdb4e76b1142cd4c89e5" }, - "name": "Migration20241120100616", + "name": "Migration20241209165812", "created_at": { - "$date": "2024-11-20T17:03:31.473Z" + "$date": "2024-12-12T10:40:52.027Z" + } + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e6" + }, + "name": "Migration20241210152600", + "created_at": { + "$date": "2024-12-12T10:40:52.029Z" } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 81c1b5bc4af..0c494cb441f 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -599,8 +599,7 @@ "name": "roomeditor", "permissions": [ "ROOM_VIEW", - "ROOM_EDIT", - "ROOM_DELETE" + "ROOM_EDIT" ] }, { @@ -616,5 +615,31 @@ }, "name": "guestTeacher", "permissions": [] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e3" + }, + "name": "roomowner", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_DELETE", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE", + "ROOM_CHANGE_OWNER" + ] + }, + { + "_id": { + "$oid": "675abdb4e76b1142cd4c89e4" + }, + "name": "roomadmin", + "permissions": [ + "ROOM_VIEW", + "ROOM_EDIT", + "ROOM_MEMBERS_ADD", + "ROOM_MEMBERS_REMOVE" + ] } ] From c8be3a7710719400f2a617aadb57c19d5bb93061 Mon Sep 17 00:00:00 2001 From: Phillip Date: Mon, 16 Dec 2024 07:35:57 +0100 Subject: [PATCH 4/5] BC-8571 adding index to files.securityCheck.requestToken (#5399) --- .../migrations/mikro-orm/Migration20241213145222.ts | 11 +++++++++++ backup/setup/migrations.json | 9 +++++++++ 2 files changed, 20 insertions(+) create mode 100644 apps/server/src/migrations/mikro-orm/Migration20241213145222.ts diff --git a/apps/server/src/migrations/mikro-orm/Migration20241213145222.ts b/apps/server/src/migrations/mikro-orm/Migration20241213145222.ts new file mode 100644 index 00000000000..3624c006225 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20241213145222.ts @@ -0,0 +1,11 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20241213145222 extends Migration { + public async up(): Promise { + await this.getCollection('files').createIndex({ 'securityCheck.requestToken': 1 }); + } + + public async down(): Promise { + // no need + } +} diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 9babec5e269..1ff81558276 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -322,5 +322,14 @@ "created_at": { "$date": "2024-12-12T10:40:52.029Z" } + }, + { + "_id": { + "$oid": "675c3caac52cd071103a87bb" + }, + "name": "Migration20241213145222", + "created_at": { + "$date": "2024-11-20T17:03:31.473Z" + } } ] 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 5/5] 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/",