Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BC-8168 - Implementing video conferences in FE and remaining issues #5420

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { RoomModule } from '../room';
import { BoardContextApiHelperService } from './board-context-api-helper.service';
import { BoardModule } from '../board/board.module';
import { LearnroomModule } from '../learnroom';
import { LegacySchoolModule } from '../legacy-school';

@Module({
imports: [BoardModule, LearnroomModule, RoomModule],
imports: [BoardModule, LearnroomModule, RoomModule, LegacySchoolModule],
providers: [BoardContextApiHelperService],
exports: [BoardContextApiHelperService],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { createMock } from '@golevelup/ts-jest';
import { AnyBoardNode, BoardExternalReferenceType, BoardNodeService } from '@modules/board';
import { CourseService } from '@modules/learnroom';
import { RoomService } from '@modules/room';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { CourseFeatures } from '@shared/domain/entity';
import { courseFactory, schoolEntityFactory, setupEntities } from '@shared/testing';
import { BoardFeature } from '../board/domain';
import { cardFactory, columnBoardFactory, columnFactory } from '../board/testing';
import { LegacySchoolService } from '../legacy-school';
import { roomFactory } from '../room/testing';
import { VideoConferenceConfig } from '../video-conference';
import { BoardContextApiHelperService } from './board-context-api-helper.service';

describe('BoardContextApiHelperService', () => {
Expand All @@ -14,6 +19,8 @@ describe('BoardContextApiHelperService', () => {
let courseService: jest.Mocked<CourseService>;
let roomService: jest.Mocked<RoomService>;
let boardNodeService: jest.Mocked<BoardNodeService>;
let legacySchoolService: jest.Mocked<LegacySchoolService>;
let configService: jest.Mocked<ConfigService<VideoConferenceConfig, true>>;

beforeEach(async () => {
await setupEntities();
Expand All @@ -32,13 +39,23 @@ describe('BoardContextApiHelperService', () => {
provide: BoardNodeService,
useValue: createMock<BoardNodeService>(),
},
{
provide: LegacySchoolService,
useValue: createMock<LegacySchoolService>(),
},
{
provide: ConfigService,
useValue: createMock<ConfigService>(),
},
],
}).compile();

service = module.get<BoardContextApiHelperService>(BoardContextApiHelperService);
courseService = module.get(CourseService);
roomService = module.get(RoomService);
boardNodeService = module.get(BoardNodeService);
legacySchoolService = module.get(LegacySchoolService);
configService = module.get(ConfigService);
});

afterAll(async () => {
Expand All @@ -53,16 +70,14 @@ describe('BoardContextApiHelperService', () => {
it('should return schoolId for course context', async () => {
const school = schoolEntityFactory.build();
const course = courseFactory.build({ school });
const cardNode = cardFactory.build();
const columnNode = columnFactory.build();
columnNode.addChild(cardNode);
const card = cardFactory.build();
const column = columnFactory.build({ children: [card] });
const columnBoard = columnBoardFactory.build({
context: { type: BoardExternalReferenceType.Course, id: 'course.id' },
});
columnBoard.addChild(columnNode);
columnBoard.addChild(column);

boardNodeService.findById.mockResolvedValueOnce(cardNode);
boardNodeService.findRoot.mockResolvedValueOnce(columnBoard);
boardNodeService.findById.mockResolvedValueOnce(card);
boardNodeService.findByClassAndId.mockResolvedValueOnce(columnBoard);
courseService.findById.mockResolvedValueOnce(course);

Expand All @@ -87,4 +102,136 @@ describe('BoardContextApiHelperService', () => {
expect(result).toBe(room.schoolId);
});
});

describe('getFeaturesForBoardNode', () => {
describe('when context is course', () => {
const setup = () => {
const course = courseFactory.build();
const column = columnFactory.build();
const columnBoard = columnBoardFactory.build({
context: { type: BoardExternalReferenceType.Course, id: 'course.id' },
children: [column],
});

courseService.findById.mockResolvedValueOnce(course);
boardNodeService.findById.mockResolvedValueOnce(column);
boardNodeService.findByClassAndId.mockResolvedValueOnce(columnBoard);

return { boardNode: column, course };
};

describe('when video conference is enabled for course', () => {
it('should return video conference feature', async () => {
const { boardNode, course } = setup();

course.features = [CourseFeatures.VIDEOCONFERENCE];
legacySchoolService.hasFeature.mockResolvedValueOnce(false);
configService.get.mockReturnValueOnce(false);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([BoardFeature.VIDEOCONFERENCE]);
});
});

describe('when video conference is enabled for school', () => {
it('should return video conference feature', async () => {
const { boardNode, course } = setup();

course.features = [];
legacySchoolService.hasFeature.mockResolvedValueOnce(true);
configService.get.mockReturnValueOnce(false);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([BoardFeature.VIDEOCONFERENCE]);
});
});

describe('when video conference is enabled for config', () => {
it('should return video conference feature', async () => {
const { boardNode, course } = setup();

course.features = [];
legacySchoolService.hasFeature.mockResolvedValueOnce(false);
configService.get.mockReturnValueOnce(true);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([BoardFeature.VIDEOCONFERENCE]);
});
});

describe('when video conference is disabled entirely', () => {
it('should not return feature', async () => {
const { boardNode } = setup();

const course = courseFactory.build();
courseService.findById.mockResolvedValueOnce(course);
legacySchoolService.hasFeature.mockResolvedValueOnce(false);
configService.get.mockReturnValueOnce(false);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([]);
});
});
});

describe('when context is room', () => {
const setup = () => {
const room = roomFactory.build();
const column = columnFactory.build();
const columnBoard = columnBoardFactory.build({
context: { type: BoardExternalReferenceType.Room, id: 'room.id' },
children: [column],
});

roomService.getSingleRoom.mockResolvedValueOnce(room);
boardNodeService.findById.mockResolvedValueOnce(column);
boardNodeService.findByClassAndId.mockResolvedValueOnce(columnBoard);

return { boardNode: column, room };
};

describe('when video conference is enabled for school', () => {
it('should return video conference feature', async () => {
const { boardNode } = setup();

legacySchoolService.hasFeature.mockResolvedValueOnce(true);
configService.get.mockReturnValueOnce(false);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([BoardFeature.VIDEOCONFERENCE]);
});
});

describe('when video conference is enabled for config', () => {
it('should return video conference feature', async () => {
const { boardNode } = setup();

legacySchoolService.hasFeature.mockResolvedValueOnce(false);
configService.get.mockReturnValueOnce(true);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([BoardFeature.VIDEOCONFERENCE]);
});
});

describe('when video conference is disabled entirely', () => {
it('should not return feature', async () => {
const { boardNode } = setup();

legacySchoolService.hasFeature.mockResolvedValueOnce(false);
configService.get.mockReturnValueOnce(false);

const result = await service.getFeaturesForBoardNode(boardNode.id);

expect(result).toEqual([]);
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@ import { BoardExternalReference, BoardExternalReferenceType, BoardNodeService, C
import { CourseService } from '@modules/learnroom';
import { RoomService } from '@modules/room';
import { Injectable } from '@nestjs/common';
import { EntityId } from '@shared/domain/types';
import { ConfigService } from '@nestjs/config';
import { CourseFeatures } from '@shared/domain/entity';
import { EntityId, SchoolFeature } from '@shared/domain/types';
import { BoardFeature } from '../board/domain';
import { LegacySchoolService } from '../legacy-school';
import { VideoConferenceConfig } from '../video-conference';

@Injectable()
export class BoardContextApiHelperService {
constructor(
private readonly courseService: CourseService,
private readonly roomService: RoomService,
private readonly boardNodeService: BoardNodeService
private readonly boardNodeService: BoardNodeService,
private readonly legacySchoolService: LegacySchoolService,
private readonly configService: ConfigService<VideoConferenceConfig, true>
) {}

public async getSchoolIdForBoardNode(nodeId: EntityId): Promise<EntityId> {
const boardNode = await this.boardNodeService.findById(nodeId);
const board = await this.boardNodeService.findRoot(boardNode);
const columnBoard = await this.boardNodeService.findByClassAndId(ColumnBoard, board.id);
const schoolId = await this.getSchoolIdForBoard(columnBoard.context);
const boardContext = await this.getBoardContext(nodeId);
const schoolId = await this.getSchoolIdForBoardContext(boardContext);
return schoolId;
}

private async getSchoolIdForBoard(context: BoardExternalReference): Promise<EntityId> {
public async getFeaturesForBoardNode(nodeId: EntityId): Promise<BoardFeature[]> {
const boardContext = await this.getBoardContext(nodeId);
const features = await this.getFeaturesForBoardContext(boardContext);
return features;
}

private async getBoardContext(nodeId: EntityId): Promise<BoardExternalReference> {
const boardNode = await this.boardNodeService.findById(nodeId, 0);
const columnBoard = await this.boardNodeService.findByClassAndId(ColumnBoard, boardNode.rootId, 0);
return columnBoard.context;
}

private async getSchoolIdForBoardContext(context: BoardExternalReference): Promise<EntityId> {
if (context.type === BoardExternalReferenceType.Course) {
const course = await this.courseService.findById(context.id);

Expand All @@ -35,4 +52,47 @@ export class BoardContextApiHelperService {
/* istanbul ignore next */
throw new Error(`Unsupported board reference type ${context.type as string}`);
}

private async getFeaturesForBoardContext(context: BoardExternalReference): Promise<BoardFeature[]> {
const features: BoardFeature[] = [];

if (context.type === BoardExternalReferenceType.Course) {
const course = await this.courseService.findById(context.id);

if (
this.isVideoConferenceEnabledForCourse(course.features) ||
(await this.isVideoConferenceEnabledForSchool(course.school.id)) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you will get the feature pushed into the array, even if only the school has VC activated, but the course has not, did I see that right? That would not be correct, as we could have the case, that VC are activated for the whole school, but not for the specific course. Could you validate my thought?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. See also test.

this.isVideoConferenceEnabledForConfig()
) {
features.push(BoardFeature.VIDEOCONFERENCE);
}

return features;
}

if (context.type === BoardExternalReferenceType.Room) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering what BoardExternalReferenceType.User is.

Will that case ever happen?

Copy link
Contributor Author

@uidp uidp Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BoardExternalReferenceType.User is for the MediaBoard which exists in the context of a user. So that is theoretically possible i think.

const room = await this.roomService.getSingleRoom(context.id);

if ((await this.isVideoConferenceEnabledForSchool(room.schoolId)) || this.isVideoConferenceEnabledForConfig()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also here we get the feature in both cases, even if its only activated for the instance, but not for the school, that does not sound right

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, we should be able to deactivate the feature for the school even if it's enabled on the instance right?

Copy link
Contributor

@MartinSchuhmacher MartinSchuhmacher Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, and it should (for the example above) be able to be deactivated on course level, even if its activated on a school (and therefore on the instance)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

features.push(BoardFeature.VIDEOCONFERENCE);
}

return features;
}

/* istanbul ignore next */
throw new Error(`Unsupported board reference type ${context.type as string}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe throwing a BadRequestException?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

}

private isVideoConferenceEnabledForCourse(courseFeatures?: CourseFeatures[]): boolean {
return (courseFeatures ?? []).includes(CourseFeatures.VIDEOCONFERENCE);
}

private isVideoConferenceEnabledForSchool(schoolId: EntityId): Promise<boolean> {
return this.legacySchoolService.hasFeature(schoolId, SchoolFeature.VIDEOCONFERENCE);
}

private isVideoConferenceEnabledForConfig(): boolean {
return this.configService.get('FEATURE_VIDEOCONFERENCE_ENABLED');
}
}
10 changes: 9 additions & 1 deletion apps/server/src/modules/board/board-api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ import {
import { BoardNodePermissionService } from './service';
import { BoardUc, CardUc, ColumnUc, ElementUc, SubmissionItemUc } from './uc';
import { RoomModule } from '../room';
import { BoardContextApiHelperModule } from '../board-context';

@Module({
imports: [BoardModule, LoggerModule, RoomMembershipModule, RoomModule, forwardRef(() => AuthorizationModule)],
imports: [
BoardModule,
LoggerModule,
RoomMembershipModule,
RoomModule,
forwardRef(() => AuthorizationModule),
BoardContextApiHelperModule,
],
controllers: [BoardController, ColumnController, CardController, ElementController, BoardSubmissionController],
providers: [BoardUc, BoardNodePermissionService, ColumnUc, CardUc, ElementUc, SubmissionItemUc, CourseRepo],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb';
import { ServerTestModule } from '@modules/server/server.module';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { cleanupCollections, groupEntityFactory, roleFactory, TestApiClient, userFactory } from '@shared/testing';
import {
cleanupCollections,
groupEntityFactory,
roleFactory,
schoolEntityFactory,
TestApiClient,
userFactory,
} from '@shared/testing';

import { Permission, RoleName } from '@shared/domain/interface';
import { accountFactory } from '@src/modules/account/testing';
Expand Down Expand Up @@ -60,7 +67,8 @@ describe(`board lookup in room (api)`, () => {
],
});

const room = roomEntityFactory.buildWithId();
const school = schoolEntityFactory.buildWithId();
const room = roomEntityFactory.buildWithId({ schoolId: school.id });

const roomMembership = roomMembershipEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id });

Expand All @@ -76,6 +84,7 @@ describe(`board lookup in room (api)`, () => {
userGroup,
room,
roomMembership,
school,
]);

const columnBoardNode = columnBoardEntityFactory.build({
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/modules/board/controller/board.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ export class BoardController {
@Param() urlParams: BoardUrlParams,
@CurrentUser() currentUser: ICurrentUser
): Promise<BoardResponse> {
const board = await this.boardUc.findBoard(currentUser.userId, urlParams.boardId);
const { board, features } = await this.boardUc.findBoard(currentUser.userId, urlParams.boardId);

const response = BoardResponseMapper.mapToResponse(board);
const response = BoardResponseMapper.mapToResponse(board, features);

return response;
}
Expand Down
Loading
Loading