diff --git a/apps/server/src/migrations/mikro-orm/Migration20240823151836.ts b/apps/server/src/migrations/mikro-orm/Migration20240823151836.ts new file mode 100644 index 00000000000..9a692086200 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240823151836.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240823151836 extends Migration { + async up(): Promise { + const adminRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'administrator' }, + { + $addToSet: { + permissions: { + $each: ['COURSE_ADMINISTRATION'], + }, + }, + } + ); + + if (adminRoleUpdate.modifiedCount > 0) { + console.info('Permission COURSE_ADMINISTRATION added to role administrator.'); + } + } + + async down(): Promise { + const adminRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'administrator' }, + { + $pull: { + permissions: { + $in: ['COURSE_ADMINISTRATION'], + }, + }, + } + ); + + if (adminRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Permission COURSE_ADMINISTRATION added to role administrator.'); + } + } +} diff --git a/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts index d66b7856ca9..46d616cf45b 100644 --- a/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts @@ -8,6 +8,7 @@ import { Action } from '../type'; import { CourseRule } from './course.rule'; describe('CourseRule', () => { + let module: TestingModule; let service: CourseRule; let authorizationHelper: AuthorizationHelper; let user: User; @@ -19,7 +20,7 @@ describe('CourseRule', () => { beforeAll(async () => { await setupEntities(); - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [AuthorizationHelper, CourseRule], }).compile(); @@ -32,12 +33,20 @@ describe('CourseRule', () => { user = userFactory.build({ roles: [role] }); }); + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + describe('when validating an entity', () => { it('should call hasAllPermissions on AuthorizationHelper', () => { entity = courseEntityFactory.build({ teachers: [user] }); const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, []); + expect(spy).toHaveBeenCalledWith(user, []); }); it('should call hasAccessToEntity on AuthorizationHelper if action = "read"', () => { @@ -73,6 +82,35 @@ describe('CourseRule', () => { }); }); + describe('when validating an entity and the user has COURSE_ADMINISTRATION permission', () => { + const setup = () => { + const permissionD = Permission.COURSE_ADMINISTRATION; + const adminRole = roleFactory.build({ permissions: [permissionD] }); + const adminUser = userFactory.build({ roles: [adminRole] }); + + return { + adminUser, + permissionD, + }; + }; + + it('should call hasAllPermissions with admin permissions on AuthorizationHelper', () => { + const { permissionD, adminUser } = setup(); + entity = courseEntityFactory.build(); + const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); + service.hasPermission(adminUser, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toHaveBeenNthCalledWith(2, adminUser, [permissionD]); + }); + + it('should not call hasAccessToEntity on AuthorizationHelper', () => { + const { adminUser } = setup(); + entity = courseEntityFactory.build(); + const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); + service.hasPermission(adminUser, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toHaveBeenCalledTimes(0); + }); + }); + describe('when validating a domain object', () => { describe('when the user is authorized', () => { const setup = () => { diff --git a/apps/server/src/modules/authorization/domain/rules/course.rule.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.ts index f4c3b51f84a..c97afb098fa 100644 --- a/apps/server/src/modules/authorization/domain/rules/course.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.ts @@ -1,6 +1,7 @@ import { Course } from '@modules/learnroom/domain'; import { Injectable } from '@nestjs/common'; import { Course as CourseEntity, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; import { AuthorizationHelper } from '../service/authorization.helper'; import { Action, AuthorizationContext, Rule } from '../type'; @@ -16,14 +17,27 @@ export class CourseRule implements Rule { public hasPermission(user: User, object: CourseEntity | Course, context: AuthorizationContext): boolean { const { action, requiredPermissions } = context; - const hasPermission = - this.authorizationHelper.hasAllPermissions(user, requiredPermissions) && - this.authorizationHelper.hasAccessToEntity( - user, - object, - action === Action.read ? ['teachers', 'substitutionTeachers', 'students'] : ['teachers', 'substitutionTeachers'] - ); - - return hasPermission; + + const hasRequiredPermission = this.authorizationHelper.hasAllPermissions(user, requiredPermissions); + const hasAdminPermission = this.authorizationHelper.hasAllPermissions(user, [Permission.COURSE_ADMINISTRATION]); + + const hasAccessToEntity = hasAdminPermission + ? true + : this.authorizationHelper.hasAccessToEntity( + user, + object, + this.isReadAction(action) + ? ['teachers', 'substitutionTeachers', 'students'] + : ['teachers', 'substitutionTeachers'] + ); + + return hasAccessToEntity && hasRequiredPermission; + } + + isReadAction(action: Action) { + if (action === Action.read) { + return true; + } + return false; } } diff --git a/apps/server/src/modules/class/domain/class.do.ts b/apps/server/src/modules/class/domain/class.do.ts index fd6449a9d46..68a7e621947 100644 --- a/apps/server/src/modules/class/domain/class.do.ts +++ b/apps/server/src/modules/class/domain/class.do.ts @@ -74,4 +74,12 @@ export class Class extends DomainObject { public removeUser(userId: string) { this.props.userIds = this.props.userIds?.filter((userId1) => userId1 !== userId); } + + public getClassFullName(): string { + const classFullName = this.props.gradeLevel + ? this.props.gradeLevel.toString().concat(this.props.name) + : this.props.name; + + return classFullName; + } } diff --git a/apps/server/src/modules/class/domain/testing/class.do.spec.ts b/apps/server/src/modules/class/domain/testing/class.do.spec.ts index 510af786665..7ff616ab4ba 100644 --- a/apps/server/src/modules/class/domain/testing/class.do.spec.ts +++ b/apps/server/src/modules/class/domain/testing/class.do.spec.ts @@ -86,4 +86,28 @@ describe(Class.name, () => { }); }); }); + + describe('getClassFullName', () => { + describe('When function is called', () => { + it('should return full class name consisting of grade level and class name', () => { + const gradeLevel = 1; + const name = 'A'; + const domainObject = classFactory.build({ name, gradeLevel }); + + const result = domainObject.getClassFullName(); + + expect(result).toEqual('1A'); + }); + + it('should return full class name consisting of class name only', () => { + const gradeLevel = undefined; + const name = 'A'; + const domainObject = classFactory.build({ name, gradeLevel }); + + const result = domainObject.getClassFullName(); + + expect(result).toEqual('A'); + }); + }); + }); }); diff --git a/apps/server/src/modules/class/repo/classes.repo.spec.ts b/apps/server/src/modules/class/repo/classes.repo.spec.ts index 9904a627e6c..dbb58fb5b44 100644 --- a/apps/server/src/modules/class/repo/classes.repo.spec.ts +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -177,4 +177,34 @@ describe(ClassesRepo.name, () => { }); }); }); + + describe('findClassById', () => { + describe('when class is not found in classes', () => { + it('should return null', async () => { + const result = await repo.findClassById(new ObjectId().toHexString()); + + expect(result).toEqual(null); + }); + }); + + describe('when class is in classes', () => { + const setup = async () => { + const class1: ClassEntity = classEntityFactory.buildWithId(); + await em.persistAndFlush([class1]); + em.clear(); + + return { + class1, + }; + }; + + it('should find class with particular classId', async () => { + const { class1 } = await setup(); + + const result = await repo.findClassById(class1.id); + + expect(result?.id).toEqual(class1.id); + }); + }); + }); }); diff --git a/apps/server/src/modules/class/repo/classes.repo.ts b/apps/server/src/modules/class/repo/classes.repo.ts index 7b3c4784d16..5f744cd1793 100644 --- a/apps/server/src/modules/class/repo/classes.repo.ts +++ b/apps/server/src/modules/class/repo/classes.repo.ts @@ -55,4 +55,16 @@ export class ClassesRepo { await this.em.persistAndFlush(existingEntities); } + + public async findClassById(id: EntityId): Promise { + const clazz = await this.em.findOne(ClassEntity, { id }); + + if (!clazz) { + return null; + } + + const domainObject: Class = ClassMapper.mapToDO(clazz); + + return domainObject; + } } diff --git a/apps/server/src/modules/class/repo/mapper/class.mapper.ts b/apps/server/src/modules/class/repo/mapper/class.mapper.ts index 8ae5e3b79b9..bd904a39bd6 100644 --- a/apps/server/src/modules/class/repo/mapper/class.mapper.ts +++ b/apps/server/src/modules/class/repo/mapper/class.mapper.ts @@ -4,7 +4,7 @@ import { ClassSourceOptions } from '../../domain/class-source-options.do'; import { ClassEntity } from '../../entity'; export class ClassMapper { - private static mapToDO(entity: ClassEntity): Class { + static mapToDO(entity: ClassEntity): Class { return new Class({ id: entity.id, name: entity.name, diff --git a/apps/server/src/modules/class/service/class.service.spec.ts b/apps/server/src/modules/class/service/class.service.spec.ts index c7275661cca..7e483ae6544 100644 --- a/apps/server/src/modules/class/service/class.service.spec.ts +++ b/apps/server/src/modules/class/service/class.service.spec.ts @@ -12,6 +12,7 @@ import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { InternalServerErrorException } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { EntityId } from '@shared/domain/types'; import { setupEntities } from '@shared/testing'; import { Logger } from '@src/core/logger'; @@ -126,6 +127,34 @@ describe(ClassService.name, () => { }); }); + describe('findById', () => { + describe('when the user has classes', () => { + const setup = () => { + const clazz: Class = classFactory.build(); + + classesRepo.findClassById.mockResolvedValueOnce(clazz); + + return { + clazz, + }; + }; + + it('should return the class', async () => { + const { clazz } = setup(); + + const result: Class = await service.findById(clazz.id); + + expect(result).toEqual(clazz); + }); + + it('should throw error', async () => { + classesRepo.findClassById.mockResolvedValueOnce(null); + + await expect(service.findById('someId')).rejects.toThrowError(NotFoundLoggableException); + }); + }); + }); + describe('deleteUserDataFromClasses', () => { describe('when user is missing', () => { const setup = () => { diff --git a/apps/server/src/modules/class/service/class.service.ts b/apps/server/src/modules/class/service/class.service.ts index e650e4bee38..c4b19d1c0ab 100644 --- a/apps/server/src/modules/class/service/class.service.ts +++ b/apps/server/src/modules/class/service/class.service.ts @@ -13,6 +13,7 @@ import { } from '@modules/deletion'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { Class } from '../domain'; @@ -100,4 +101,12 @@ export class ClassService implements DeletionService, IEventHandler item.id); } + + public async findById(id: EntityId): Promise { + const clazz: Class | null = await this.classesRepo.findClassById(id); + if (!clazz) { + throw new NotFoundLoggableException(Class.name, { id }); + } + return clazz; + } } diff --git a/apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts new file mode 100644 index 00000000000..602dd6c8461 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/api-test/course-info.api.spec.ts @@ -0,0 +1,151 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course as CourseEntity } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { + cleanupCollections, + courseFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { CourseSortProps, CourseStatus } from '../../domain'; +import { CourseInfoListResponse } from '../dto/response'; + +const createStudent = () => { + const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]); + return { account: studentAccount, user: studentUser }; +}; +const createTeacher = () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [ + Permission.COURSE_VIEW, + Permission.COURSE_EDIT, + ]); + return { account: teacherAccount, user: teacherUser }; +}; + +const createAdmin = () => { + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]); + return { account: adminAccount, user: adminUser }; +}; + +describe('Course Info Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, 'course-info'); + }); + + afterAll(async () => { + await cleanupCollections(em); + await app.close(); + }); + + describe('[GET] /course-info', () => { + describe('when logged in as admin', () => { + const setup = async () => { + const student = createStudent(); + const teacher = createTeacher(); + const admin = createAdmin(); + const school = schoolEntityFactory.buildWithId({}); + + const currentCourses: CourseEntity[] = courseFactory.buildList(5, { + school, + untilDate: new Date('2045-07-31T23:59:59'), + }); + const archivedCourses: CourseEntity[] = courseFactory.buildList(10, { + school, + untilDate: new Date('2024-07-31T23:59:59'), + }); + + admin.user.school = school; + await em.persistAndFlush(school); + await em.persistAndFlush(currentCourses); + await em.persistAndFlush(archivedCourses); + await em.persistAndFlush([admin.account, admin.user]); + em.clear(); + + return { + student, + currentCourses, + archivedCourses, + teacher, + admin, + school, + }; + }; + + it('should return the correct response structure', async () => { + const { admin } = await setup(); + const query = {}; + + const loggedInClient = await testApiClient.login(admin.account); + const response = await loggedInClient.get().query(query); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('skip'); + expect(response.body).toHaveProperty('limit'); + expect(response.body).toHaveProperty('total'); + }); + + it('should return archived courses in pages', async () => { + const { admin } = await setup(); + const query = { skip: 0, limit: 10, sortBy: CourseSortProps.NAME, status: CourseStatus.ARCHIVE }; + + const loggedInClient = await testApiClient.login(admin.account); + const response = await loggedInClient.get().query(query); + + const { total, skip, limit, data } = response.body as CourseInfoListResponse; + expect(response.statusCode).toBe(200); + expect(skip).toBe(0); + expect(limit).toBe(10); + expect(total).toBe(10); + expect(data.length).toBe(10); + }); + + it('should return current courses in pages', async () => { + const { admin, currentCourses } = await setup(); + const query = { skip: 4, limit: 2, sortBy: CourseSortProps.NAME, status: CourseStatus.CURRENT }; + + const loggedInClient = await testApiClient.login(admin.account); + const response = await loggedInClient.get().query(query); + + const { total, skip, limit, data } = response.body as CourseInfoListResponse; + expect(response.statusCode).toBe(200); + expect(skip).toBe(4); + expect(limit).toBe(2); + expect(total).toBe(5); + expect(data.length).toBe(1); + expect(data[0].id).toBe(currentCourses[4].id); + }); + }); + + describe('when not authorized', () => { + it('should return unauthorized', async () => { + const query = {}; + + const response = await testApiClient.get().query(query); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts index b191b26a0e8..c9991fd5ebc 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts @@ -245,6 +245,114 @@ describe('Course Controller (API)', () => { }); }); + describe('[POST] /courses/:courseId/start-sync', () => { + describe('when a course is not synchronized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { + loggedInClient, + course, + group, + }; + }; + + it('should start the synchronization', async () => { + const { loggedInClient, course, group } = await setup(); + const params = { groupId: group.id }; + + const response = await loggedInClient.post(`${course.id}/start-sync`).send(params); + + const result: CourseEntity = await em.findOneOrFail(CourseEntity, course.id); + expect(response.statusCode).toEqual(HttpStatus.NO_CONTENT); + expect(result.syncedWithGroup?.id).toBe(group.id); + }); + }); + + describe('when a course is already synchronized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const otherGroup = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + syncedWithGroup: otherGroup, + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { + loggedInClient, + course, + group, + otherGroup, + }; + }; + + it('should not start the synchronization', async () => { + const { loggedInClient, course, group, otherGroup } = await setup(); + const params = { groupId: group.id }; + + const response = await loggedInClient.post(`${course.id}/start-sync`).send(params); + + const result: CourseEntity = await em.findOneOrFail(CourseEntity, course.id); + expect(response.statusCode).toEqual(HttpStatus.UNPROCESSABLE_ENTITY); + expect(response.body).toEqual({ + code: HttpStatus.UNPROCESSABLE_ENTITY, + message: 'Unprocessable Entity', + title: 'Course Already Synchronized', + type: 'COURSE_ALREADY_SYNCHRONIZED', + }); + expect(result.syncedWithGroup?.id).toBe(otherGroup.id); + }); + }); + + describe('when the user is unauthorized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + return { + course, + group, + }; + }; + + it('should return unauthorized', async () => { + const { course, group } = await setup(); + const params = { groupId: group.id }; + + const response = await testApiClient.post(`${course.id}/start-sync`).send(params); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + describe('[GET] /courses/:courseId/cc-metadata', () => { const setup = async () => { const teacher = createTeacher(); diff --git a/apps/server/src/modules/learnroom/controller/course-info.controller.ts b/apps/server/src/modules/learnroom/controller/course-info.controller.ts new file mode 100644 index 00000000000..38a4355f7d8 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/course-info.controller.ts @@ -0,0 +1,48 @@ +import { CurrentUser, ICurrentUser, JwtAuthentication } from '@infra/auth-guard'; +import { Controller, Get, HttpStatus, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PaginationParams } from '@shared/controller/'; +import { Page } from '@shared/domain/domainobject'; +import { ErrorResponse } from '@src/core/error/dto'; +import { CourseInfoResponseMapper } from '../mapper/course-info-response.mapper'; +import { CourseInfoDto } from '../uc/dto'; +import { CourseFilterParams } from './dto/request/course-filter-params'; +import { CourseSortParams } from './dto/request/course-sort-params'; +import { CourseInfoListResponse } from './dto/response'; +import { CourseInfoUc } from '../uc/course-info.uc'; + +@ApiTags('Course Info') +@JwtAuthentication() +@Controller('course-info') +export class CourseInfoController { + constructor(private readonly courseInfoUc: CourseInfoUc) {} + + @Get() + @ApiOperation({ summary: 'Get course information.' }) + @ApiResponse({ status: HttpStatus.OK, type: CourseInfoListResponse }) + @ApiResponse({ status: '4XX', type: ErrorResponse }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + async getCourseInfo( + @CurrentUser() currentUser: ICurrentUser, + @Query() pagination: PaginationParams, + @Query() sortingQuery: CourseSortParams, + @Query() filterParams: CourseFilterParams + ): Promise { + const courses: Page = await this.courseInfoUc.getCourseInfo( + currentUser.userId, + currentUser.schoolId, + sortingQuery.sortBy, + filterParams.status, + pagination, + sortingQuery.sortOrder + ); + + const response: CourseInfoListResponse = CourseInfoResponseMapper.mapToCourseInfoListResponse( + courses, + pagination.skip, + pagination.limit + ); + + return response; + } +} diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 3d0d8314651..7d20c2f5292 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -30,8 +30,14 @@ import { Response } from 'express'; import { CourseMapper } from '../mapper/course.mapper'; import { CourseExportUc, CourseImportUc, CourseSyncUc, CourseUc } from '../uc'; import { CommonCartridgeFileValidatorPipe } from '../utils'; -import { CourseImportBodyParams, CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; -import { CourseExportBodyParams } from './dto/course-export.body.params'; +import { + CourseExportBodyParams, + CourseImportBodyParams, + CourseMetadataListResponse, + CourseQueryParams, + CourseSyncBodyParams, + CourseUrlParams, +} from './dto'; import { CourseCommonCartridgeMetadataResponse } from './dto/course-cc-metadata.response'; @ApiTags('Courses') @@ -111,6 +117,19 @@ export class CourseController { await this.courseSyncUc.stopSynchronization(currentUser.userId, params.courseId); } + @Post(':courseId/start-sync/') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Start the synchronization of a course with a group.' }) + @ApiNoContentResponse({ description: 'The course was successfully synchronized to a group.' }) + @ApiUnprocessableEntityResponse({ description: 'The course is already synchronized with a group.' }) + public async startSynchronization( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: CourseUrlParams, + @Body() bodyParams: CourseSyncBodyParams + ): Promise { + await this.courseSyncUc.startSynchronization(currentUser.userId, params.courseId, bodyParams.groupId); + } + @Get(':courseId/user-permissions') @ApiOperation({ summary: 'Get permissions for a user in a course.' }) @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) diff --git a/apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts new file mode 100644 index 00000000000..63fdffa5bf3 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/course-sync.body.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class CourseSyncBodyParams { + @IsMongoId() + @ApiProperty({ + description: 'The id of the group', + required: true, + nullable: false, + }) + groupId!: string; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/index.ts b/apps/server/src/modules/learnroom/controller/dto/index.ts index c459afc49d1..756a0f26429 100644 --- a/apps/server/src/modules/learnroom/controller/dto/index.ts +++ b/apps/server/src/modules/learnroom/controller/dto/index.ts @@ -12,3 +12,5 @@ export * from './patch-visibility.params'; export * from './course-room-element.url.params'; export * from './course-room.url.params'; export * from './single-column-board'; +export * from './course-sync.body.params'; +export * from './course-export.body.params'; diff --git a/apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts b/apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts new file mode 100644 index 00000000000..d1e8ede12cf --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/request/course-filter-params.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { CourseStatus } from '../../../domain'; + +export class CourseFilterParams { + @IsOptional() + @IsEnum(CourseStatus) + @ApiPropertyOptional({ enum: CourseStatus, enumName: 'CourseStatus' }) + status?: CourseStatus; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts b/apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts new file mode 100644 index 00000000000..eef900bb569 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/request/course-sort-params.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SortingParams } from '@shared/controller'; +import { IsEnum, IsOptional } from 'class-validator'; +import { CourseSortProps } from '../../../domain/interface/course-sort-props.enum'; + +export class CourseSortParams extends SortingParams { + @IsOptional() + @IsEnum(CourseSortProps) + @ApiPropertyOptional({ enum: CourseSortProps, enumName: 'CourseSortProps' }) + sortBy?: CourseSortProps; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts b/apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts new file mode 100644 index 00000000000..789c50e60d1 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/response/course-info-data-response.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; + +export class CourseInfoDataResponse { + @ApiProperty() + id: EntityId; + + @ApiProperty() + name: string; + + @ApiProperty({ type: [String] }) + teacherNames: string[]; + + @ApiProperty({ type: [String] }) + classNames: string[]; + + @ApiPropertyOptional() + syncedGroup?: string; + + constructor(props: CourseInfoDataResponse) { + this.id = props.id; + this.name = props.name; + this.classNames = props.classNames; + this.teacherNames = props.teacherNames; + this.syncedGroup = props.syncedGroup; + } +} diff --git a/apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts b/apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts new file mode 100644 index 00000000000..d988fe45dbc --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/response/course-info-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { CourseInfoDataResponse } from './course-info-data-response'; + +export class CourseInfoListResponse extends PaginationResponse { + constructor(data: CourseInfoDataResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [CourseInfoDataResponse] }) + data: CourseInfoDataResponse[]; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/response/index.ts b/apps/server/src/modules/learnroom/controller/dto/response/index.ts new file mode 100644 index 00000000000..dd0dbbc3a7f --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/response/index.ts @@ -0,0 +1,2 @@ +export { CourseInfoListResponse } from './course-info-list.response'; +export { CourseInfoDataResponse } from './course-info-data-response'; diff --git a/apps/server/src/modules/learnroom/domain/do/course.ts b/apps/server/src/modules/learnroom/domain/do/course.ts index 067c5aa899f..a6e8eb84bce 100644 --- a/apps/server/src/modules/learnroom/domain/do/course.ts +++ b/apps/server/src/modules/learnroom/domain/do/course.ts @@ -65,6 +65,14 @@ export class Course extends DomainObject { return this.props.substitutionTeacherIds; } + get classes(): EntityId[] { + return this.props.classIds; + } + + get groups(): EntityId[] { + return this.props.groupIds; + } + set startDate(value: Date | undefined) { this.props.startDate = value; } diff --git a/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts new file mode 100644 index 00000000000..15c37f4ce4a --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { courseFactory } from '../../testing'; +import { CourseAlreadySynchronizedLoggableException } from './course-already-synchronized.loggable-exception'; + +describe(CourseAlreadySynchronizedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const course = courseFactory.build(); + + const exception = new CourseAlreadySynchronizedLoggableException(course.id); + + return { + exception, + course, + }; + }; + + it('should log the correct message', () => { + const { exception, course } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'COURSE_ALREADY_SYNCHRONIZED', + stack: expect.any(String), + data: { + courseId: course.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts new file mode 100644 index 00000000000..cd747ad5008 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/course-already-synchronized.loggable-exception.ts @@ -0,0 +1,21 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class CourseAlreadySynchronizedLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly courseId: EntityId) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'COURSE_ALREADY_SYNCHRONIZED', + stack: this.stack, + data: { + courseId: this.courseId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/learnroom/domain/error/index.ts b/apps/server/src/modules/learnroom/domain/error/index.ts index 0f64cd09bd0..e6c6d6cc70a 100644 --- a/apps/server/src/modules/learnroom/domain/error/index.ts +++ b/apps/server/src/modules/learnroom/domain/error/index.ts @@ -1 +1,2 @@ export { CourseNotSynchronizedLoggableException } from './course-not-synchronized.loggable-exception'; +export { CourseAlreadySynchronizedLoggableException } from './course-already-synchronized.loggable-exception'; diff --git a/apps/server/src/modules/learnroom/domain/index.ts b/apps/server/src/modules/learnroom/domain/index.ts index 7ac280479fe..e38999d9811 100644 --- a/apps/server/src/modules/learnroom/domain/index.ts +++ b/apps/server/src/modules/learnroom/domain/index.ts @@ -1,4 +1,4 @@ export { Course, CourseProps } from './do'; -export { CourseRepo, COURSE_REPO } from './interface'; -export { CourseNotSynchronizedLoggableException } from './error'; +export { CourseAlreadySynchronizedLoggableException, CourseNotSynchronizedLoggableException } from './error'; +export { COURSE_REPO, CourseFilter, CourseRepo, CourseSortProps, CourseStatus } from './interface'; export { CourseSynchronizationStoppedLoggable } from './loggable'; diff --git a/apps/server/src/modules/learnroom/domain/interface/course-filter.ts b/apps/server/src/modules/learnroom/domain/interface/course-filter.ts new file mode 100644 index 00000000000..6a1f93ac5dc --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course-filter.ts @@ -0,0 +1,7 @@ +import { EntityId } from '@shared/domain/types'; +import { CourseStatus } from './course-status.enum'; + +export interface CourseFilter { + schoolId?: EntityId; + status?: CourseStatus; +} diff --git a/apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts b/apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts new file mode 100644 index 00000000000..c2eb5eb5323 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course-sort-props.enum.ts @@ -0,0 +1,3 @@ +export enum CourseSortProps { + NAME = 'name', +} diff --git a/apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts b/apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts new file mode 100644 index 00000000000..2866db339dc --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course-status.enum.ts @@ -0,0 +1,4 @@ +export enum CourseStatus { + ARCHIVE = 'archive', + CURRENT = 'current', +} diff --git a/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts b/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts index 2e40c8bc2a2..e1c63920c92 100644 --- a/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts +++ b/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts @@ -1,12 +1,17 @@ import type { Group } from '@modules/group'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDomainObjectRepoInterface } from '@shared/repo/base-domain-object.repo.interface'; import { Course } from '../do'; +import { CourseFilter } from './course-filter'; export interface CourseRepo extends BaseDomainObjectRepoInterface { findCourseById(id: EntityId): Promise; findBySyncedGroup(group: Group): Promise; + + getCourseInfo(filter: CourseFilter, options?: IFindOptions): Promise>; } export const COURSE_REPO = Symbol('COURSE_REPO'); diff --git a/apps/server/src/modules/learnroom/domain/interface/index.ts b/apps/server/src/modules/learnroom/domain/interface/index.ts index 6c0fd29b1f0..a890705533d 100644 --- a/apps/server/src/modules/learnroom/domain/interface/index.ts +++ b/apps/server/src/modules/learnroom/domain/interface/index.ts @@ -1 +1,4 @@ -export { CourseRepo, COURSE_REPO } from './course.repo.interface'; +export { CourseFilter } from './course-filter'; +export { CourseSortProps } from './course-sort-props.enum'; +export { CourseStatus } from './course-status.enum'; +export { COURSE_REPO, CourseRepo } from './course.repo.interface'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index e451643a3b6..3c565127dab 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -1,26 +1,34 @@ import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { ClassModule } from '@modules/class'; import { CopyHelperModule } from '@modules/copy-helper'; +import { GroupModule } from '@modules/group'; + import { LessonModule } from '@modules/lesson'; import { RoleModule } from '@modules/role'; +import { SchoolModule } from '@modules/school'; +import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { CourseRepo, DashboardModelMapper, DashboardRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; +import { CourseRoomsController } from './controller/course-rooms.controller'; import { CourseController } from './controller/course.controller'; import { DashboardController } from './controller/dashboard.controller'; -import { CourseRoomsController } from './controller/course-rooms.controller'; import { LearnroomModule } from './learnroom.module'; import { RoomBoardResponseMapper } from './mapper/room-board-response.mapper'; + +import { CourseInfoController } from './controller/course-info.controller'; import { CourseCopyUC, CourseExportUc, CourseImportUc, + CourseInfoUc, + CourseRoomsAuthorisationService, + CourseRoomsUc, CourseSyncUc, CourseUc, DashboardUc, LessonCopyUC, RoomBoardDTOFactory, - CourseRoomsAuthorisationService, - CourseRoomsUc, } from './uc'; /** @@ -35,11 +43,16 @@ import { LearnroomModule, AuthorizationReferenceModule, RoleModule, + SchoolModule, + GroupModule, + UserModule, + ClassModule, ], - controllers: [DashboardController, CourseController, CourseRoomsController], + controllers: [DashboardController, CourseController, CourseInfoController, CourseRoomsController], providers: [ DashboardUc, CourseUc, + CourseInfoUc, CourseRoomsUc, RoomBoardResponseMapper, RoomBoardDTOFactory, diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index f405aa4d51d..0adf76cacd0 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -1,8 +1,12 @@ import { BoardModule } from '@modules/board'; +import { ClassModule } from '@modules/class'; import { CopyHelperModule } from '@modules/copy-helper'; +import { GroupModule } from '@modules/group'; import { LessonModule } from '@modules/lesson'; +import { SchoolModule } from '@modules/school'; import { TaskModule } from '@modules/task'; import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; +import { UserModule } from '@modules/user'; import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { @@ -28,10 +32,10 @@ import { CourseCopyService, CourseDoService, CourseGroupService, + CourseRoomsService, CourseService, DashboardService, GroupDeletedHandlerService, - CourseRoomsService, } from './service'; import { CommonCartridgeFileValidatorPipe } from './utils'; @@ -48,6 +52,10 @@ import { CommonCartridgeFileValidatorPipe } from './utils'; LoggerModule, TaskModule, CqrsModule, + UserModule, + ClassModule, + SchoolModule, + GroupModule, ], providers: [ { diff --git a/apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts b/apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts new file mode 100644 index 00000000000..34d2e468ebf --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/course-info-response.mapper.ts @@ -0,0 +1,36 @@ +import { Page } from '@shared/domain/domainobject'; +import { CourseInfoListResponse, CourseInfoDataResponse } from '../controller/dto/response'; +import { CourseInfoDto } from '../uc/dto'; + +export class CourseInfoResponseMapper { + public static mapToCourseInfoListResponse( + courseInfos: Page, + skip?: number, + limit?: number + ): CourseInfoListResponse { + const courseInfoResponses: CourseInfoDataResponse[] = courseInfos.data.map((courseInfo) => + this.mapToCourseInfoResponse(courseInfo) + ); + + const response: CourseInfoListResponse = new CourseInfoListResponse( + courseInfoResponses, + courseInfos.total, + skip, + limit + ); + + return response; + } + + private static mapToCourseInfoResponse(courseInfo: CourseInfoDto): CourseInfoDataResponse { + const courseInfoResponse: CourseInfoDataResponse = new CourseInfoDataResponse({ + id: courseInfo.id, + name: courseInfo.name, + classNames: courseInfo.classes, + teacherNames: courseInfo.teachers, + syncedGroup: courseInfo.syncedGroupName, + }); + + return courseInfoResponse; + } +} diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts index c637c041432..194917950f1 100644 --- a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts @@ -7,6 +7,7 @@ import { Group } from '@modules/group'; import { GroupEntity } from '@modules/group/entity'; import { Test, TestingModule } from '@nestjs/testing'; import { Course as CourseEntity, CourseFeatures, CourseGroup, SchoolEntity, User } from '@shared/domain/entity'; +import { SortOrder } from '@shared/domain/interface'; import { cleanupCollections, courseFactory as courseEntityFactory, @@ -16,7 +17,7 @@ import { schoolEntityFactory, userFactory, } from '@shared/testing'; -import { Course, COURSE_REPO, CourseProps } from '../../domain'; +import { Course, COURSE_REPO, CourseProps, CourseStatus } from '../../domain'; import { courseFactory } from '../../testing'; import { CourseMikroOrmRepo } from './course.repo'; import { CourseEntityMapper } from './mapper/course.entity.mapper'; @@ -174,4 +175,84 @@ describe(CourseMikroOrmRepo.name, () => { }); }); }); + + describe('findCourses', () => { + describe('when entitys are not found', () => { + const setup = async () => { + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); + const courseEntities: CourseEntity[] = courseEntityFactory.buildList(2, { + school: schoolEntity, + untilDate: new Date('2050-04-24'), + }); + + await em.persistAndFlush([schoolEntity, ...courseEntities]); + em.clear(); + + const filter = { schoolId: schoolEntity.id, status: CourseStatus.ARCHIVE }; + + const courseDOs = courseEntities.map((courseEntity) => CourseEntityMapper.mapEntityToDo(courseEntity)); + return { courseDOs, filter }; + }; + + it('should return empty array', async () => { + const { filter } = await setup(); + + const result = await repo.getCourseInfo(filter); + + expect(result.data).toEqual([]); + }); + }); + + describe('when entitys are found for school', () => { + const setup = async () => { + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); + const courseEntities: CourseEntity[] = courseEntityFactory.buildList(5, { + school: schoolEntity, + untilDate: new Date('1995-04-24'), + }); + + courseEntities.push( + ...courseEntityFactory.buildList(3, { + school: schoolEntity, + untilDate: new Date('2050-04-24'), + }) + ); + + await em.persistAndFlush([schoolEntity, ...courseEntities]); + em.clear(); + + const pagination = { skip: 0, limit: 10 }; + const options = { + pagination, + order: { + name: SortOrder.desc, + }, + }; + const filter = { schoolId: schoolEntity.id, status: CourseStatus.ARCHIVE }; + + const courseDOs = courseEntities.map((courseEntity) => CourseEntityMapper.mapEntityToDo(courseEntity)); + + return { courseDOs, options, filter }; + }; + + it('should return archived courses', async () => { + const { options, filter } = await setup(); + + const result = await repo.getCourseInfo(filter, options); + + expect(result.data.length).toEqual(5); + expect(result.total).toEqual(5); + }); + + it('should return current courses', async () => { + const { options, filter } = await setup(); + + filter.status = CourseStatus.CURRENT; + const result = await repo.getCourseInfo(filter, options); + + expect(result.data.length).toEqual(3); + expect(result.total).toEqual(3); + }); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts index f4efdf13bfb..647d43aed26 100644 --- a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts @@ -1,9 +1,12 @@ -import { EntityData, EntityName } from '@mikro-orm/core'; +import { EntityData, EntityName, FindOptions } from '@mikro-orm/core'; import { Group } from '@modules/group'; +import { Page } from '@shared/domain/domainobject'; import { Course as CourseEntity } from '@shared/domain/entity'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { CourseScope } from '@shared/repo'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; -import { Course, CourseRepo } from '../../domain'; +import { Course, CourseFilter, CourseRepo, CourseStatus } from '../../domain'; import { CourseEntityMapper } from './mapper/course.entity.mapper'; export class CourseMikroOrmRepo extends BaseDomainObjectRepo implements CourseRepo { @@ -44,4 +47,40 @@ export class CourseMikroOrmRepo extends BaseDomainObjectRepo): Promise> { + const scope: CourseScope = new CourseScope(); + scope.bySchoolId(filter.schoolId); + if (filter.status === CourseStatus.CURRENT) { + scope.forActiveCourses(); + } else { + scope.forArchivedCourses(); + } + + const findOptions = this.mapToMikroOrmOptions(options); + + const [entities, total] = await this.em.findAndCount(CourseEntity, scope.query, findOptions); + await Promise.all( + entities.map(async (entity: CourseEntity): Promise => { + if (!entity.courseGroups.isInitialized()) { + await entity.courseGroups.init(); + } + }) + ); + + const courses: Course[] = entities.map((entity: CourseEntity): Course => CourseEntityMapper.mapEntityToDo(entity)); + const page: Page = new Page(courses, total); + + return page; + } + + private mapToMikroOrmOptions

(options?: IFindOptions): FindOptions { + const findOptions: FindOptions = { + offset: options?.pagination?.skip, + limit: options?.pagination?.limit, + orderBy: options?.order, + }; + + return findOptions; + } } diff --git a/apps/server/src/modules/learnroom/service/course-do.service.spec.ts b/apps/server/src/modules/learnroom/service/course-do.service.spec.ts index b015194b5ee..a6fae948299 100644 --- a/apps/server/src/modules/learnroom/service/course-do.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-do.service.spec.ts @@ -3,8 +3,18 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Group } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions, SortOrder } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { groupFactory } from '@shared/testing'; -import { Course, COURSE_REPO, CourseNotSynchronizedLoggableException, CourseRepo } from '../domain'; +import { + Course, + COURSE_REPO, + CourseAlreadySynchronizedLoggableException, + CourseFilter, + CourseNotSynchronizedLoggableException, + CourseRepo, +} from '../domain'; import { courseFactory } from '../testing'; import { CourseDoService } from './course-do.service'; @@ -169,4 +179,85 @@ describe(CourseDoService.name, () => { }); }); }); + + describe('startSynchronization', () => { + describe('when a course is not synchronized with a group', () => { + const setup = () => { + const course: Course = courseFactory.build(); + const group: Group = groupFactory.build(); + + return { + course, + group, + }; + }; + + it('should save a course with a synchronized group', async () => { + const { course, group } = setup(); + + await service.startSynchronization(course, group); + + expect(courseRepo.save).toHaveBeenCalledWith( + new Course({ + ...course.getProps(), + syncedWithGroup: group.id, + }) + ); + }); + }); + + describe('when a course is synchronized with a group', () => { + const setup = () => { + const course: Course = courseFactory.build({ syncedWithGroup: new ObjectId().toHexString() }); + const group: Group = groupFactory.build(); + + return { + course, + group, + }; + }; + it('should throw an unprocessable entity exception', async () => { + const { course, group } = setup(); + + await expect(service.startSynchronization(course, group)).rejects.toThrow( + CourseAlreadySynchronizedLoggableException + ); + }); + }); + }); + + describe('findCourses', () => { + describe('when course are found', () => { + const setup = () => { + const courses: Course[] = courseFactory.buildList(5); + const schoolId: EntityId = new ObjectId().toHexString(); + const filter: CourseFilter = { schoolId }; + const options: IFindOptions = { + order: { + name: SortOrder.asc, + }, + pagination: { + limit: 2, + skip: 1, + }, + }; + + courseRepo.getCourseInfo.mockResolvedValueOnce(new Page(courses, 5)); + + return { + courses, + schoolId, + filter, + options, + }; + }; + + it('should return the courses by passing filter and options', async () => { + const { courses, filter, options } = setup(); + const result: Page = await service.getCourseInfo(filter, options); + + expect(result.data).toEqual(courses); + }); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/service/course-do.service.ts b/apps/server/src/modules/learnroom/service/course-do.service.ts index 5bcba757d1f..951693dee25 100644 --- a/apps/server/src/modules/learnroom/service/course-do.service.ts +++ b/apps/server/src/modules/learnroom/service/course-do.service.ts @@ -1,8 +1,17 @@ import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; import { type Group } from '@modules/group'; import { Inject, Injectable } from '@nestjs/common'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { type Course, COURSE_REPO, CourseNotSynchronizedLoggableException, CourseRepo } from '../domain'; +import { + type Course, + COURSE_REPO, + CourseAlreadySynchronizedLoggableException, + CourseFilter, + CourseNotSynchronizedLoggableException, + CourseRepo, +} from '../domain'; @Injectable() export class CourseDoService implements AuthorizationLoaderServiceGeneric { @@ -35,4 +44,20 @@ export class CourseDoService implements AuthorizationLoaderServiceGeneric { + if (course.syncedWithGroup) { + throw new CourseAlreadySynchronizedLoggableException(course.id); + } + + course.syncedWithGroup = group.id; + + await this.courseRepo.save(course); + } + + public async getCourseInfo(filter: CourseFilter, options?: IFindOptions): Promise> { + const courses = await this.courseRepo.getCourseInfo(filter, options); + + return courses; + } } diff --git a/apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts new file mode 100644 index 00000000000..2369fc6404c --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-info.uc.spec.ts @@ -0,0 +1,311 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { classFactory } from '@modules/class/domain/testing'; +import { GroupService } from '@modules/group'; +import { SchoolService } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; +import { UserService } from '@modules/user'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Page } from '@shared/domain/domainobject'; +import { IFindOptions, Permission, RoleName, SortOrder } from '@shared/domain/interface'; +import { groupFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, userFactory } from '@shared/testing'; +import { Course, CourseFilter, CourseSortProps, CourseStatus } from '../domain'; +import { CourseDoService } from '../service'; +import { courseFactory as courseDoFactory } from '../testing'; +import { CourseInfoUc } from './course-info.uc'; +import { CourseInfoDto } from './dto'; + +describe('CourseInfoUc', () => { + let module: TestingModule; + let uc: CourseInfoUc; + + let authorizationService: DeepMocked; + let schoolService: DeepMocked; + let courseDoService: DeepMocked; + let groupService: DeepMocked; + let userService: DeepMocked; + let classService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + CourseInfoUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), + }, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(CourseInfoUc); + authorizationService = module.get(AuthorizationService); + schoolService = module.get(SchoolService); + courseDoService = module.get(CourseDoService); + groupService = module.get(GroupService); + userService = module.get(UserService); + classService = module.get(ClassService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getCourseInfo', () => { + describe('when calling getCourseInfo', () => { + const setup = () => { + const user = userFactory.withRoleByName(RoleName.TEACHER).buildWithId(); + const teacher = userDoFactory.build({ id: user.id, firstName: 'firstName', lastName: 'lastName' }); + const { adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]); + const group = groupFactory.build({ name: 'groupName' }); + const clazz = classFactory.build({ name: 'A', gradeLevel: 1 }); + const courses = courseDoFactory.buildList(5, { + syncedWithGroup: group.id, + teacherIds: [user.id], + groupIds: [group.id], + classIds: [clazz.id], + }); + const pagination = { skip: 0, limit: 5 }; + const school = schoolFactory.build(); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValue(adminUser); + authorizationService.checkPermission.mockReturnValueOnce(undefined); + groupService.findById.mockResolvedValue(group); + userService.findById.mockResolvedValue(teacher); + classService.findById.mockResolvedValue(clazz); + courseDoService.getCourseInfo.mockResolvedValueOnce(new Page(courses, 5)); + + return { + user, + courses, + pagination, + school, + adminUser, + group, + clazz, + }; + }; + + it('should call school service getSchoolById', async () => { + const { adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(schoolService.getSchoolById).toHaveBeenCalledWith(school.id); + }); + + it('should call user service getUserWithPermissions', async () => { + const { adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(adminUser.id); + }); + + it('should call authorization service checkPermission', async () => { + const { adminUser, school } = setup(); + const expectedPermissions = { + action: 'read', + requiredPermissions: ['COURSE_ADMINISTRATION'], + }; + await uc.getCourseInfo(adminUser.id, school.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith(adminUser, school, expectedPermissions); + }); + + it('should call user service findById', async () => { + const { user, adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(userService.findById).toHaveBeenCalledWith(user.id); + }); + + it('should call class service findById', async () => { + const { clazz, adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(classService.findById).toHaveBeenCalledWith(clazz.id); + }); + it('should call group service findById', async () => { + const { group, adminUser, school } = setup(); + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(groupService.findById).toHaveBeenCalledWith(group.id); + }); + + it('should call with default options', async () => { + const { adminUser, school } = setup(); + const filter: CourseFilter = { schoolId: school.id, status: undefined }; + const options: IFindOptions = { + order: { + name: SortOrder.asc, + }, + pagination: undefined, + }; + + await uc.getCourseInfo(adminUser.id, school.id); + + expect(courseDoService.getCourseInfo).toHaveBeenCalledWith(filter, options); + }); + + it('should call with non-default options and filter', async () => { + const { school, adminUser } = setup(); + const filter: CourseFilter = { schoolId: school.id, status: CourseStatus.ARCHIVE }; + const options: IFindOptions = { + order: { + name: SortOrder.asc, + }, + pagination: { skip: 0, limit: 5 }, + }; + + await uc.getCourseInfo( + adminUser.id, + school.id, + CourseSortProps.NAME, + CourseStatus.ARCHIVE, + { skip: 0, limit: 5 }, + SortOrder.asc + ); + + expect(courseDoService.getCourseInfo).toHaveBeenCalledWith(filter, options); + }); + }); + + describe('when courses are found', () => { + const setup = () => { + const user = userFactory.withRoleByName(RoleName.TEACHER).buildWithId(); + const teacher = userDoFactory.build({ id: user.id, firstName: 'firstName', lastName: 'lastName' }); + const { adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]); + const group = groupFactory.build({ name: 'groupName' }); + const clazz = classFactory.build({ name: 'A', gradeLevel: 1 }); + const course1 = courseDoFactory.build({ + id: 'course1', + name: 'course1', + syncedWithGroup: group.id, + teacherIds: [user.id], + groupIds: [group.id], + classIds: [clazz.id], + }); + const course2 = courseDoFactory.build({ + id: 'course2', + name: 'course2', + teacherIds: [user.id], + groupIds: [group.id], + classIds: [clazz.id], + }); + const school = schoolFactory.build(); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValue(adminUser); + authorizationService.checkPermission.mockReturnValueOnce(undefined); + groupService.findById.mockResolvedValue(group); + userService.findById.mockResolvedValue(teacher); + classService.findById.mockResolvedValue(clazz); + courseDoService.getCourseInfo.mockResolvedValueOnce(new Page([course1, course2], 1)); + + return { + school, + adminUser, + }; + }; + + it('should return courses with sorted and filtered results', async () => { + const { school, adminUser } = setup(); + + const result: Page = await uc.getCourseInfo(adminUser.id, school.id); + + expect(result.data[0]).toMatchObject({ + id: 'course1', + name: 'course1', + teachers: ['firstName lastName'], + classes: ['1A', 'groupName'], + syncedGroupName: 'groupName', + }); + expect(result.data[1]).toMatchObject({ + id: 'course2', + name: 'course2', + teachers: ['firstName lastName'], + classes: ['1A', 'groupName'], + syncedGroupName: undefined, + }); + }); + }); + + describe('when user does not have permission', () => { + const setup = () => { + const { adminUser } = UserAndAccountTestFactory.buildAdmin({}, []); + const school = schoolFactory.build(); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValue(adminUser); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw new ForbiddenException(); + }); + + return { + school, + adminUser, + }; + }; + it('should throw an forbidden exception', async () => { + const { school, adminUser } = setup(); + + const getCourseInfo = async () => uc.getCourseInfo(adminUser.id, school.id); + + await expect(getCourseInfo()).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when courses are not found', () => { + const setup = () => { + const adminUserId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + courseDoService.getCourseInfo.mockResolvedValueOnce(new Page([], 0)); + + return { + adminUserId, + schoolId, + }; + }; + + it('should return an empty page if no courses are found', async () => { + const { adminUserId, schoolId } = setup(); + + const result = await uc.getCourseInfo(adminUserId, schoolId); + + expect(result.total).toBe(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/uc/course-info.uc.ts b/apps/server/src/modules/learnroom/uc/course-info.uc.ts new file mode 100644 index 00000000000..e133a86616d --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-info.uc.ts @@ -0,0 +1,119 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ClassService } from '@modules/class'; +import { Group, GroupService } from '@modules/group'; +import { School, SchoolService } from '@modules/school'; +import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { Page, UserDO } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; +import { IFindOptions, Pagination, Permission, SortOrder, SortOrderMap } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { Course as CourseDO } from '../domain'; +import { CourseFilter, CourseStatus } from '../domain/interface'; +import { CourseSortProps } from '../domain/interface/course-sort-props.enum'; +import { CourseDoService } from '../service'; +import { CourseInfoDto } from './dto'; + +@Injectable() +export class CourseInfoUc { + public constructor( + private readonly authService: AuthorizationService, + private readonly schoolService: SchoolService, + private readonly courseDoService: CourseDoService, + private readonly groupService: GroupService, + private readonly userService: UserService, + private readonly classService: ClassService + ) {} + + public async getCourseInfo( + userId: EntityId, + schoolId: EntityId, + sortByField: CourseSortProps = CourseSortProps.NAME, + courseStatusQueryType?: CourseStatus, + pagination?: Pagination, + sortOrder: SortOrder = SortOrder.asc + ): Promise> { + const school: School = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authService.getUserWithPermissions(userId); + this.authService.checkPermission( + user, + school, + AuthorizationContextBuilder.read([Permission.COURSE_ADMINISTRATION]) + ); + + const order: SortOrderMap = { [sortByField]: sortOrder }; + const filter: CourseFilter = { schoolId, status: courseStatusQueryType }; + const options: IFindOptions = { pagination, order }; + const courses: Page = await this.courseDoService.getCourseInfo(filter, options); + + const resolvedCourses: CourseInfoDto[] = await this.getCourseData(courses.data); + + const page = new Page(resolvedCourses, courses.total); + + return page; + } + + private async getCourseData(courses: CourseDO[]): Promise { + const courseInfos: CourseInfoDto[] = await Promise.all( + courses.map(async (course) => { + const groupName = course.syncedWithGroup ? await this.getSyncedGroupName(course.syncedWithGroup) : undefined; + const teacherNames: string[] = await this.getCourseTeacherFullNames(course.teachers); + const classNames: string[] = await this.getCourseClassNamaes(course.classes); + const groupNames: string[] = await this.getCourseGroupNames(course.groups); + + const mapped = new CourseInfoDto({ + id: course.id, + name: course.name, + classes: [...classNames, ...groupNames], + teachers: teacherNames, + syncedGroupName: groupName, + }); + + return mapped; + }) + ); + + return courseInfos; + } + + private async getSyncedGroupName(groupId: EntityId): Promise { + const group: Group = await this.groupService.findById(groupId); + + return group.name; + } + + private async getCourseTeacherFullNames(teacherIds: EntityId[]): Promise { + const teacherNames: string[] = await Promise.all( + teacherIds.map(async (teacherId): Promise => { + const teacher: UserDO = await this.userService.findById(teacherId); + const fullName = teacher.firstName.concat(' ', teacher.lastName); + + return fullName; + }) + ); + return teacherNames; + } + + private async getCourseClassNamaes(classIds: EntityId[]): Promise { + const classes: string[] = await Promise.all[]>( + classIds.map(async (classId): Promise => { + const clazz = await this.classService.findById(classId); + + return clazz.getClassFullName(); + }) + ); + return classes; + } + + private async getCourseGroupNames(groupIds: EntityId[]): Promise { + const groups: string[] = await Promise.all[]>( + groupIds.map(async (groupId): Promise => { + const group = await this.groupService.findById(groupId); + + return group.name; + }) + ); + return groups; + } +} diff --git a/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts index f9171895b9d..60366ab3065 100644 --- a/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts @@ -1,8 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { GroupService } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { setupEntities, userFactory } from '@shared/testing'; +import { groupFactory, setupEntities, userFactory } from '@shared/testing'; import { CourseDoService } from '../service'; import { courseFactory } from '../testing'; import { CourseSyncUc } from './course-sync.uc'; @@ -13,6 +14,7 @@ describe(CourseSyncUc.name, () => { let authorizationService: DeepMocked; let courseService: DeepMocked; + let groupService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -26,12 +28,17 @@ describe(CourseSyncUc.name, () => { provide: CourseDoService, useValue: createMock(), }, + { + provide: GroupService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(CourseSyncUc); authorizationService = module.get(AuthorizationService); courseService = module.get(CourseDoService); + groupService = module.get(GroupService); await setupEntities(); }); @@ -79,4 +86,48 @@ describe(CourseSyncUc.name, () => { }); }); }); + + describe('startSynchronization', () => { + describe('when a user starts a synchronization of a course with a group', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.build(); + const group = groupFactory.build(); + + courseService.findById.mockResolvedValueOnce(course); + groupService.findById.mockResolvedValueOnce(group); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + course, + group, + }; + }; + + it('should check the users permission', async () => { + const { user, course, group } = setup(); + + await uc.startSynchronization(user.id, course.id, group.id); + + expect(courseService.findById).toHaveBeenCalledWith(course.id); + expect(groupService.findById).toHaveBeenCalledWith(group.id); + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + course, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + }); + + it('should start the synchronization', async () => { + const { user, course, group } = setup(); + + await uc.startSynchronization(user.id, course.id, group.id); + expect(courseService.findById).toHaveBeenCalledWith(course.id); + expect(groupService.findById).toHaveBeenCalledWith(group.id); + + expect(courseService.startSynchronization).toHaveBeenCalledWith(course, group); + }); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/uc/course-sync.uc.ts b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts index 5ffe55abe3e..53a4fe2e173 100644 --- a/apps/server/src/modules/learnroom/uc/course-sync.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts @@ -1,4 +1,5 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Group, GroupService } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { type User as UserEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; @@ -10,7 +11,8 @@ import { CourseDoService } from '../service'; export class CourseSyncUc { constructor( private readonly authorizationService: AuthorizationService, - private readonly courseService: CourseDoService + private readonly courseService: CourseDoService, + private readonly groupService: GroupService ) {} public async stopSynchronization(userId: EntityId, courseId: EntityId): Promise { @@ -25,4 +27,18 @@ export class CourseSyncUc { await this.courseService.stopSynchronization(course); } + + public async startSynchronization(userId: string, courseId: string, groupId: string) { + const course: Course = await this.courseService.findById(courseId); + const group: Group = await this.groupService.findById(groupId); + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + + this.authorizationService.checkPermission( + user, + course, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + + await this.courseService.startSynchronization(course, group); + } } diff --git a/apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts b/apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts new file mode 100644 index 00000000000..a26fe97612a --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/dto/course-info.dto.ts @@ -0,0 +1,21 @@ +import { EntityId } from '@shared/domain/types'; + +export class CourseInfoDto { + id: EntityId; + + name: string; + + teachers: string[]; + + classes: string[]; + + syncedGroupName?: string; + + constructor(props: CourseInfoDto) { + this.id = props.id; + this.name = props.name; + this.classes = props.classes; + this.teachers = props.teachers; + this.syncedGroupName = props.syncedGroupName; + } +} diff --git a/apps/server/src/modules/learnroom/uc/dto/index.ts b/apps/server/src/modules/learnroom/uc/dto/index.ts new file mode 100644 index 00000000000..3632d17b639 --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/dto/index.ts @@ -0,0 +1 @@ +export { CourseInfoDto } from './course-info.dto'; diff --git a/apps/server/src/modules/learnroom/uc/index.ts b/apps/server/src/modules/learnroom/uc/index.ts index c50d28e74d7..6c5de6d226a 100644 --- a/apps/server/src/modules/learnroom/uc/index.ts +++ b/apps/server/src/modules/learnroom/uc/index.ts @@ -2,6 +2,7 @@ export * from './course-copy.uc'; export * from './course-export.uc'; export * from './course-import.uc'; export * from './course-sync.uc'; +export * from './course-info.uc'; export * from './course.uc'; export * from './dashboard.uc'; export * from './lesson-copy.uc'; 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 2642d2d6761..d92b29aed4d 100644 --- a/apps/server/src/modules/server/api/dto/config.response.ts +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -32,6 +32,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; + @ApiProperty() + FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: boolean; + @ApiProperty() FEATURE_CTL_TOOLS_COPY_ENABLED: boolean; @@ -134,6 +137,9 @@ export class ConfigResponse { @ApiProperty() FEATURE_USER_MIGRATION_ENABLED: boolean; + @ApiProperty() + CALENDAR_SERVICE_ENABLED: boolean; + @ApiProperty() FEATURE_COPY_SERVICE_ENABLED: boolean; @@ -225,6 +231,7 @@ export class ConfigResponse { this.ACCESSIBILITY_REPORT_EMAIL = config.ACCESSIBILITY_REPORT_EMAIL; this.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN = config.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN; this.ALERT_STATUS_URL = config.ALERT_STATUS_URL; + this.CALENDAR_SERVICE_ENABLED = config.CALENDAR_SERVICE_ENABLED; this.FEATURE_ES_COLLECTIONS_ENABLED = config.FEATURE_ES_COLLECTIONS_ENABLED; this.FEATURE_EXTENSIONS_ENABLED = config.FEATURE_EXTENSIONS_ENABLED; this.FEATURE_TEAMS_ENABLED = config.FEATURE_TEAMS_ENABLED; @@ -279,6 +286,7 @@ export class ConfigResponse { this.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION = config.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION; this.CTL_TOOLS_RELOAD_TIME_MS = config.CTL_TOOLS_RELOAD_TIME_MS; this.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED; + this.FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED; this.FEATURE_CTL_TOOLS_COPY_ENABLED = config.FEATURE_CTL_TOOLS_COPY_ENABLED; this.FEATURE_SHOW_MIGRATION_WIZARD = config.FEATURE_SHOW_MIGRATION_WIZARD; this.MIGRATION_WIZARD_DOCUMENTATION_LINK = config.MIGRATION_WIZARD_DOCUMENTATION_LINK; 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 58fe1d2f799..5627d34e17e 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 @@ -38,6 +38,7 @@ describe('Server Controller (API)', () => { 'ADMIN_TABLES_DISPLAY_CONSENT_COLUMN', 'ALERT_STATUS_URL', 'CTL_TOOLS_RELOAD_TIME_MS', + 'CALENDAR_SERVICE_ENABLED', 'DOCUMENT_BASE_DIR', 'FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED', 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', @@ -70,6 +71,7 @@ describe('Server Controller (API)', () => { 'FEATURE_SCHOOL_TERMS_OF_USE_ENABLED', 'FEATURE_SHOW_MIGRATION_WIZARD', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', + 'FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED', 'FEATURE_SHOW_OUTDATED_USERS', 'FEATURE_TASK_SHARE', 'FEATURE_TEAMS_ENABLED', diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 147ad77b200..8d392ce1cb2 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -72,6 +72,7 @@ export interface ServerConfig ACCESSIBILITY_REPORT_EMAIL: string; ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: boolean; ALERT_STATUS_URL: string | null; + CALENDAR_SERVICE_ENABLED: boolean; FEATURE_ES_COLLECTIONS_ENABLED: boolean; FEATURE_EXTENSIONS_ENABLED: boolean; FEATURE_TEAMS_ENABLED: boolean; @@ -105,6 +106,7 @@ export interface ServerConfig FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: boolean; FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: boolean; FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; + FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: boolean; FEATURE_SHOW_MIGRATION_WIZARD: boolean; MIGRATION_WIZARD_DOCUMENTATION_LINK?: string; FEATURE_OTHER_GROUPUSERS_PROVISIONING_ENABLED: boolean; @@ -132,6 +134,7 @@ const config: ServerConfig = { Configuration.get('ALERT_STATUS_URL') === null ? (Configuration.get('ALERT_STATUS_URL') as null) : (Configuration.get('ALERT_STATUS_URL') as string), + CALENDAR_SERVICE_ENABLED: Configuration.get('CALENDAR_SERVICE_ENABLED') as boolean, FEATURE_ES_COLLECTIONS_ENABLED: Configuration.get('FEATURE_ES_COLLECTIONS_ENABLED') as boolean, FEATURE_EXTENSIONS_ENABLED: Configuration.get('FEATURE_EXTENSIONS_ENABLED') as boolean, FEATURE_TEAMS_ENABLED: Configuration.get('FEATURE_TEAMS_ENABLED') as boolean, @@ -221,6 +224,7 @@ const config: ServerConfig = { FEATURE_SHOW_OUTDATED_USERS: Configuration.get('FEATURE_SHOW_OUTDATED_USERS') as boolean, FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: Configuration.get('FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION') as boolean, FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: Configuration.get('FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED') as boolean, + FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED: Configuration.get('FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED') as boolean, FEATURE_SHOW_MIGRATION_WIZARD: Configuration.get('FEATURE_SHOW_MIGRATION_WIZARD') as boolean, FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: Configuration.get( 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED' diff --git a/apps/server/src/shared/domain/entity/course.entity.ts b/apps/server/src/shared/domain/entity/course.entity.ts index 84a9005e48e..3bad3a7e62a 100644 --- a/apps/server/src/shared/domain/entity/course.entity.ts +++ b/apps/server/src/shared/domain/entity/course.entity.ts @@ -120,7 +120,7 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit if (props.features) this.features = props.features; this.classes.set(props.classes || []); this.groups.set(props.groups || []); - this.syncedWithGroup = props.syncedWithGroup; + if (props.syncedWithGroup) this.syncedWithGroup = props.syncedWithGroup; } public getStudentIds(): EntityId[] { diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index d85ed328e55..70d57507164 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -26,6 +26,7 @@ export enum Permission { CONTEXT_TOOL_USER = 'CONTEXT_TOOL_USER', COURSEGROUP_CREATE = 'COURSEGROUP_CREATE', COURSEGROUP_EDIT = 'COURSEGROUP_EDIT', + COURSE_ADMINISTRATION = 'COURSE_ADMINISTRATION', COURSE_CREATE = 'COURSE_CREATE', COURSE_DELETE = 'COURSE_DELETE', COURSE_EDIT = 'COURSE_EDIT', diff --git a/apps/server/src/shared/repo/course/course.repo.ts b/apps/server/src/shared/repo/course/course.repo.ts index fb52b8c115e..ebe1074db02 100644 --- a/apps/server/src/shared/repo/course/course.repo.ts +++ b/apps/server/src/shared/repo/course/course.repo.ts @@ -1,56 +1,11 @@ -import { FilterQuery, QueryOrderMap } from '@mikro-orm/core'; +import { QueryOrderMap } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { Course } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { BaseRepo } from '../base.repo'; -import { Scope } from '../scope'; - -class CourseScope extends Scope { - forAllGroupTypes(userId: EntityId): CourseScope { - const isStudent = { students: userId }; - const isTeacher = { teachers: userId }; - const isSubstitutionTeacher = { substitutionTeachers: userId }; - - if (userId) { - this.addQuery({ $or: [isStudent, isTeacher, isSubstitutionTeacher] }); - } - - return this; - } - - forTeacherOrSubstituteTeacher(userId: EntityId): CourseScope { - const isTeacher = { teachers: userId }; - const isSubstitutionTeacher = { substitutionTeachers: userId }; - - if (userId) { - this.addQuery({ $or: [isTeacher, isSubstitutionTeacher] }); - } - - return this; - } - - forTeacher(userId: EntityId): CourseScope { - this.addQuery({ teachers: userId }); - return this; - } - - forActiveCourses(): CourseScope { - const now = new Date(); - const noUntilDate = { untilDate: { $exists: false } } as FilterQuery; - const untilDateInFuture = { untilDate: { $gte: now } }; - - this.addQuery({ $or: [noUntilDate, untilDateInFuture] }); - - return this; - } - - forCourseId(courseId: EntityId): CourseScope { - this.addQuery({ id: courseId }); - return this; - } -} +import { CourseScope } from './course.scope'; @Injectable() export class CourseRepo extends BaseRepo { diff --git a/apps/server/src/shared/repo/course/course.scope.spec.ts b/apps/server/src/shared/repo/course/course.scope.spec.ts new file mode 100644 index 00000000000..84d0a22767d --- /dev/null +++ b/apps/server/src/shared/repo/course/course.scope.spec.ts @@ -0,0 +1,202 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { CourseScope } from './course.scope'; + +describe(CourseScope.name, () => { + let scope: CourseScope; + + beforeEach(() => { + scope = new CourseScope(); + scope.allowEmptyQuery(true); + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + describe('forAllGroupTypes', () => { + describe('when id is defined', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const isStudent = { students: userId }; + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + return { + userId, + isStudent, + isTeacher, + isSubstitutionTeacher, + }; + }; + + it('should add query', () => { + const { userId, isStudent, isTeacher, isSubstitutionTeacher } = setup(); + + scope.forAllGroupTypes(userId); + + expect(scope.query).toEqual({ $or: [isStudent, isTeacher, isSubstitutionTeacher] }); + }); + }); + }); + + describe('forTeacherOrSubstituteTeacher', () => { + describe('when id is defined', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + return { + userId, + isTeacher, + isSubstitutionTeacher, + }; + }; + + it('should add query', () => { + const { userId, isTeacher, isSubstitutionTeacher } = setup(); + + scope.forTeacherOrSubstituteTeacher(userId); + + expect(scope.query).toEqual({ $or: [isTeacher, isSubstitutionTeacher] }); + }); + }); + }); + + describe('forTeacher', () => { + describe('when id is defined', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const isTeacher = { teachers: userId }; + + return { + userId, + isTeacher, + }; + }; + + it('should add query', () => { + const { userId } = setup(); + + scope.forTeacher(userId); + + expect(scope.query).toEqual({ teachers: userId }); + }); + }); + }); + + describe('forActiveCourses', () => { + describe('when called', () => { + const setup = () => { + const now = new Date(); + + const noUntilDate = { untilDate: { $exists: false } }; + const untilDateInFuture = { untilDate: { $gte: now } }; + + return { + noUntilDate, + untilDateInFuture, + }; + }; + + it('should add query', () => { + const { noUntilDate, untilDateInFuture } = setup(); + + scope.forActiveCourses(); + + expect(scope.query).toEqual({ $or: [noUntilDate, untilDateInFuture] }); + }); + }); + }); + + describe('forCourseId', () => { + describe('when id is defined', () => { + const setup = () => { + const courseId = new ObjectId().toHexString(); + + return { courseId }; + }; + + it('should add query', () => { + const { courseId } = setup(); + + scope.forCourseId(courseId); + + expect(scope.query).toEqual({ id: courseId }); + }); + }); + }); + + describe('bySchoolId', () => { + describe('when id is defined', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + + return { schoolId }; + }; + + it('should add query', () => { + const { schoolId } = setup(); + + scope.bySchoolId(schoolId); + + expect(scope.query).toEqual({ school: schoolId }); + }); + }); + + describe('when id is not defined', () => { + it('should add query', () => { + scope.bySchoolId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + }); + + describe('bySchoolId', () => { + describe('when id is defined', () => { + const setup = () => { + const schoolId = new ObjectId().toHexString(); + + return { schoolId }; + }; + + it('should add query', () => { + const { schoolId } = setup(); + + scope.bySchoolId(schoolId); + + expect(scope.query).toEqual({ school: schoolId }); + }); + }); + + describe('when id is not defined', () => { + it('should add query', () => { + scope.bySchoolId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + }); + + describe('forArchivedCourses', () => { + describe('when called', () => { + const setup = () => { + const now = new Date(); + const untilDateExists = { untilDate: { $exists: true } }; + const untilDateInPast = { untilDate: { $lt: now } }; + + return { + untilDateExists, + untilDateInPast, + }; + }; + + it('should add query', () => { + const { untilDateExists, untilDateInPast } = setup(); + + scope.forArchivedCourses(); + + expect(scope.query).toEqual({ $and: [untilDateExists, untilDateInPast] }); + }); + }); + }); +}); diff --git a/apps/server/src/shared/repo/course/course.scope.ts b/apps/server/src/shared/repo/course/course.scope.ts new file mode 100644 index 00000000000..35bcb8cdfe2 --- /dev/null +++ b/apps/server/src/shared/repo/course/course.scope.ts @@ -0,0 +1,67 @@ +import { FilterQuery } from '@mikro-orm/core'; + +import { Course } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { Scope } from '../scope'; + +export class CourseScope extends Scope { + forAllGroupTypes(userId: EntityId): this { + const isStudent = { students: userId }; + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + if (userId) { + this.addQuery({ $or: [isStudent, isTeacher, isSubstitutionTeacher] }); + } + + return this; + } + + forTeacherOrSubstituteTeacher(userId: EntityId): this { + const isTeacher = { teachers: userId }; + const isSubstitutionTeacher = { substitutionTeachers: userId }; + + if (userId) { + this.addQuery({ $or: [isTeacher, isSubstitutionTeacher] }); + } + + return this; + } + + forTeacher(userId: EntityId): this { + this.addQuery({ teachers: userId }); + return this; + } + + forActiveCourses(): this { + const now = new Date(); + const noUntilDate = { untilDate: { $exists: false } } as FilterQuery; + const untilDateInFuture = { untilDate: { $gte: now } }; + + this.addQuery({ $or: [noUntilDate, untilDateInFuture] }); + + return this; + } + + forCourseId(courseId: EntityId): this { + this.addQuery({ id: courseId }); + return this; + } + + bySchoolId(schoolId: EntityId | undefined): this { + if (schoolId) { + this.addQuery({ school: schoolId }); + } + return this; + } + + forArchivedCourses(): this { + const now = new Date(); + const untilDateExists = { untilDate: { $exists: true } } as FilterQuery; + const untilDateInPast = { untilDate: { $lt: now } }; + + this.addQuery({ $and: [untilDateExists, untilDateInPast] }); + + return this; + } +} diff --git a/apps/server/src/shared/repo/course/index.ts b/apps/server/src/shared/repo/course/index.ts index f2a743f00e6..1e8ed2bf9f5 100644 --- a/apps/server/src/shared/repo/course/index.ts +++ b/apps/server/src/shared/repo/course/index.ts @@ -1 +1,2 @@ export * from './course.repo'; +export * from './course.scope'; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 65915f515f2..2a7feac0983 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -205,5 +205,14 @@ "created_at": { "$date": "2024-07-25T14:57:30.752Z" } + }, + { + "_id": { + "$oid": "66c8a9d1d2ae9ba6c4b43c5d" + }, + "name": "Migration20240823151836", + "created_at": { + "$date": "2024-08-23T15:25:05.360Z" + } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 03922f4ff3b..8de703b913b 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -90,6 +90,7 @@ "COURSE_CREATE", "COURSE_EDIT", "COURSE_REMOVE", + "COURSE_ADMINISTRATION", "DATASOURCES_CREATE", "DATASOURCES_DELETE", "DATASOURCES_EDIT", diff --git a/config/default.schema.json b/config/default.schema.json index 7b28b3ff4fc..5ba007e6743 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1393,6 +1393,11 @@ "default": false, "description": "Enables the new class list view" }, + "FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the new course list view" + }, "FEATURE_GROUPS_IN_COURSE_ENABLED": { "type": "boolean", "default": false, @@ -1661,6 +1666,11 @@ "default": false, "description": "Enables the AI Tutor" }, + "CALENDAR_SERVICE_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables calender service" + }, "FEATURE_ROOMS_ENABLED": { "type": "boolean", "default": "false", diff --git a/src/services/user-group/services/courses.js b/src/services/user-group/services/courses.js index 7f773999f60..2d358210e6e 100644 --- a/src/services/user-group/services/courses.js +++ b/src/services/user-group/services/courses.js @@ -1,5 +1,6 @@ const { authenticate } = require('@feathersjs/authentication'); const { iff, isProvider } = require('feathers-hooks-common'); +const { Configuration } = require('@hpi-schul-cloud/commons/lib'); const { ifNotLocal, restrictToCurrentSchool, @@ -34,6 +35,8 @@ const { const { checkScopePermissions } = require('../../helpers/scopePermissions/hooks'); +const newRoomViewEnabled = Configuration.get('FEATURE_SHOW_NEW_ROOMS_VIEW_ENABLED'); + class Courses { constructor(options) { this.options = options || {}; @@ -60,6 +63,9 @@ class Courses { } remove(id, params) { + if (newRoomViewEnabled) { + this.app.service('/calendar/courses').remove(id, prepareInternalParams(params)); + } return this.app.service('courseModel').remove(id, prepareInternalParams(params)); }