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 c7c519c9435..8059cdf8557 100644 --- a/apps/server/src/modules/class/repo/classes.repo.spec.ts +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -1,13 +1,14 @@ -import { MongoMemoryDatabaseModule } from '@shared/infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { TestingModule } from '@nestjs/testing/testing-module'; import { Test } from '@nestjs/testing'; -import { cleanupCollections } from '@shared/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { SchoolEntity } from '@shared/domain'; +import { MongoMemoryDatabaseModule } from '@shared/infra/database'; +import { cleanupCollections, schoolFactory } from '@shared/testing'; import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; -import { ClassesRepo } from './classes.repo'; +import { Class } from '../domain'; import { ClassEntity } from '../entity'; +import { ClassesRepo } from './classes.repo'; import { ClassMapper } from './mapper'; -import { Class } from '../domain'; describe(ClassesRepo.name, () => { let module: TestingModule; @@ -32,6 +33,38 @@ describe(ClassesRepo.name, () => { await cleanupCollections(em); }); + describe('findAllBySchoolId', () => { + describe('when school has no class', () => { + it('should return empty array', async () => { + const result = await repo.findAllBySchoolId(new ObjectId().toHexString()); + + expect(result).toEqual([]); + }); + }); + + describe('when school has classes', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const classes: ClassEntity[] = classEntityFactory.buildListWithId(3, { schoolId: school.id }); + + await em.persistAndFlush(classes); + + return { + school, + classes, + }; + }; + + it('should find classes with particular userId', async () => { + const { school } = await setup(); + + const result: Class[] = await repo.findAllBySchoolId(school.id); + + expect(result.length).toEqual(3); + }); + }); + }); + describe('findAllByUserId', () => { describe('when user is not found in classes', () => { it('should return empty array', async () => { @@ -40,6 +73,7 @@ describe(ClassesRepo.name, () => { expect(result).toEqual([]); }); }); + describe('when user is in classes', () => { const setup = async () => { const testUser = new ObjectId(); diff --git a/apps/server/src/modules/class/repo/classes.repo.ts b/apps/server/src/modules/class/repo/classes.repo.ts index 1e4b3ba750e..378b3de9716 100644 --- a/apps/server/src/modules/class/repo/classes.repo.ts +++ b/apps/server/src/modules/class/repo/classes.repo.ts @@ -1,18 +1,28 @@ -import { Injectable } from '@nestjs/common'; - import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { ClassEntity } from '../entity'; import { Class } from '../domain'; +import { ClassEntity } from '../entity'; import { ClassMapper } from './mapper'; @Injectable() export class ClassesRepo { - constructor(private readonly em: EntityManager, private readonly mapper: ClassMapper) {} + constructor(private readonly em: EntityManager) {} + + async findAllBySchoolId(schoolId: EntityId): Promise { + const classes: ClassEntity[] = await this.em.find(ClassEntity, { schoolId: new ObjectId(schoolId) }); + + const mapped: Class[] = ClassMapper.mapToDOs(classes); + + return mapped; + } async findAllByUserId(userId: EntityId): Promise { const classes: ClassEntity[] = await this.em.find(ClassEntity, { userIds: new ObjectId(userId) }); - return ClassMapper.mapToDOs(classes); + + const mapped: Class[] = ClassMapper.mapToDOs(classes); + + return mapped; } async updateMany(classes: Class[]): Promise { 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 d851a38f62e..3d1e851bd32 100644 --- a/apps/server/src/modules/class/service/class.service.spec.ts +++ b/apps/server/src/modules/class/service/class.service.spec.ts @@ -1,13 +1,15 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { EntityId } from '@shared/domain'; -import { InternalServerErrorException } from '@nestjs/common'; -import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; -import { ObjectId } from '@mikro-orm/mongodb'; import { setupEntities } from '@shared/testing'; -import { ClassService } from './class.service'; +import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; +import { Class } from '../domain'; +import { classFactory } from '../domain/testing/factory/class.factory'; import { ClassesRepo } from '../repo'; import { ClassMapper } from '../repo/mapper'; +import { ClassService } from './class.service'; describe(ClassService.name, () => { let module: TestingModule; @@ -39,38 +41,35 @@ describe(ClassService.name, () => { await module.close(); }); - describe('findUserDataFromClasses', () => { - describe('when finding by userId', () => { + describe('findClassesForSchool', () => { + describe('when the school has classes', () => { const setup = () => { - const userId1 = new ObjectId(); - const userId2 = new ObjectId(); - const userId3 = new ObjectId(); - const class1 = classEntityFactory.withUserIds([userId1, userId2]).build(); - const class2 = classEntityFactory.withUserIds([userId1, userId3]).build(); - classEntityFactory.withUserIds([userId2, userId3]).build(); + const schoolId: string = new ObjectId().toHexString(); - const mappedClasses = ClassMapper.mapToDOs([class1, class2]); + const classes: Class[] = classFactory.buildList(3); - classesRepo.findAllByUserId.mockResolvedValue(mappedClasses); + classesRepo.findAllBySchoolId.mockResolvedValueOnce(classes); return { - userId1, + schoolId, + classes, }; }; - it('should call classesRepo.findAllByUserId', async () => { - const { userId1 } = setup(); - await service.deleteUserDataFromClasses(userId1.toHexString()); + it('should call the repo', async () => { + const { schoolId } = setup(); - expect(classesRepo.findAllByUserId).toBeCalledWith(userId1.toHexString()); + await service.findClassesForSchool(schoolId); + + expect(classesRepo.findAllBySchoolId).toHaveBeenCalledWith(schoolId); }); - it('should return array of two teams with user', async () => { - const { userId1 } = setup(); + it('should return the classes', async () => { + const { schoolId, classes } = setup(); - const result = await service.findUserDataFromClasses(userId1.toHexString()); + const result: Class[] = await service.findClassesForSchool(schoolId); - expect(result.length).toEqual(2); + expect(result).toEqual(classes); }); }); }); diff --git a/apps/server/src/modules/class/service/class.service.ts b/apps/server/src/modules/class/service/class.service.ts index 7a42606769a..9671c456912 100644 --- a/apps/server/src/modules/class/service/class.service.ts +++ b/apps/server/src/modules/class/service/class.service.ts @@ -1,14 +1,14 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { EntityId } from '@shared/domain'; -import { ClassesRepo } from '../repo'; import { Class } from '../domain'; +import { ClassesRepo } from '../repo'; @Injectable() export class ClassService { constructor(private readonly classesRepo: ClassesRepo) {} - public async findUserDataFromClasses(userId: EntityId): Promise { - const classes = await this.classesRepo.findAllByUserId(userId); + public async findClassesForSchool(schoolId: EntityId): Promise { + const classes: Class[] = await this.classesRepo.findAllBySchoolId(schoolId); return classes; } diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts new file mode 100644 index 00000000000..f0561518c0c --- /dev/null +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -0,0 +1,140 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, RoleName, SchoolEntity, SortOrder, SystemEntity, User } from '@shared/domain'; +import { + groupEntityFactory, + roleFactory, + schoolFactory, + systemFactory, + TestApiClient, + UserAndAccountTestFactory, + userFactory, +} from '@shared/testing'; +import { ClassEntity } from '@src/modules/class/entity'; +import { classEntityFactory } from '@src/modules/class/entity/testing/factory/class.entity.factory'; +import { ServerTestModule } from '@src/modules/server'; +import { GroupEntity, GroupEntityTypes } from '../../entity'; +import { ClassInfoSearchListResponse, ClassSortBy } from '../dto'; + +const baseRouteName = '/groups'; + +describe('Group (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, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('findClassesForSchool', () => { + describe('when an admin requests a list of classes', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); + const teacherUser: User = userFactory.buildWithId({ school, roles: [teacherRole] }); + const system: SystemEntity = systemFactory.buildWithId(); + const clazz: ClassEntity = classEntityFactory.buildWithId({ + name: 'Group A', + schoolId: school._id, + teacherIds: [teacherUser._id], + source: undefined, + }); + const group: GroupEntity = groupEntityFactory.buildWithId({ + name: 'Group B', + type: GroupEntityTypes.CLASS, + externalSource: { + externalId: 'externalId', + system, + }, + organization: school, + users: [ + { + user: adminUser, + role: teacherRole, + }, + ], + }); + + await em.persistAndFlush([school, adminAccount, adminUser, teacherRole, teacherUser, system, clazz, group]); + em.clear(); + + const adminClient = await testApiClient.login(adminAccount); + + return { + adminClient, + group, + clazz, + system, + adminUser, + teacherUser, + }; + }; + + it('should return the classes of his school', async () => { + const { adminClient, group, clazz, system, adminUser, teacherUser } = await setup(); + + const response = await adminClient.get(`/class`).query({ + skip: 0, + limit: 2, + sortBy: ClassSortBy.NAME, + sortOrder: SortOrder.desc, + }); + + expect(response.body).toEqual({ + total: 2, + data: [ + { + name: group.name, + externalSourceName: system.displayName, + teachers: [adminUser.lastName], + }, + { + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + teachers: [teacherUser.lastName], + }, + ], + skip: 0, + limit: 2, + }); + }); + }); + + describe('when an invalid user requests a list of classes', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + }; + }; + + it('should return forbidden', async () => { + const { studentClient } = await setup(); + + const response = await studentClient.get(`/class`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/controller/dto/index.ts b/apps/server/src/modules/group/controller/dto/index.ts new file mode 100644 index 00000000000..a05175977f8 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/index.ts @@ -0,0 +1,2 @@ +export * from './request'; +export * from './response'; diff --git a/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts new file mode 100644 index 00000000000..094f7efece4 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SortingParams } from '@shared/controller'; +import { IsEnum, IsOptional } from 'class-validator'; + +export enum ClassSortBy { + NAME = 'name', + EXTERNAL_SOURCE_NAME = 'externalSourceName', +} + +export class ClassSortParams extends SortingParams { + @IsOptional() + @IsEnum(ClassSortBy) + @ApiPropertyOptional({ enum: ClassSortBy }) + sortBy?: ClassSortBy; +} diff --git a/apps/server/src/modules/group/controller/dto/request/index.ts b/apps/server/src/modules/group/controller/dto/request/index.ts new file mode 100644 index 00000000000..2255e9aac09 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/index.ts @@ -0,0 +1 @@ +export * from './class-sort-params'; diff --git a/apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts b/apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts new file mode 100644 index 00000000000..0af573f1b94 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/class-info-search-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { ClassInfoResponse } from './class-info.response'; + +export class ClassInfoSearchListResponse extends PaginationResponse { + constructor(data: ClassInfoResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [ClassInfoResponse] }) + data: ClassInfoResponse[]; +} diff --git a/apps/server/src/modules/group/controller/dto/response/class-info.response.ts b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts new file mode 100644 index 00000000000..a2d71333c04 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ClassInfoResponse { + @ApiProperty() + name: string; + + @ApiPropertyOptional() + externalSourceName?: string; + + @ApiProperty({ type: [String] }) + teachers: string[]; + + constructor(props: ClassInfoResponse) { + this.name = props.name; + this.externalSourceName = props.externalSourceName; + this.teachers = props.teachers; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/index.ts b/apps/server/src/modules/group/controller/dto/response/index.ts new file mode 100644 index 00000000000..1ec8a62f0d4 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/index.ts @@ -0,0 +1,2 @@ +export * from './class-info.response'; +export * from './class-info-search-list.response'; diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts new file mode 100644 index 00000000000..e810e200d85 --- /dev/null +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -0,0 +1,46 @@ +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'; +import { ErrorResponse } from '@src/core/error/dto'; +import { ICurrentUser } from '@src/modules/authentication'; +import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { GroupUc } from '../uc'; +import { ClassInfoDto } from '../uc/dto'; +import { ClassInfoSearchListResponse, ClassSortParams } from './dto'; +import { GroupResponseMapper } from './mapper'; + +@ApiTags('Group') +@Authenticate('jwt') +@Controller('groups') +export class GroupController { + constructor(private readonly groupUc: GroupUc) {} + + @ApiOperation({ summary: 'Get a list of classes and groups of type class for the current users school.' }) + @ApiResponse({ status: HttpStatus.OK, type: ClassInfoSearchListResponse }) + @ApiResponse({ status: '4XX', type: ErrorResponse }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + @Get('/class') + public async findClassesForSchool( + @Query() pagination: PaginationParams, + @Query() sortingQuery: ClassSortParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const board: Page = await this.groupUc.findAllClassesForSchool( + currentUser.userId, + currentUser.schoolId, + pagination.skip, + pagination.limit, + sortingQuery.sortBy, + sortingQuery.sortOrder + ); + + const response: ClassInfoSearchListResponse = GroupResponseMapper.mapToClassInfosToListResponse( + board, + pagination.skip, + pagination.limit + ); + + return response; + } +} diff --git a/apps/server/src/modules/group/controller/index.ts b/apps/server/src/modules/group/controller/index.ts new file mode 100644 index 00000000000..daf2953fe71 --- /dev/null +++ b/apps/server/src/modules/group/controller/index.ts @@ -0,0 +1 @@ +export * from './group.controller'; diff --git a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts new file mode 100644 index 00000000000..6fbb0c6dc65 --- /dev/null +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -0,0 +1,34 @@ +import { Page } from '@shared/domain'; +import { ClassInfoDto } from '../../uc/dto'; +import { ClassInfoResponse, ClassInfoSearchListResponse } from '../dto'; + +export class GroupResponseMapper { + static mapToClassInfosToListResponse( + classInfos: Page, + skip?: number, + limit?: number + ): ClassInfoSearchListResponse { + const mappedData: ClassInfoResponse[] = classInfos.data.map((classInfo) => + this.mapToClassInfoToResponse(classInfo) + ); + + const response: ClassInfoSearchListResponse = new ClassInfoSearchListResponse( + mappedData, + classInfos.total, + skip, + limit + ); + + return response; + } + + private static mapToClassInfoToResponse(classInfo: ClassInfoDto): ClassInfoResponse { + const mapped = new ClassInfoResponse({ + name: classInfo.name, + externalSourceName: classInfo.externalSourceName, + teachers: classInfo.teachers, + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/group/controller/mapper/index.ts b/apps/server/src/modules/group/controller/mapper/index.ts new file mode 100644 index 00000000000..8dbc461623b --- /dev/null +++ b/apps/server/src/modules/group/controller/mapper/index.ts @@ -0,0 +1 @@ +export * from './group-response.mapper'; diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 8ebd8b7ab04..cbc5a416ffe 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -21,4 +21,20 @@ export interface GroupProps extends AuthorizableObject { organizationId?: string; } -export class Group extends DomainObject {} +export class Group extends DomainObject { + get name(): string { + return this.props.name; + } + + get users(): GroupUser[] { + return this.props.users; + } + + get externalSource(): ExternalSource | undefined { + return this.props.externalSource; + } + + get organizationId(): string | undefined { + return this.props.organizationId; + } +} diff --git a/apps/server/src/modules/group/group-api.module.ts b/apps/server/src/modules/group/group-api.module.ts index 1be422855a9..913fb2ef903 100644 --- a/apps/server/src/modules/group/group-api.module.ts +++ b/apps/server/src/modules/group/group-api.module.ts @@ -1,7 +1,17 @@ import { Module } from '@nestjs/common'; +import { AuthorizationModule } from '@src/modules/authorization'; +import { ClassModule } from '@src/modules/class'; +import { RoleModule } from '@src/modules/role'; +import { LegacySchoolModule } from '@src/modules/legacy-school'; +import { SystemModule } from '@src/modules/system'; +import { UserModule } from '@src/modules/user'; +import { GroupController } from './controller'; import { GroupModule } from './group.module'; +import { GroupUc } from './uc'; @Module({ - imports: [GroupModule], + imports: [GroupModule, ClassModule, UserModule, RoleModule, LegacySchoolModule, AuthorizationModule, SystemModule], + controllers: [GroupController], + providers: [GroupUc], }) export class GroupApiModule {} diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index a7c7454dae4..6b7c9daf741 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,10 +1,10 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalSource } from '@shared/domain'; +import { ExternalSource, SchoolEntity } from '@shared/domain'; import { MongoMemoryDatabaseModule } from '@shared/infra/database'; -import { cleanupCollections, groupEntityFactory, groupFactory } from '@shared/testing'; +import { cleanupCollections, groupEntityFactory, groupFactory, schoolFactory } from '@shared/testing'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; -import { GroupEntity } from '../entity'; +import { GroupEntity, GroupEntityTypes } from '../entity'; import { GroupRepo } from './group.repo'; describe('GroupRepo', () => { @@ -82,6 +82,70 @@ describe('GroupRepo', () => { }); }); + describe('findClassesForSchool', () => { + describe('when groups of type class for the school exist', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + type: GroupEntityTypes.CLASS, + organization: school, + }); + + const otherSchool: SchoolEntity = schoolFactory.buildWithId(); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { + type: GroupEntityTypes.CLASS, + organization: otherSchool, + }); + + await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups]); + em.clear(); + + return { + school, + otherSchool, + groups, + }; + }; + + it('should return the group', async () => { + const { school, groups } = await setup(); + + const result: Group[] = await repo.findClassesForSchool(school.id); + + expect(result).toHaveLength(groups.length); + }); + + it('should not return groups from another school', async () => { + const { school, otherSchool } = await setup(); + + const result: Group[] = await repo.findClassesForSchool(school.id); + + expect(result.map((group) => group.organizationId)).not.toContain(otherSchool.id); + }); + }); + + describe('when no group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + + await em.persistAndFlush(school); + em.clear(); + + return { + school, + }; + }; + + it('should return an empty array', async () => { + const { school } = await setup(); + + const result: Group[] = await repo.findClassesForSchool(school.id); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('save', () => { describe('when a new object is provided', () => { const setup = () => { diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index a5477908d6c..2c920b9a39d 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -2,14 +2,14 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { Group, GroupProps } from '../domain'; -import { GroupEntity, GroupEntityProps } from '../entity'; +import { GroupEntity, GroupEntityProps, GroupEntityTypes } from '../entity'; import { GroupDomainMapper } from './group-domain.mapper'; @Injectable() export class GroupRepo { constructor(private readonly em: EntityManager) {} - async findById(id: EntityId): Promise { + public async findById(id: EntityId): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id }); if (!entity) { @@ -23,7 +23,7 @@ export class GroupRepo { return domainObject; } - async findByExternalSource(externalId: string, systemId: EntityId): Promise { + public async findByExternalSource(externalId: string, systemId: EntityId): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { externalSource: { externalId, @@ -42,7 +42,22 @@ export class GroupRepo { return domainObject; } - async save(domainObject: Group): Promise { + public async findClassesForSchool(schoolId: EntityId): Promise { + const entities: GroupEntity[] = await this.em.find(GroupEntity, { + type: GroupEntityTypes.CLASS, + organization: schoolId, + }); + + const domainObjects = entities.map((entity) => { + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + return new Group(props); + }); + + return domainObjects; + } + + public async save(domainObject: Group): Promise { const entityProps: GroupEntityProps = GroupDomainMapper.mapDomainObjectToEntityProperties(domainObject, this.em); const newEntity: GroupEntity = new GroupEntity(entityProps); @@ -67,7 +82,7 @@ export class GroupRepo { return savedDomainObject; } - async delete(domainObject: Group): Promise { + public async delete(domainObject: Group): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id: domainObject.id }); if (!entity) { diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 3bcc8fa287e..71cc9eaeb6a 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { groupFactory } from '@shared/testing'; @@ -119,6 +120,38 @@ describe('GroupService', () => { }); }); + describe('findClassesForSchool', () => { + describe('when the school has groups of type class', () => { + const setup = () => { + const schoolId: string = new ObjectId().toHexString(); + const groups: Group[] = groupFactory.buildList(3); + + groupRepo.findClassesForSchool.mockResolvedValue(groups); + + return { + schoolId, + groups, + }; + }; + + it('should call the repo', async () => { + const { schoolId } = setup(); + + await service.findClassesForSchool(schoolId); + + expect(groupRepo.findClassesForSchool).toHaveBeenCalledWith(schoolId); + }); + + it('should return the groups', async () => { + const { schoolId, groups } = setup(); + + const result: Group[] = await service.findClassesForSchool(schoolId); + + expect(result).toEqual(groups); + }); + }); + }); + describe('save', () => { describe('when saving a group', () => { const setup = () => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index 030f3eb6685..dcba9377de3 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -9,7 +9,7 @@ import { GroupRepo } from '../repo'; export class GroupService implements AuthorizationLoaderServiceGeneric { constructor(private readonly groupRepo: GroupRepo) {} - async findById(id: EntityId): Promise { + public async findById(id: EntityId): Promise { const group: Group | null = await this.groupRepo.findById(id); if (!group) { @@ -19,25 +19,31 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } - async findByExternalSource(externalId: string, systemId: EntityId): Promise { + public async tryFindById(id: EntityId): Promise { + const group: Group | null = await this.groupRepo.findById(id); + + return group; + } + + public async findByExternalSource(externalId: string, systemId: EntityId): Promise { const group: Group | null = await this.groupRepo.findByExternalSource(externalId, systemId); return group; } - async tryFindById(id: EntityId): Promise { - const group: Group | null = await this.groupRepo.findById(id); + public async findClassesForSchool(schoolId: EntityId): Promise { + const group: Group[] = await this.groupRepo.findClassesForSchool(schoolId); return group; } - async save(group: Group): Promise { + public async save(group: Group): Promise { const savedGroup: Group = await this.groupRepo.save(group); return savedGroup; } - async delete(group: Group): Promise { + public async delete(group: Group): Promise { await this.groupRepo.delete(group); } } diff --git a/apps/server/src/modules/group/uc/dto/class-info.dto.ts b/apps/server/src/modules/group/uc/dto/class-info.dto.ts new file mode 100644 index 00000000000..0d2b5adaf68 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/class-info.dto.ts @@ -0,0 +1,13 @@ +export class ClassInfoDto { + name: string; + + externalSourceName?: string; + + teachers: string[]; + + constructor(props: ClassInfoDto) { + this.name = props.name; + this.externalSourceName = props.externalSourceName; + this.teachers = props.teachers; + } +} diff --git a/apps/server/src/modules/group/uc/dto/index.ts b/apps/server/src/modules/group/uc/dto/index.ts new file mode 100644 index 00000000000..389a31da162 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/index.ts @@ -0,0 +1,2 @@ +export * from './class-info.dto'; +export * from './resolved-group-user'; diff --git a/apps/server/src/modules/group/uc/dto/resolved-group-user.ts b/apps/server/src/modules/group/uc/dto/resolved-group-user.ts new file mode 100644 index 00000000000..862abdba594 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/resolved-group-user.ts @@ -0,0 +1,13 @@ +import { UserDO } from '@shared/domain'; +import { RoleDto } from '@src/modules/role/service/dto/role.dto'; + +export class ResolvedGroupUser { + user: UserDO; + + role: RoleDto; + + constructor(props: ResolvedGroupUser) { + this.user = props.user; + this.role = props.role; + } +} diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts new file mode 100644 index 00000000000..b4115d3739b --- /dev/null +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -0,0 +1,309 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo, Page, Permission, SortOrder, User, UserDO } from '@shared/domain'; +import { + groupFactory, + legacySchoolDoFactory, + roleDtoFactory, + setupEntities, + UserAndAccountTestFactory, + userDoFactory, + userFactory, +} from '@shared/testing'; +import { Action, AuthorizationContext, AuthorizationService } from '@src/modules/authorization'; +import { ClassService } from '@src/modules/class'; +import { Class } from '@src/modules/class/domain'; +import { classFactory } from '@src/modules/class/domain/testing/factory/class.factory'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +import { RoleService } from '@src/modules/role'; +import { RoleDto } from '@src/modules/role/service/dto/role.dto'; +import { SystemDto, SystemService } from '@src/modules/system'; +import { UserService } from '@src/modules/user'; +import { Group } from '../domain'; +import { GroupService } from '../service'; +import { ClassInfoDto } from './dto'; +import { GroupUc } from './group.uc'; + +describe('GroupUc', () => { + let module: TestingModule; + let uc: GroupUc; + + let groupService: DeepMocked; + let classService: DeepMocked; + let systemService: DeepMocked; + let userService: DeepMocked; + let roleService: DeepMocked; + let schoolService: DeepMocked; + let authorizationService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + GroupUc, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: ClassService, + useValue: createMock(), + }, + { + provide: SystemService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(GroupUc); + groupService = module.get(GroupService); + classService = module.get(ClassService); + systemService = module.get(SystemService); + userService = module.get(UserService); + roleService = module.get(RoleService); + schoolService = module.get(LegacySchoolService); + authorizationService = module.get(AuthorizationService); + + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findClassesForSchool', () => { + describe('when the user has no permission', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + error, + }; + }; + + it('should throw forbidden', async () => { + const { user, error } = setup(); + + const func = () => uc.findAllClassesForSchool(user.id, user.school.id); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when the school has classes', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + const { studentUser } = UserAndAccountTestFactory.buildStudent(); + const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const teacherRole: RoleDto = roleDtoFactory.buildWithId({ + id: teacherUser.roles[0].id, + name: teacherUser.roles[0].name, + }); + const studentRole: RoleDto = roleDtoFactory.buildWithId({ + id: studentUser.roles[0].id, + name: studentUser.roles[0].name, + }); + const teacherUserDo: UserDO = userDoFactory.buildWithId({ + id: teacherUser.id, + lastName: teacherUser.lastName, + roles: [{ id: teacherUser.roles[0].id, name: teacherUser.roles[0].name }], + }); + const studentUserDo: UserDO = userDoFactory.buildWithId({ + id: studentUser.id, + lastName: studentUser.lastName, + roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }], + }); + const clazz: Class = classFactory.build({ name: 'A', teacherIds: [teacherUser.id], source: 'LDAP' }); + const system: SystemDto = new SystemDto({ + id: new ObjectId().toHexString(), + displayName: 'External System', + type: 'oauth2', + }); + const group: Group = groupFactory.build({ + name: 'B', + users: [{ userId: teacherUser.id, roleId: teacherUser.roles[0].id }], + externalSource: undefined, + }); + const groupWithSystem: Group = groupFactory.build({ + name: 'C', + externalSource: { externalId: 'externalId', systemId: system.id }, + users: [ + { userId: teacherUser.id, roleId: teacherUser.roles[0].id }, + { userId: studentUser.id, roleId: studentUser.roles[0].id }, + ], + }); + + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); + classService.findClassesForSchool.mockResolvedValueOnce([clazz]); + groupService.findClassesForSchool.mockResolvedValueOnce([group, groupWithSystem]); + systemService.findById.mockResolvedValue(system); + userService.findById.mockImplementation((userId: string): Promise => { + if (userId === teacherUser.id) { + return Promise.resolve(teacherUserDo); + } + + if (userId === studentUser.id) { + return Promise.resolve(studentUserDo); + } + + throw new Error(); + }); + roleService.findById.mockImplementation((roleId: string): Promise => { + if (roleId === teacherUser.roles[0].id) { + return Promise.resolve(teacherRole); + } + + if (roleId === studentUser.roles[0].id) { + return Promise.resolve(studentRole); + } + + throw new Error(); + }); + + return { + teacherUser, + school, + clazz, + group, + groupWithSystem, + system, + }; + }; + + it('should check the CLASS_LIST permission', async () => { + const { teacherUser, school } = setup(); + + await uc.findAllClassesForSchool(teacherUser.id, teacherUser.school.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith<[User, LegacySchoolDo, AuthorizationContext]>( + teacherUser, + school, + { + action: Action.read, + requiredPermissions: [Permission.CLASS_LIST], + } + ); + }); + + describe('when no pagination is given', () => { + it('should return all classes sorted by name', async () => { + const { teacherUser, clazz, group, groupWithSystem, system } = setup(); + + const result: Page = await uc.findAllClassesForSchool(teacherUser.id, teacherUser.school.id); + + expect(result).toEqual>({ + data: [ + { + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + externalSourceName: clazz.source, + teachers: [teacherUser.lastName], + }, + { + name: group.name, + teachers: [teacherUser.lastName], + }, + { + name: groupWithSystem.name, + externalSourceName: system.displayName, + teachers: [teacherUser.lastName], + }, + ], + total: 3, + }); + }); + }); + + describe('when sorting by external source name in descending order', () => { + it('should return all classes sorted by external source name in descending order', async () => { + const { teacherUser, clazz, group, groupWithSystem, system } = setup(); + + const result: Page = await uc.findAllClassesForSchool( + teacherUser.id, + teacherUser.school.id, + undefined, + undefined, + 'externalSourceName', + SortOrder.desc + ); + + expect(result).toEqual>({ + data: [ + { + name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, + externalSourceName: clazz.source, + teachers: [teacherUser.lastName], + }, + { + name: groupWithSystem.name, + externalSourceName: system.displayName, + teachers: [teacherUser.lastName], + }, + { + name: group.name, + teachers: [teacherUser.lastName], + }, + ], + total: 3, + }); + }); + }); + + describe('when using pagination', () => { + it('should return the selected page', async () => { + const { teacherUser, group } = setup(); + + const result: Page = await uc.findAllClassesForSchool( + teacherUser.id, + teacherUser.school.id, + 1, + 1, + 'name', + SortOrder.asc + ); + + expect(result).toEqual>({ + data: [ + { + name: group.name, + teachers: [teacherUser.lastName], + }, + ], + total: 3, + }); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts new file mode 100644 index 00000000000..a179b8cb352 --- /dev/null +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId, LegacySchoolDo, Page, Permission, SortOrder, User, UserDO } from '@shared/domain'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; +import { ClassService } from '@src/modules/class'; +import { Class } from '@src/modules/class/domain'; +import { LegacySchoolService } from '@src/modules/legacy-school'; +import { RoleService } from '@src/modules/role'; +import { RoleDto } from '@src/modules/role/service/dto/role.dto'; +import { SystemDto, SystemService } from '@src/modules/system'; +import { UserService } from '@src/modules/user'; +import { Group, GroupUser } from '../domain'; +import { GroupService } from '../service'; +import { SortHelper } from '../util'; +import { ClassInfoDto, ResolvedGroupUser } from './dto'; +import { GroupUcMapper } from './mapper/group-uc.mapper'; + +@Injectable() +export class GroupUc { + constructor( + private readonly groupService: GroupService, + private readonly classService: ClassService, + private readonly systemService: SystemService, + private readonly userService: UserService, + private readonly roleService: RoleService, + private readonly schoolService: LegacySchoolService, + private readonly authorizationService: AuthorizationService + ) {} + + public async findAllClassesForSchool( + userId: EntityId, + schoolId: EntityId, + skip = 0, + limit?: number, + sortBy: keyof ClassInfoDto = 'name', + sortOrder: SortOrder = SortOrder.asc + ): Promise> { + const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, school, AuthorizationContextBuilder.read([Permission.CLASS_LIST])); + + const combinedClassInfo: ClassInfoDto[] = await this.findCombinedClassListForSchool(schoolId); + + combinedClassInfo.sort((a: ClassInfoDto, b: ClassInfoDto): number => + SortHelper.genericSortFunction(a[sortBy], b[sortBy], sortOrder) + ); + + const pageContent: ClassInfoDto[] = this.applyPagination(combinedClassInfo, skip, limit); + + const page: Page = new Page(pageContent, combinedClassInfo.length); + + return page; + } + + private async findCombinedClassListForSchool(schoolId: string): Promise { + const [classInfosFromClasses, classInfosFromGroups] = await Promise.all([ + await this.findClassesForSchool(schoolId), + await this.findGroupsOfTypeClassForSchool(schoolId), + ]); + + const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; + + return combinedClassInfo; + } + + private async findClassesForSchool(schoolId: EntityId): Promise { + const classes: Class[] = await this.classService.findClassesForSchool(schoolId); + + const classInfosFromClasses: ClassInfoDto[] = await Promise.all( + classes.map(async (clazz: Class): Promise => { + const teachers: UserDO[] = await Promise.all( + clazz.teacherIds.map((teacherId: EntityId) => this.userService.findById(teacherId)) + ); + + const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto(clazz, teachers); + + return mapped; + }) + ); + + return classInfosFromClasses; + } + + private async findGroupsOfTypeClassForSchool(schoolId: EntityId): Promise { + const groupsOfTypeClass: Group[] = await this.groupService.findClassesForSchool(schoolId); + + const systemMap: Map = await this.findSystemNamesForGroups(groupsOfTypeClass); + + const classInfosFromGroups: ClassInfoDto[] = await Promise.all( + groupsOfTypeClass.map(async (group: Group): Promise => { + let system: SystemDto | undefined; + if (group.externalSource) { + system = systemMap.get(group.externalSource.systemId); + } + + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + + const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto(group, resolvedUsers, system); + + return mapped; + }) + ); + + return classInfosFromGroups; + } + + private async findSystemNamesForGroups(groups: Group[]): Promise> { + const systemIds: EntityId[] = groups + .map((group: Group): string | undefined => group.externalSource?.systemId) + .filter((systemId: string | undefined): systemId is EntityId => systemId !== undefined); + + const uniqueSystemIds: EntityId[] = Array.from(new Set(systemIds)); + + const systems: Map = new Map(); + + await Promise.all( + uniqueSystemIds.map(async (systemId: string) => { + const system: SystemDto = await this.systemService.findById(systemId); + + systems.set(systemId, system); + }) + ); + + return systems; + } + + private async findUsersForGroup(group: Group): Promise { + const resolvedGroupUsers: ResolvedGroupUser[] = await Promise.all( + group.users.map(async (groupUser: GroupUser): Promise => { + const user: UserDO = await this.userService.findById(groupUser.userId); + const role: RoleDto = await this.roleService.findById(groupUser.roleId); + + const resolvedGroups = new ResolvedGroupUser({ + user, + role, + }); + + return resolvedGroups; + }) + ); + + return resolvedGroupUsers; + } + + private applyPagination(combinedClassInfo: ClassInfoDto[], skip: number, limit: number | undefined) { + const page: ClassInfoDto[] = combinedClassInfo.slice(skip, limit ? skip + limit : combinedClassInfo.length); + + return page; + } +} diff --git a/apps/server/src/modules/group/uc/index.ts b/apps/server/src/modules/group/uc/index.ts new file mode 100644 index 00000000000..3f268fdf74c --- /dev/null +++ b/apps/server/src/modules/group/uc/index.ts @@ -0,0 +1 @@ +export * from './group.uc'; diff --git a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts new file mode 100644 index 00000000000..1e1f11057ce --- /dev/null +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -0,0 +1,35 @@ +import { RoleName, UserDO } from '@shared/domain'; +import { Class } from '@src/modules/class/domain'; +import { SystemDto } from '@src/modules/system'; +import { Group } from '../../domain'; +import { ClassInfoDto, ResolvedGroupUser } from '../dto'; + +export class GroupUcMapper { + public static mapGroupToClassInfoDto( + group: Group, + resolvedUsers: ResolvedGroupUser[], + system?: SystemDto + ): ClassInfoDto { + const mapped: ClassInfoDto = new ClassInfoDto({ + name: group.name, + externalSourceName: system?.displayName, + teachers: resolvedUsers + .filter((groupUser: ResolvedGroupUser) => groupUser.role.name === RoleName.TEACHER) + .map((groupUser: ResolvedGroupUser) => groupUser.user.lastName), + }); + + return mapped; + } + + public static mapClassToClassInfoDto(clazz: Class, teachers: UserDO[]): ClassInfoDto { + const name = clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name; + + const mapped: ClassInfoDto = new ClassInfoDto({ + name, + externalSourceName: clazz.source, + teachers: teachers.map((user: UserDO) => user.lastName), + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/group/util/index.ts b/apps/server/src/modules/group/util/index.ts new file mode 100644 index 00000000000..f8413f66d6e --- /dev/null +++ b/apps/server/src/modules/group/util/index.ts @@ -0,0 +1 @@ +export * from './sort-helper'; diff --git a/apps/server/src/modules/group/util/sort-helper.spec.ts b/apps/server/src/modules/group/util/sort-helper.spec.ts new file mode 100644 index 00000000000..f4c738a3c65 --- /dev/null +++ b/apps/server/src/modules/group/util/sort-helper.spec.ts @@ -0,0 +1,70 @@ +import { SortOrder } from '@shared/domain'; +import { SortHelper } from './sort-helper'; + +describe('SortHelper', () => { + describe('genericSortFunction', () => { + describe('when a is defined and b is undefined', () => { + it('should return more than 0', () => { + const result: number = SortHelper.genericSortFunction(1, undefined, SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when a is undefined and b is defined', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(undefined, 1, SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when a and b are both undefined', () => { + it('should return 0', () => { + const result: number = SortHelper.genericSortFunction(undefined, undefined, SortOrder.asc); + + expect(result).toEqual(0); + }); + }); + + describe('when a is a greater number than b', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction(2, 1, SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when b is a greater number than a', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(1, 2, SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when a is later in the alphabet as b', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction('B', 'A', SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when b is later in the alphabet as a', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction('A', 'B', SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when a is greater than b, but the order is reversed', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(2, 1, SortOrder.desc); + + expect(result).toBeLessThan(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/util/sort-helper.ts b/apps/server/src/modules/group/util/sort-helper.ts new file mode 100644 index 00000000000..099c726403e --- /dev/null +++ b/apps/server/src/modules/group/util/sort-helper.ts @@ -0,0 +1,21 @@ +import { SortOrder } from '@shared/domain'; + +export class SortHelper { + public static genericSortFunction(a: T, b: T, sortOrder: SortOrder): number { + let order: number; + + if (typeof a !== 'undefined' && typeof b === 'undefined') { + order = 1; + } else if (typeof a === 'undefined' && typeof b !== 'undefined') { + order = -1; + } else if (typeof a === 'string' && typeof b === 'string') { + order = a.localeCompare(b); + } else if (typeof a === 'number' && typeof b === 'number') { + order = a - b; + } else { + order = 0; + } + + return sortOrder === SortOrder.desc ? -order : order; + } +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index d199bfce8e6..902e2e850f2 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -166,7 +166,7 @@ describe('SanisResponseMapper', () => { expect(result![0]).toEqual({ name: group.gruppe.bezeichnung, type: GroupTypes.CLASS, - externalOrganizationId: group.gruppe.orgid, + externalOrganizationId: personenkontext.organisation.id, from: group.gruppe.laufzeit.von, until: group.gruppe.laufzeit.bis, externalId: group.gruppe.id, diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 3a0d340427e..34a7ff4029c 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -92,15 +92,17 @@ export class SanisResponseMapper { .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) .filter((user): user is ExternalGroupUserDto => user !== null); - return { + const externalOrganizationId = source.personenkontexte[0].organisation?.id; + + return new ExternalGroupDto({ name: group.gruppe.bezeichnung, type: groupType, - externalOrganizationId: group.gruppe.orgid, + externalOrganizationId, from: group.gruppe.laufzeit?.von, until: group.gruppe.laufzeit?.bis, externalId: group.gruppe.id, users: gruppenzugehoerigkeiten, - }; + }); }) .filter((group): group is ExternalGroupDto => group !== null); diff --git a/apps/server/src/shared/testing/factory/user.factory.ts b/apps/server/src/shared/testing/factory/user.factory.ts index b79d4c739ff..1557b3ccd35 100644 --- a/apps/server/src/shared/testing/factory/user.factory.ts +++ b/apps/server/src/shared/testing/factory/user.factory.ts @@ -22,7 +22,7 @@ class UserFactory extends BaseFactory { asStudent(additionalPermissions: Permission[] = []): this { const permissions = _.union(userPermissions, studentPermissions, additionalPermissions); - const role = roleFactory.buildWithId({ permissions }); + const role = roleFactory.buildWithId({ permissions, name: RoleName.STUDENT }); const params: DeepPartial = { roles: [role] }; @@ -31,7 +31,7 @@ class UserFactory extends BaseFactory { asTeacher(additionalPermissions: Permission[] = []): this { const permissions = _.union(userPermissions, teacherPermissions, additionalPermissions); - const role = roleFactory.buildWithId({ permissions }); + const role = roleFactory.buildWithId({ permissions, name: RoleName.TEACHER }); const params: DeepPartial = { roles: [role] }; @@ -40,7 +40,7 @@ class UserFactory extends BaseFactory { asAdmin(additionalPermissions: Permission[] = []): this { const permissions = _.union(userPermissions, adminPermissions, additionalPermissions); - const role = roleFactory.buildWithId({ permissions }); + const role = roleFactory.buildWithId({ permissions, name: RoleName.ADMINISTRATOR }); const params: DeepPartial = { roles: [role] }; diff --git a/config/default.schema.json b/config/default.schema.json index d986b4ffdf9..9103cfd7d4f 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1288,6 +1288,11 @@ } } }, + "FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the new class list view" + }, "TSP_SCHOOL_SYNCER": { "type": "object", "description": "TSP School Syncer properties", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index b6452220088..31a3ae22224 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -62,6 +62,7 @@ const exposedVars = [ 'FEATURE_SHOW_OUTDATED_USERS', 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', + 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', ]; /**