diff --git a/apps/server/src/modules/group/domain/group-aggregate.scope.spec.ts b/apps/server/src/modules/group/domain/group-aggregate.scope.spec.ts new file mode 100644 index 00000000000..bd37cd922fd --- /dev/null +++ b/apps/server/src/modules/group/domain/group-aggregate.scope.spec.ts @@ -0,0 +1,191 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { GroupEntityTypes } from '../entity'; +import { GroupAggregateScope } from './group-aggregate.scope'; +import { GroupTypes } from './group-types'; +import { GroupVisibilityPermission } from './group-visibility-permission.enum'; + +describe(GroupAggregateScope.name, () => { + const defaultFacetQuery = { + $facet: { + data: [{ $skip: 0 }], + total: [{ $count: 'count' }], + }, + }; + + describe('byUserPermission', () => { + describe('when the user is allowed to see all groups of a school', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toString(); + + return { + userId, + schoolId, + }; + }; + + it('should build the correct query', () => { + const { userId, schoolId } = setup(); + + const result = new GroupAggregateScope() + .byUserPermission(userId, schoolId, GroupVisibilityPermission.ALL_SCHOOL_GROUPS) + .build(); + + expect(result).toEqual([{ $match: { organization: new ObjectId(schoolId) } }, defaultFacetQuery]); + }); + }); + + describe('when the user is allowed to see his own groups and all classes of a school', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + + return { + userId, + schoolId, + }; + }; + + it('should build the correct query', () => { + const { userId, schoolId } = setup(); + + const result = new GroupAggregateScope() + .byUserPermission(userId, schoolId, GroupVisibilityPermission.ALL_SCHOOL_CLASSES) + .build(); + + expect(result).toEqual([ + { + $match: { + $or: [ + { organization: new ObjectId(schoolId), type: GroupEntityTypes.CLASS }, + { users: { $elemMatch: { user: new ObjectId(userId) } } }, + ], + }, + }, + defaultFacetQuery, + ]); + }); + }); + + describe('when the user is allowed to only see his own groups', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const schoolId = new ObjectId().toHexString(); + + return { + userId, + schoolId, + }; + }; + + it('should build the correct query', () => { + const { userId, schoolId } = setup(); + + const result = new GroupAggregateScope() + .byUserPermission(userId, schoolId, GroupVisibilityPermission.OWN_GROUPS) + .build(); + + expect(result).toEqual([ + { $match: { users: { $elemMatch: { user: new ObjectId(userId) } } } }, + defaultFacetQuery, + ]); + }); + }); + }); + + describe('byAvailableForSync', () => { + describe('when filtering for groups that are available for a course synchronization', () => { + it('should build the correct query', () => { + const result = new GroupAggregateScope().byAvailableForSync(true).build(); + + expect(result).toEqual([ + { + $lookup: { + from: 'courses', + localField: '_id', + foreignField: 'syncedWithGroup', + as: 'syncedCourses', + }, + }, + { + $match: { + $or: [{ syncedCourses: { $size: 0 } }, { type: { $eq: GroupTypes.CLASS } }], + }, + }, + defaultFacetQuery, + ]); + }); + }); + + describe('when no value was given', () => { + it('should not include the query in the result', () => { + const result = new GroupAggregateScope().byAvailableForSync(undefined).build(); + + expect(result).toEqual([defaultFacetQuery]); + }); + }); + }); + + describe('byOrganization', () => { + describe('when filtering for an organization of a group', () => { + it('should build the correct query', () => { + const schoolId = new ObjectId().toHexString(); + + const result = new GroupAggregateScope().byOrganization(schoolId).build(); + + expect(result).toEqual([{ $match: { organization: new ObjectId(schoolId) } }, defaultFacetQuery]); + }); + }); + + describe('when no value was given', () => { + it('should not include the query in the result', () => { + const result = new GroupAggregateScope().byOrganization(undefined).build(); + + expect(result).toEqual([defaultFacetQuery]); + }); + }); + }); + + describe('byUser', () => { + describe('when filtering for a group user', () => { + it('should build the correct query', () => { + const userId = new ObjectId().toHexString(); + + const result = new GroupAggregateScope().byUser(userId).build(); + + expect(result).toEqual([ + { $match: { users: { $elemMatch: { user: new ObjectId(userId) } } } }, + defaultFacetQuery, + ]); + }); + }); + + describe('when no value was given', () => { + it('should not include the query in the result', () => { + const result = new GroupAggregateScope().byUser(undefined).build(); + + expect(result).toEqual([defaultFacetQuery]); + }); + }); + }); + + describe('byName', () => { + describe('when filtering for a group name', () => { + it('should build the correct query', () => { + const testName = 'testGroup'; + + const result = new GroupAggregateScope().byName(testName).build(); + + expect(result).toEqual([{ $match: { name: { $regex: testName, $options: 'i' } } }, defaultFacetQuery]); + }); + }); + + describe('when no value was given', () => { + it('should not include the query in the result', () => { + const result = new GroupAggregateScope().byName(undefined).build(); + + expect(result).toEqual([defaultFacetQuery]); + }); + }); + }); +}); diff --git a/apps/server/src/modules/group/domain/group-aggregate.scope.ts b/apps/server/src/modules/group/domain/group-aggregate.scope.ts new file mode 100644 index 00000000000..684836e34a6 --- /dev/null +++ b/apps/server/src/modules/group/domain/group-aggregate.scope.ts @@ -0,0 +1,78 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { StringValidator } from '@shared/common'; +import { EntityId } from '@shared/domain/types'; +import { MongoDbScope, MongoPatterns } from '@shared/repo'; +import { GroupEntity, GroupEntityTypes } from '../entity'; +import { GroupTypes } from './group-types'; +import { GroupVisibilityPermission } from './group-visibility-permission.enum'; + +export class GroupAggregateScope extends MongoDbScope { + byUserPermission(userId: EntityId, schoolId: EntityId, permission: GroupVisibilityPermission): this { + if (permission === GroupVisibilityPermission.ALL_SCHOOL_GROUPS) { + this.byOrganization(schoolId); + } else if (permission === GroupVisibilityPermission.ALL_SCHOOL_CLASSES) { + this.pipeline.push({ + $match: { + $or: [ + { organization: new ObjectId(schoolId), type: GroupEntityTypes.CLASS }, + { users: { $elemMatch: { user: new ObjectId(userId) } } }, + ], + }, + }); + } else { + this.byUser(userId); + } + + return this; + } + + byAvailableForSync(value: boolean | undefined): this { + if (value) { + this.pipeline.push( + { + $lookup: { + from: 'courses', + localField: '_id', + foreignField: 'syncedWithGroup', + as: 'syncedCourses', + }, + }, + { + $match: { + $or: [{ syncedCourses: { $size: 0 } }, { type: { $eq: GroupTypes.CLASS } }], + }, + } + ); + } + + return this; + } + + byUser(id: EntityId | undefined): this { + if (id) { + this.pipeline.push({ $match: { users: { $elemMatch: { user: new ObjectId(id) } } } }); + } + + return this; + } + + byOrganization(id: EntityId | undefined): this { + if (id) { + this.pipeline.push({ $match: { organization: new ObjectId(id) } }); + } + + return this; + } + + byName(nameQuery: string | undefined): this { + const escapedName: string | undefined = nameQuery + ?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '') + .trim(); + + if (StringValidator.isNotEmptyString(escapedName, true)) { + this.pipeline.push({ $match: { name: { $regex: escapedName, $options: 'i' } } }); + } + + return this; + } +} diff --git a/apps/server/src/modules/group/domain/group-visibility-permission.enum.ts b/apps/server/src/modules/group/domain/group-visibility-permission.enum.ts new file mode 100644 index 00000000000..db888a11110 --- /dev/null +++ b/apps/server/src/modules/group/domain/group-visibility-permission.enum.ts @@ -0,0 +1,5 @@ +export enum GroupVisibilityPermission { + OWN_GROUPS, + ALL_SCHOOL_CLASSES, + ALL_SCHOOL_GROUPS, +} diff --git a/apps/server/src/modules/group/domain/index.ts b/apps/server/src/modules/group/domain/index.ts index adf5f691661..57ac15c6408 100644 --- a/apps/server/src/modules/group/domain/index.ts +++ b/apps/server/src/modules/group/domain/index.ts @@ -4,3 +4,5 @@ export { GroupPeriod } from './group-period'; export * from './group-types'; export { GroupDeletedEvent } from './event'; export { GroupFilter } from './interface'; +export { GroupAggregateScope } from './group-aggregate.scope'; +export { GroupVisibilityPermission } from './group-visibility-permission.enum'; 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 0b260ebfc6c..75ead126ce8 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -3,8 +3,8 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { type SystemEntity } from '@modules/system/entity'; import { Test, TestingModule } from '@nestjs/testing'; import { ExternalSource, Page } from '@shared/domain/domainobject'; -import { Course as CourseEntity, SchoolEntity, User } from '@shared/domain/entity'; -import { IFindOptions } from '@shared/domain/interface'; +import { SchoolEntity, User } from '@shared/domain/entity'; +import { RoleName, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { cleanupCollections, @@ -16,11 +16,11 @@ import { systemEntityFactory, userFactory, } from '@shared/testing'; -import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; +import { Group, GroupAggregateScope, GroupProps, GroupTypes, GroupUser, GroupVisibilityPermission } from '../domain'; import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable } from '../entity'; import { GroupRepo } from './group.repo'; -describe('GroupRepo', () => { +describe(GroupRepo.name, () => { let module: TestingModule; let repo: GroupRepo; let em: EntityManager; @@ -107,7 +107,7 @@ describe('GroupRepo', () => { groups[2].type = GroupEntityTypes.OTHER; groups[3].type = GroupEntityTypes.ROOM; - const nameQuery = groups[1].name.slice(-3); + const nameQuery = groups[2].name.slice(-3); const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); @@ -138,6 +138,7 @@ describe('GroupRepo', () => { expect(result.total).toEqual(groups.length); expect(result.data.length).toEqual(1); + // FIXME expect(result.data[0].id).toEqual(groups[1].id); MikroORM sorting does not work correctly (e.g. [10, 7, 8, 9]) }); it('should return groups according to name query', async () => { @@ -146,17 +147,15 @@ describe('GroupRepo', () => { const result: Page = await repo.findGroups({ userId, nameQuery }); expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[1].id); + expect(result.data[0].id).toEqual(groups[2].id); }); it('should return only groups of the given group types', async () => { const { userId } = await setup(); - const resultClass: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.CLASS] }); - expect(resultClass.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + const result: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); - const resultRoom: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); - expect(resultRoom.data).toEqual([expect.objectContaining>({ type: GroupTypes.ROOM })]); + expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.ROOM })]); }); }); @@ -372,182 +371,343 @@ describe('GroupRepo', () => { }); }); - describe('findAvailableGroups', () => { - describe('when the user has groups', () => { + describe('findGroupsForScope', () => { + describe('when using pagination and sorting', () => { const setup = async () => { - const userEntity: User = userFactory.buildWithId(); - const userId: EntityId = userEntity.id; - const groupUserEntity: GroupUserEmbeddable = new GroupUserEmbeddable({ - user: userEntity, - role: roleFactory.buildWithId(), - }); - const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { - users: [groupUserEntity], - }); + const groups = groupEntityFactory.buildListWithId(4); - const courseGroup = groupEntityFactory.buildWithId({ - users: [groupUserEntity], - type: GroupEntityTypes.COURSE, + const scope = new GroupAggregateScope({ + pagination: { skip: 1, limit: 2 }, + order: { name: SortOrder.desc }, }); - groups.push(courseGroup); - const nameQuery = groups[2].name.slice(-3); - const course: CourseEntity = courseFactory.build({ syncedWithGroup: groups[0] }); - const courseSyncedWithGroupOfTypeClass = courseFactory.build({ syncedWithGroup: groups[3] }); - const availableGroupsCount = 3; - const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); - - await em.persistAndFlush([userEntity, ...groups, ...otherGroups, course, courseSyncedWithGroupOfTypeClass]); + await em.persistAndFlush([...groups]); em.clear(); - const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; - return { - userId, + scope, groups, - availableGroupsCount, - nameQuery, - defaultOptions, }; }; - it('should return the available groups', async () => { - const { userId, availableGroupsCount, defaultOptions } = await setup(); + it('should return the correct groups', async () => { + const { groups, scope } = await setup(); - const result: Page = await repo.findAvailableGroups({ userId }, defaultOptions); + const result: Page = await repo.findGroupsForScope(scope); - expect(result.total).toEqual(availableGroupsCount); - expect(result.data.every((group) => group.users[0].userId === userId)).toEqual(true); + expect(result.total).toEqual(groups.length); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toEqual(groups[2].id); + expect(result.data[1].id).toEqual(groups[1].id); }); + }); - it('should return groups according to pagination', async () => { - const { userId, groups, availableGroupsCount } = await setup(); + describe('when searching by name', () => { + const setup = async () => { + const groups = groupEntityFactory.buildListWithId(3); - const result: Page = await repo.findAvailableGroups({ userId }, { pagination: { skip: 1, limit: 1 } }); + const scope = new GroupAggregateScope().byName(groups[1].name); - expect(result.total).toEqual(availableGroupsCount); - expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[1].id); - }); + await em.persistAndFlush([...groups]); + em.clear(); - it('should return groups according to name query', async () => { - const { userId, groups, nameQuery, defaultOptions } = await setup(); + return { + scope, + groups, + }; + }; - const result: Page = await repo.findAvailableGroups({ userId, nameQuery }, defaultOptions); + it('should return the groups with the selected name', async () => { + const { groups, scope } = await setup(); - expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[2].id); + const result: Page = await repo.findGroupsForScope(scope); + + expect(result.total).toEqual(1); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toEqual(groups[1].id); }); }); - describe('when the user has no groups exists', () => { + describe('when searching by organization', () => { const setup = async () => { - const userEntity: User = userFactory.buildWithId(); - const userId: EntityId = userEntity.id; + const school = schoolEntityFactory.buildWithId(); + const group = groupEntityFactory.buildWithId({ organization: school }); + const otherGroup = groupEntityFactory.buildWithId({ organization: schoolEntityFactory.buildWithId() }); - const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + const scope = new GroupAggregateScope().byOrganization(school.id); - await em.persistAndFlush([userEntity, ...otherGroups]); + await em.persistAndFlush([group, otherGroup, school]); em.clear(); - const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; - return { - userId, - defaultOptions, + scope, + group, + otherGroup, }; }; - it('should return an empty array', async () => { - const { userId, defaultOptions } = await setup(); + it('should return the groups with the selected school', async () => { + const { group, scope } = await setup(); - const result: Page = await repo.findAvailableGroups({ userId }, defaultOptions); + const result: Page = await repo.findGroupsForScope(scope); - expect(result.total).toEqual(0); + expect(result.total).toEqual(1); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toEqual(group.id); }); }); - describe('when available groups for the school exist', () => { + describe('when searching by user', () => { const setup = async () => { - const school: SchoolEntity = schoolEntityFactory.buildWithId(); - const schoolId: EntityId = school.id; - const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { - type: GroupEntityTypes.OTHER, - organization: school, + const user = userFactory.buildWithId(); + const group = groupEntityFactory.buildWithId({ + users: [ + new GroupUserEmbeddable({ + user, + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }), + ], }); - const nameQuery = groups[2].name.slice(-3); - const course: CourseEntity = courseFactory.build({ school, syncedWithGroup: groups[0] }); - const availableGroupsCount = 2; + const otherGroup = groupEntityFactory.buildWithId(); - const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); - const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { - type: GroupEntityTypes.OTHER, - organization: otherSchool, - }); + const scope = new GroupAggregateScope().byUser(user.id); - await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups, course]); + await em.persistAndFlush([group, otherGroup, user]); em.clear(); - const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; - return { - schoolId, - groups, - availableGroupsCount, - nameQuery, - defaultOptions, + scope, + group, }; }; - it('should return the available groups from selected school', async () => { - const { schoolId, availableGroupsCount, defaultOptions } = await setup(); + it('should return the groups with the selected user', async () => { + const { group, scope } = await setup(); - const result: Page = await repo.findAvailableGroups({ schoolId }, defaultOptions); + const result: Page = await repo.findGroupsForScope(scope); - expect(result.data).toHaveLength(availableGroupsCount); - expect(result.data.every((group) => group.organizationId === schoolId)).toEqual(true); + expect(result.total).toEqual(1); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toEqual(group.id); }); + }); - it('should return groups according to pagination', async () => { - const { schoolId, groups, availableGroupsCount } = await setup(); + describe('when searching for available groups for a course synchronization', () => { + describe('when the user only has permission to view this own groups', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); + const availableCourseGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.COURSE, + users: [ + new GroupUserEmbeddable({ + user, + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }), + ], + organization: school, + }); + const synchronizedCourseGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.COURSE, + users: [ + new GroupUserEmbeddable({ + user, + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }), + ], + organization: school, + }); + const courseSynchronizedWithCourseGroup = courseFactory.buildWithId({ + syncedWithGroup: synchronizedCourseGroup, + }); + + const synchronizedClassGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.CLASS, + users: [ + new GroupUserEmbeddable({ + user, + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }), + ], + organization: school, + }); + const courseSynchronizedWithClassGroup = courseFactory.buildWithId({ + syncedWithGroup: synchronizedClassGroup, + }); + + const otherGroup = groupEntityFactory.buildWithId(); + + const scope = new GroupAggregateScope() + .byUserPermission(user.id, school.id, GroupVisibilityPermission.OWN_GROUPS) + .byAvailableForSync(true); + + await em.persistAndFlush([ + availableCourseGroup, + synchronizedCourseGroup, + courseSynchronizedWithCourseGroup, + synchronizedClassGroup, + courseSynchronizedWithClassGroup, + otherGroup, + ]); + em.clear(); + + return { + scope, + availableCourseGroup, + synchronizedClassGroup, + }; + }; - const result: Page = await repo.findAvailableGroups({ schoolId }, { pagination: { skip: 1, limit: 1 } }); + it('should return the groups that are available and contain the user', async () => { + const { availableCourseGroup, synchronizedClassGroup, scope } = await setup(); - expect(result.total).toEqual(availableGroupsCount); - expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[1].id); + const result: Page = await repo.findGroupsForScope(scope); + + expect(result.total).toEqual(2); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toEqual(availableCourseGroup.id); + expect(result.data[1].id).toEqual(synchronizedClassGroup.id); + }); }); - it('should return groups according to name query', async () => { - const { schoolId, groups, nameQuery, defaultOptions } = await setup(); + describe('when the user has permission to view this own groups and all classes of the school', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); + const availableCourseGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.COURSE, + users: [ + new GroupUserEmbeddable({ + user, + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }), + ], + organization: school, + }); + const synchronizedCourseGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.COURSE, + users: [ + new GroupUserEmbeddable({ + user, + role: roleFactory.buildWithId({ name: RoleName.STUDENT }), + }), + ], + organization: school, + }); + const courseSynchronizedWithCourseGroup = courseFactory.buildWithId({ + syncedWithGroup: synchronizedCourseGroup, + }); + + const synchronizedClassGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.CLASS, + users: [], + organization: school, + }); + const courseSynchronizedWithClassGroup = courseFactory.buildWithId({ + syncedWithGroup: synchronizedClassGroup, + }); + + const otherGroup = groupEntityFactory.buildWithId(); + + const scope = new GroupAggregateScope() + .byUserPermission(user.id, school.id, GroupVisibilityPermission.ALL_SCHOOL_CLASSES) + .byAvailableForSync(true); + + await em.persistAndFlush([ + availableCourseGroup, + synchronizedCourseGroup, + courseSynchronizedWithCourseGroup, + synchronizedClassGroup, + courseSynchronizedWithClassGroup, + otherGroup, + ]); + em.clear(); + + return { + scope, + availableCourseGroup, + synchronizedClassGroup, + }; + }; - const result: Page = await repo.findAvailableGroups({ schoolId, nameQuery }, defaultOptions); + it('should return the groups that are available to the user', async () => { + const { availableCourseGroup, synchronizedClassGroup, scope } = await setup(); - expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[2].id); + const result: Page = await repo.findGroupsForScope(scope); + + expect(result.total).toEqual(2); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toEqual(availableCourseGroup.id); + expect(result.data[1].id).toEqual(synchronizedClassGroup.id); + }); }); - }); - describe('when no group exists', () => { - const setup = async () => { - const school: SchoolEntity = schoolEntityFactory.buildWithId(); - const schoolId: EntityId = school.id; + describe('when the user has permission to view this all groups of the school', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); + const availableCourseGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.COURSE, + users: [], + organization: school, + }); + const synchronizedCourseGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.COURSE, + users: [], + organization: school, + }); + const courseSynchronizedWithCourseGroup = courseFactory.buildWithId({ + syncedWithGroup: synchronizedCourseGroup, + }); + + const synchronizedClassGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.CLASS, + users: [], + organization: school, + }); + const courseSynchronizedWithClassGroup = courseFactory.buildWithId({ + syncedWithGroup: synchronizedClassGroup, + }); + + const otherGroup = groupEntityFactory.buildWithId(); + + const scope = new GroupAggregateScope() + .byUserPermission(user.id, school.id, GroupVisibilityPermission.ALL_SCHOOL_GROUPS) + .byAvailableForSync(true); + + await em.persistAndFlush([ + availableCourseGroup, + synchronizedCourseGroup, + courseSynchronizedWithCourseGroup, + synchronizedClassGroup, + courseSynchronizedWithClassGroup, + otherGroup, + ]); + em.clear(); + + return { + scope, + availableCourseGroup, + synchronizedClassGroup, + }; + }; - await em.persistAndFlush([school]); - em.clear(); + it('should return all groups of the school that are available', async () => { + const { availableCourseGroup, synchronizedClassGroup, scope } = await setup(); - const defaultOptions: IFindOptions = { pagination: { skip: 0 } }; + const result: Page = await repo.findGroupsForScope(scope); - return { - schoolId, - defaultOptions, - }; - }; + expect(result.total).toEqual(2); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toEqual(availableCourseGroup.id); + expect(result.data[1].id).toEqual(synchronizedClassGroup.id); + }); + }); + }); + describe('when no group exists', () => { it('should return an empty array', async () => { - const { schoolId, defaultOptions } = await setup(); - - const result: Page = await repo.findAvailableGroups({ schoolId }, defaultOptions); + const result: Page = await repo.findGroupsForScope(new GroupAggregateScope()); expect(result.total).toEqual(0); }); diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index c66b6bdd096..180bc4fd6ae 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -7,7 +7,8 @@ import { IFindOptions } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { MongoPatterns } from '@shared/repo'; import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; -import { Group, GroupFilter, GroupTypes } from '../domain'; +import { ScopeAggregateResult } from '@shared/repo/mongodb-scope'; +import { Group, GroupAggregateScope, GroupFilter, GroupTypes } from '../domain'; import { GroupEntity } from '../entity'; import { GroupDomainMapper, GroupTypesToGroupEntityTypesMapping } from './group-domain.mapper'; import { GroupScope } from './group.scope'; @@ -82,60 +83,11 @@ export class GroupRepo extends BaseDomainObjectRepo { return page; } - public async findAvailableGroups(filter: GroupFilter, options?: IFindOptions): Promise> { - const pipeline: unknown[] = []; - let nameRegexFilter = {}; - - const escapedName = filter.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); - if (StringValidator.isNotEmptyString(escapedName, true)) { - nameRegexFilter = { name: { $regex: escapedName, $options: 'i' } }; - } - - if (filter.userId) { - pipeline.push({ $match: { users: { $elemMatch: { user: new ObjectId(filter.userId) } } } }); - } - - if (filter.schoolId) { - pipeline.push({ $match: { organization: new ObjectId(filter.schoolId) } }); - } - - pipeline.push( - { $match: nameRegexFilter }, - { - $lookup: { - from: 'courses', - localField: '_id', - foreignField: 'syncedWithGroup', - as: 'syncedCourses', - }, - }, - { - $match: { - $or: [{ syncedCourses: { $size: 0 } }, { type: { $eq: GroupTypes.CLASS } }], - }, - }, - { $sort: { name: 1 } } - ); - - if (options?.pagination?.limit) { - pipeline.push({ - $facet: { - total: [{ $count: 'count' }], - data: [{ $skip: options.pagination?.skip }, { $limit: options.pagination.limit }], - }, - }); - } else { - pipeline.push({ - $facet: { - total: [{ $count: 'count' }], - data: [{ $skip: options?.pagination?.skip }], - }, - }); - } - - const mongoEntitiesFacet = (await this.em.aggregate(GroupEntity, pipeline)) as [ - { total: [{ count: number }]; data: EntityDictionary[] } - ]; + public async findGroupsForScope(scope: GroupAggregateScope): Promise> { + const mongoEntitiesFacet = (await this.em.aggregate( + GroupEntity, + scope.build() + )) as ScopeAggregateResult; const total: number = mongoEntitiesFacet[0]?.total[0]?.count ?? 0; 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 cc944f840e6..f558e06f3ba 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -1,15 +1,25 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { RoleDto, RoleService } from '@modules/role'; -import { UserService } from '@modules/user'; +import type { ProvisioningConfig } from '@modules/provisioning'; +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page } from '@shared/domain/domainobject'; -import { RoleName } from '@shared/domain/interface'; +import { IFindOptions, RoleName, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { groupFactory, roleDtoFactory, userDoFactory } from '@shared/testing'; -import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; +import { + groupFactory, + roleDtoFactory, + schoolEntityFactory, + setupEntities, + userDoFactory, + userFactory, +} from '@shared/testing'; +import { RoleDto, RoleService } from '@src/modules/role'; +import { UserService } from '@src/modules/user'; +import { Group, GroupAggregateScope, GroupDeletedEvent, GroupTypes, GroupVisibilityPermission } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -20,8 +30,11 @@ describe('GroupService', () => { let userService: DeepMocked; let groupRepo: DeepMocked; let eventBus: DeepMocked; + let configService: DeepMocked>; beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ providers: [ GroupService, @@ -41,6 +54,10 @@ describe('GroupService', () => { provide: EventBus, useValue: createMock(), }, + { + provide: ConfigService, + useValue: createMock(), + }, ], }).compile(); @@ -49,6 +66,7 @@ describe('GroupService', () => { userService = module.get(UserService); groupRepo = module.get(GroupRepo); eventBus = module.get(EventBus); + configService = module.get(ConfigService); }); afterAll(async () => { @@ -253,74 +271,128 @@ describe('GroupService', () => { }); }); - describe('findAvailableGroups', () => { - describe('when available groups exist', () => { + describe('findGroupsForUser', () => { + describe('when available groups exist and the feature is enabled', () => { const setup = () => { - const userId: EntityId = new ObjectId().toHexString(); - const schoolId: EntityId = new ObjectId().toHexString(); + const user = userFactory.buildWithId({ school: schoolEntityFactory.buildWithId() }); const nameQuery = 'name'; const groups: Group[] = groupFactory.buildList(2); - groupRepo.findAvailableGroups.mockResolvedValue(new Page([groups[1]], 1)); + const options: IFindOptions = { + pagination: { + skip: 1, + limit: 1, + }, + order: { + name: SortOrder.asc, + }, + }; + + configService.get.mockReturnValueOnce(true); + groupRepo.findGroupsForScope.mockResolvedValueOnce(new Page(groups, 2)); return { - userId, - schoolId, + user, nameQuery, groups, + options, }; }; - it('should return groups for user', async () => { - const { userId, groups } = setup(); + it('should call repo', async () => { + const { user, nameQuery, options } = setup(); - const result: Page = await service.findAvailableGroups({ userId }); + await service.findGroupsForUser(user, GroupVisibilityPermission.ALL_SCHOOL_CLASSES, true, nameQuery, options); - expect(result.data).toEqual([groups[1]]); + expect(groupRepo.findGroupsForScope).toHaveBeenCalledWith( + new GroupAggregateScope(options) + .byUserPermission(user.id, user.school.id, GroupVisibilityPermission.ALL_SCHOOL_CLASSES) + .byName(nameQuery) + .byAvailableForSync(true) + ); }); - it('should return groups for school', async () => { - const { schoolId, groups } = setup(); + it('should return the groups', async () => { + const { user, groups, nameQuery, options } = setup(); - const result: Page = await service.findAvailableGroups({ schoolId }); + const result: Page = await service.findGroupsForUser( + user, + GroupVisibilityPermission.ALL_SCHOOL_CLASSES, + true, + nameQuery, + options + ); - expect(result.data).toEqual([groups[1]]); + expect(result.data).toEqual(groups); }); + }); + + describe('when available groups exist but the feature is disabled', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const groups: Group[] = groupFactory.buildList(2); + + configService.get.mockReturnValueOnce(false); + groupRepo.findGroupsForScope.mockResolvedValueOnce(new Page(groups, 2)); + + return { + user, + groups, + }; + }; it('should call repo', async () => { - const { userId, schoolId, nameQuery } = setup(); + const { user } = setup(); - await service.findAvailableGroups({ userId, schoolId, nameQuery }); + await service.findGroupsForUser(user, GroupVisibilityPermission.ALL_SCHOOL_GROUPS, true); - expect(groupRepo.findAvailableGroups).toHaveBeenCalledWith({ userId, schoolId, nameQuery }, undefined); + expect(groupRepo.findGroupsForScope).toHaveBeenCalledWith( + new GroupAggregateScope().byUserPermission( + user.id, + user.school.id, + GroupVisibilityPermission.ALL_SCHOOL_GROUPS + ) + ); + }); + + it('should return the groups', async () => { + const { user, groups } = setup(); + + const result: Page = await service.findGroupsForUser( + user, + GroupVisibilityPermission.ALL_SCHOOL_GROUPS, + true + ); + + expect(result.data).toEqual(groups); }); }); - describe('when no groups exist', () => { + describe('when no groups exists', () => { const setup = () => { - const userId: EntityId = new ObjectId().toHexString(); - const schoolId: EntityId = new ObjectId().toHexString(); + const user = userFactory.buildWithId(); - groupRepo.findAvailableGroups.mockResolvedValue(new Page([], 0)); + groupRepo.findGroupsForScope.mockResolvedValueOnce(new Page([], 0)); return { - userId, - schoolId, + user, }; }; - it('should return empty array for user', async () => { - const { userId } = setup(); + it('should call repo', async () => { + const { user } = setup(); - const result: Page = await service.findAvailableGroups({ userId }); + await service.findGroupsForUser(user, GroupVisibilityPermission.OWN_GROUPS, false); - expect(result.data).toEqual([]); + expect(groupRepo.findGroupsForScope).toHaveBeenCalledWith( + new GroupAggregateScope().byUserPermission(user.id, user.school.id, GroupVisibilityPermission.OWN_GROUPS) + ); }); - it('should return empty array for school', async () => { - const { schoolId } = setup(); + it('should return an empty array', async () => { + const { user } = setup(); - const result: Page = await service.findAvailableGroups({ schoolId }); + const result: Page = await service.findGroupsForUser(user, GroupVisibilityPermission.OWN_GROUPS, false); expect(result.data).toEqual([]); }); @@ -489,6 +561,16 @@ describe('GroupService', () => { expect(groupRepo.save).toHaveBeenCalledWith(group); }); + + describe('when the role id is undefined', () => { + it('should throw', async () => { + roleService.findByName.mockResolvedValue(roleDtoFactory.build({ id: undefined })); + + await expect(service.addUserToGroup('groupId', 'userId', RoleName.STUDENT)).rejects.toThrow( + BadRequestException + ); + }); + }); }); }); diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index d4117a2d3c8..76e9f242e1e 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -1,14 +1,25 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; +import type { ProvisioningConfig } from '@modules/provisioning'; import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { EventBus } from '@nestjs/cqrs'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; import { IFindOptions, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { RoleService } from '@src/modules/role'; import { UserService } from '@src/modules/user/service/user.service'; -import { Group, GroupDeletedEvent, GroupFilter, GroupTypes, GroupUser } from '../domain'; +import { + Group, + GroupAggregateScope, + GroupDeletedEvent, + GroupFilter, + GroupVisibilityPermission, + GroupUser, + GroupTypes, +} from '../domain'; import { GroupRepo } from '../repo'; @Injectable() @@ -17,7 +28,8 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { private readonly groupRepo: GroupRepo, private readonly userService: UserService, private readonly roleService: RoleService, - private readonly eventBus: EventBus + private readonly eventBus: EventBus, + private readonly configService: ConfigService ) {} public async findById(id: EntityId): Promise { @@ -48,8 +60,21 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return groups; } - public async findAvailableGroups(filter: GroupFilter, options?: IFindOptions): Promise> { - const groups: Page = await this.groupRepo.findAvailableGroups(filter, options); + public async findGroupsForUser( + user: User, + permission: GroupVisibilityPermission, + availableGroupsForCourseSync: boolean, + nameQuery?: string, + options?: IFindOptions + ): Promise> { + const scope = new GroupAggregateScope(options) + .byUserPermission(user.id, user.school.id, permission) + .byName(nameQuery) + .byAvailableForSync( + availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') + ); + + const groups: Page = await this.groupRepo.findGroupsForScope(scope); return groups; } @@ -82,7 +107,9 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { public async addUserToGroup(groupId: EntityId, userId: EntityId, roleName: RoleName): Promise { const role = await this.roleService.findByName(roleName); - if (!role.id) throw new BadRequestException('Role has no id.'); + if (!role.id) { + throw new BadRequestException('Role has no id.'); + } const group = await this.findById(groupId); const user = await this.userService.findById(userId); // user must have an id, because we are fetching it by id -> fix in service diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index c03258d21ce..c875a7b488d 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -1,18 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationService } from '@modules/authorization'; -import { ProvisioningConfig } from '@modules/provisioning'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { School, SchoolService } from '@modules/school/domain'; import { schoolFactory } from '@modules/school/testing'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; import { Role, User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; +import { Permission, SortOrder } from '@shared/domain/interface'; import { groupFactory, roleDtoFactory, @@ -24,12 +22,12 @@ import { userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { Group, GroupTypes } from '../domain'; +import { Group, GroupTypes, GroupVisibilityPermission } from '../domain'; import { GroupService } from '../service'; import { ResolvedGroupDto } from './dto'; import { GroupUc } from './group.uc'; -describe('GroupUc', () => { +describe(GroupUc.name, () => { let module: TestingModule; let uc: GroupUc; @@ -38,7 +36,6 @@ describe('GroupUc', () => { let roleService: DeepMocked; let schoolService: DeepMocked; let authorizationService: DeepMocked; - let configService: DeepMocked>; let logger: DeepMocked; beforeAll(async () => { @@ -65,10 +62,6 @@ describe('GroupUc', () => { provide: AuthorizationService, useValue: createMock(), }, - { - provide: ConfigService, - useValue: createMock(), - }, { provide: Logger, useValue: createMock(), @@ -82,7 +75,6 @@ describe('GroupUc', () => { roleService = module.get(RoleService); schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); - configService = module.get(ConfigService); logger = module.get(Logger); await setupEntities(); @@ -317,353 +309,223 @@ describe('GroupUc', () => { it('should throw forbidden', async () => { const { user, error, school } = setup(); - const func = () => uc.getAllGroups(user.id, school.id); - - await expect(func).rejects.toThrow(error); + await expect(() => uc.getAllGroups(user.id, school.id)).rejects.toThrow(error); }); }); - describe('when admin requests groups', () => { + describe('when an admin requests groups', () => { const setup = () => { const school: School = schoolFactory.build(); - const otherSchool: School = schoolFactory.build(); - const roles: Role = roleFactory.build({ permissions: [Permission.GROUP_FULL_ADMIN, Permission.GROUP_VIEW] }); + const role: Role = roleFactory.buildWithId({ + permissions: [Permission.GROUP_FULL_ADMIN, Permission.GROUP_VIEW], + }); const user: User = userFactory.buildWithId({ - roles: [roles], + roles: [role], school: schoolEntityFactory.buildWithId(undefined, school.id), }); - - const groupInSchool: Group = groupFactory.build({ organizationId: school.id }); - const availableGroupInSchool: Group = groupFactory.build({ organizationId: school.id }); - const groupInOtherSchool: Group = groupFactory.build({ organizationId: otherSchool.id }); - const userRole: RoleDto = roleDtoFactory.build({ - id: user.roles[0].id, - name: user.roles[0].name, + id: role.id, + name: role.name, }); const userDto: UserDO = userDoFactory.build({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, - roles: [{ id: user.roles[0].id, name: user.roles[0].name }], + roles: [{ id: role.id, name: role.name }], }); + const group: Group = groupFactory.build({ organizationId: school.id }); + + const nameQuery = 'name'; schoolService.getSchoolById.mockResolvedValue(school); authorizationService.getUserWithPermissions.mockResolvedValue(user); authorizationService.hasAllPermissions.mockReturnValueOnce(true); - groupService.findAvailableGroups.mockResolvedValue(new Page([availableGroupInSchool], 1)); - groupService.findGroups.mockResolvedValue(new Page([groupInSchool, availableGroupInSchool], 2)); + groupService.findGroupsForUser.mockResolvedValue(new Page([group], 1)); userService.findByIdOrNull.mockResolvedValue(userDto); roleService.findById.mockResolvedValue(userRole); - configService.get.mockReturnValueOnce(true); - return { user, school, - groupInSchool, - availableGroupInSchool, - groupInOtherSchool, + group, + nameQuery, + userRole, + userDto, }; }; - describe('when requesting all groups', () => { - it('should return all groups of the school', async () => { - const { user, groupInSchool, availableGroupInSchool, school } = setup(); - - const response = await uc.getAllGroups(user.id, school.id); - - expect(response).toMatchObject({ - data: [ - { - id: groupInSchool.id, - name: groupInSchool.name, - type: GroupTypes.CLASS, - externalSource: groupInSchool.externalSource, - organizationId: groupInSchool.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - { - id: availableGroupInSchool.id, - name: availableGroupInSchool.name, - type: GroupTypes.CLASS, - externalSource: availableGroupInSchool.externalSource, - organizationId: availableGroupInSchool.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - ], - total: 2, - }); - }); + it('should should search for the groups', async () => { + const { user, school, nameQuery } = setup(); - it('should not return group not in school', async () => { - const { user, groupInOtherSchool, school } = setup(); - - const response = await uc.getAllGroups(user.id, school.id); - - expect(response).not.toMatchObject({ - data: [ - { - id: groupInOtherSchool.id, - name: groupInOtherSchool.name, - type: GroupTypes.CLASS, - externalSource: groupInOtherSchool.externalSource, - organizationId: groupInOtherSchool.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - ], - total: 1, - }); - }); + await uc.getAllGroups(user.id, school.id, {}, nameQuery, true); + + expect(groupService.findGroupsForUser).toHaveBeenCalledWith( + user, + GroupVisibilityPermission.ALL_SCHOOL_GROUPS, + true, + nameQuery, + { order: { name: SortOrder.asc } } + ); }); - describe('when requesting all available groups', () => { - it('should return all available groups for course sync', async () => { - const { user, availableGroupInSchool, school } = setup(); - - const response = await uc.getAllGroups(user.id, school.id, undefined, undefined, true); - - expect(response).toMatchObject({ - data: [ - { - id: availableGroupInSchool.id, - name: availableGroupInSchool.name, - type: GroupTypes.CLASS, - externalSource: availableGroupInSchool.externalSource, - organizationId: availableGroupInSchool.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - ], - total: 1, - }); + it('should return the groups of the school', async () => { + const { user, group, school, nameQuery, userRole, userDto } = setup(); + + const result = await uc.getAllGroups(user.id, school.id, {}, nameQuery, true); + + expect(result).toEqual({ + data: [ + { + ...group.getProps(), + users: [{ role: userRole, user: userDto }], + }, + ], + total: 1, }); }); }); - describe('when teacher requests groups', () => { + describe('when teacher requests groups and he can see students', () => { const setup = () => { const school: School = schoolFactory.build(); - const roles: Role = roleFactory.build({ permissions: [Permission.GROUP_VIEW] }); + const role: Role = roleFactory.buildWithId({ permissions: [Permission.STUDENT_LIST, Permission.GROUP_VIEW] }); const user: User = userFactory.buildWithId({ - roles: [roles], + roles: [role], school: schoolEntityFactory.buildWithId(undefined, school.id), }); - - const teachersGroup: Group = groupFactory.build({ - organizationId: school.id, - users: [{ userId: user.id, roleId: user.roles[0].id }], + const userRole: RoleDto = roleDtoFactory.build({ + id: role.id, + name: role.name, }); - const availableTeachersGroup: Group = groupFactory.build({ - organizationId: school.id, - users: [{ userId: user.id, roleId: user.roles[0].id }], + const userDto: UserDO = userDoFactory.build({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + roles: [{ id: role.id, name: role.name }], + }); + const group: Group = groupFactory.build({ organizationId: school.id }); + + const nameQuery = 'name'; + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + authorizationService.hasPermission.mockReturnValueOnce(true); + groupService.findGroupsForUser.mockResolvedValue(new Page([group], 1)); + userService.findByIdOrNull.mockResolvedValue(userDto); + roleService.findById.mockResolvedValue(userRole); + + return { + user, + school, + group, + nameQuery, + userRole, + userDto, + }; + }; + + it('should should search for the groups', async () => { + const { user, school, nameQuery } = setup(); + + await uc.getAllGroups(user.id, school.id, {}, nameQuery, true); + + expect(groupService.findGroupsForUser).toHaveBeenCalledWith( + user, + GroupVisibilityPermission.ALL_SCHOOL_CLASSES, + true, + nameQuery, + { order: { name: SortOrder.asc } } + ); + }); + + it('should return the groups of the school', async () => { + const { user, group, school, nameQuery, userRole, userDto } = setup(); + + const result = await uc.getAllGroups(user.id, school.id, {}, nameQuery, true); + + expect(result).toEqual({ + data: [ + { + ...group.getProps(), + users: [{ role: userRole, user: userDto }], + }, + ], + total: 1, }); - const notTeachersGroup: Group = groupFactory.build({ organizationId: school.id }); + }); + }); + describe('when teacher requests groups and he cannot see students', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const role: Role = roleFactory.buildWithId({ permissions: [Permission.STUDENT_LIST, Permission.GROUP_VIEW] }); + const user: User = userFactory.buildWithId({ + roles: [role], + school: schoolEntityFactory.buildWithId(undefined, school.id), + }); const userRole: RoleDto = roleDtoFactory.build({ - id: user.roles[0].id, - name: user.roles[0].name, + id: role.id, + name: role.name, }); const userDto: UserDO = userDoFactory.build({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, - roles: [{ id: user.roles[0].id, name: user.roles[0].name }], + roles: [{ id: role.id, name: role.name }], }); + const group: Group = groupFactory.build({ organizationId: school.id }); + + const nameQuery = 'name'; schoolService.getSchoolById.mockResolvedValue(school); authorizationService.getUserWithPermissions.mockResolvedValue(user); - authorizationService.hasAllPermissions.mockReturnValue(false); - groupService.findAvailableGroups.mockResolvedValue(new Page([availableTeachersGroup], 1)); - groupService.findGroups.mockResolvedValue(new Page([teachersGroup, availableTeachersGroup], 2)); + authorizationService.hasAllPermissions.mockReturnValueOnce(false); + authorizationService.hasPermission.mockReturnValueOnce(false); + groupService.findGroupsForUser.mockResolvedValue(new Page([group], 1)); userService.findByIdOrNull.mockResolvedValue(userDto); roleService.findById.mockResolvedValue(userRole); - configService.get.mockReturnValueOnce(true); - return { user, school, - teachersGroup, - availableTeachersGroup, - notTeachersGroup, + group, + nameQuery, + userRole, + userDto, }; }; - describe('when requesting all groups', () => { - it('should return all groups the teacher is part of', async () => { - const { user, teachersGroup, availableTeachersGroup, school } = setup(); - - const response = await uc.getAllGroups(user.id, school.id); - - expect(response).toMatchObject({ - data: [ - { - id: teachersGroup.id, - name: teachersGroup.name, - type: GroupTypes.CLASS, - externalSource: teachersGroup.externalSource, - organizationId: teachersGroup.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - { - id: availableTeachersGroup.id, - name: availableTeachersGroup.name, - type: GroupTypes.CLASS, - externalSource: availableTeachersGroup.externalSource, - organizationId: availableTeachersGroup.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - ], - total: 2, - }); - }); + it('should should search for the groups', async () => { + const { user, school, nameQuery } = setup(); - it('should not return group without the teacher', async () => { - const { user, notTeachersGroup, school } = setup(); - - const response = await uc.getAllGroups(user.id, school.id); - - expect(response).not.toMatchObject({ - data: [ - { - id: notTeachersGroup.id, - name: notTeachersGroup.name, - type: GroupTypes.CLASS, - externalSource: notTeachersGroup.externalSource, - organizationId: notTeachersGroup.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - ], - total: 1, - }); - }); + await uc.getAllGroups(user.id, school.id, {}, nameQuery, true); + + expect(groupService.findGroupsForUser).toHaveBeenCalledWith( + user, + GroupVisibilityPermission.OWN_GROUPS, + true, + nameQuery, + { order: { name: SortOrder.asc } } + ); }); - describe('when requesting all available groups', () => { - it('should return all available groups for course sync the teacher is part of', async () => { - const { user, availableTeachersGroup, school } = setup(); - - const response = await uc.getAllGroups(user.id, school.id, undefined, undefined, true); - - expect(response).toMatchObject({ - data: [ - { - id: availableTeachersGroup.id, - name: availableTeachersGroup.name, - type: GroupTypes.CLASS, - externalSource: availableTeachersGroup.externalSource, - organizationId: availableTeachersGroup.organizationId, - users: [ - { - user: { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }, - role: { - id: user.roles[0].id, - name: user.roles[0].name, - }, - }, - ], - }, - ], - total: 1, - }); + it('should return the groups of the school', async () => { + const { user, group, school, nameQuery, userRole, userDto } = setup(); + + const result = await uc.getAllGroups(user.id, school.id, {}, nameQuery, true); + + expect(result).toEqual({ + data: [ + { + ...group.getProps(), + users: [{ role: userRole, user: userDto }], + }, + ], + total: 1, }); }); }); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 714307c6a6d..61394bbdda9 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -1,17 +1,15 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { ProvisioningConfig } from '@modules/provisioning'; import { RoleDto, RoleService } from '@modules/role'; import { School, SchoolService } from '@modules/school/domain'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; import { Page, UserDO } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { Group, GroupFilter, GroupUser } from '../domain'; +import { Group, GroupUser, GroupVisibilityPermission } from '../domain'; import { GroupService } from '../service'; import { ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; @@ -24,7 +22,6 @@ export class GroupUc { private readonly roleService: RoleService, private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, - private readonly configService: ConfigService, private readonly logger: Logger ) {} @@ -74,7 +71,7 @@ export class GroupUc { public async getAllGroups( userId: EntityId, schoolId: EntityId, - options: IFindOptions = { pagination: { skip: 0 } }, + options: IFindOptions = {}, nameQuery?: string, availableGroupsForCourseSync?: boolean ): Promise> { @@ -83,18 +80,17 @@ export class GroupUc { const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkPermission(user, school, AuthorizationContextBuilder.read([Permission.GROUP_VIEW])); - const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [Permission.GROUP_FULL_ADMIN]); + const groupVisibilityPermission: GroupVisibilityPermission = this.getGroupVisibilityPermission(user, school); - const filter: GroupFilter = { nameQuery }; options.order = { name: SortOrder.asc }; - if (canSeeFullList) { - filter.schoolId = schoolId; - } else { - filter.userId = userId; - } - - const groups: Page = await this.getGroups(filter, options, availableGroupsForCourseSync); + const groups: Page = await this.groupService.findGroupsForUser( + user, + groupVisibilityPermission, + !!availableGroupsForCourseSync, + nameQuery, + options + ); const resolvedGroups: ResolvedGroupDto[] = await Promise.all( groups.data.map(async (group: Group) => { @@ -110,18 +106,21 @@ export class GroupUc { return page; } - private async getGroups( - filter: GroupFilter, - options: IFindOptions, - availableGroupsForCourseSync?: boolean - ): Promise> { - let foundGroups: Page; - if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { - foundGroups = await this.groupService.findAvailableGroups(filter, options); - } else { - foundGroups = await this.groupService.findGroups(filter, options); + private getGroupVisibilityPermission(user: User, school: School): GroupVisibilityPermission { + const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [Permission.GROUP_FULL_ADMIN]); + if (canSeeFullList) { + return GroupVisibilityPermission.ALL_SCHOOL_GROUPS; + } + + const canSeeAllClasses: boolean = this.authorizationService.hasPermission( + user, + school, + AuthorizationContextBuilder.read([Permission.STUDENT_LIST]) + ); + if (canSeeAllClasses) { + return GroupVisibilityPermission.ALL_SCHOOL_CLASSES; } - return foundGroups; + return GroupVisibilityPermission.OWN_GROUPS; } } 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 6fd315a438b..ef10c434469 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 @@ -49,6 +49,10 @@ describe('Course Controller (API)', () => { await app.close(); }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe('[GET] /courses/', () => { const setup = () => { const student = createStudent(); @@ -278,6 +282,37 @@ describe('Course Controller (API)', () => { }); }); + describe('when a groupId parameter is invalid', () => { + 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); + const params = { groupId: 'not-mongo-id' }; + + return { + loggedInClient, + course, + group, + params, + }; + }; + + it('should not start the synchronization with validation error', async () => { + const { loggedInClient, course, params } = await setup(); + + const response = await loggedInClient.post(`${course.id}/start-sync`).send(params); + + expect(response.statusCode).toEqual(HttpStatus.BAD_REQUEST); + }); + }); + describe('when a course is already synchronized', () => { const setup = async () => { const teacher = createTeacher(); diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 7d20c2f5292..8b4854622d5 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -122,6 +122,7 @@ export class CourseController { @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.' }) + @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) public async startSynchronization( @CurrentUser() currentUser: ICurrentUser, @Param() params: CourseUrlParams, diff --git a/apps/server/src/modules/learnroom/domain/do/course.ts b/apps/server/src/modules/learnroom/domain/do/course.ts index e95fd85b27e..a3ae9b7a2ee 100644 --- a/apps/server/src/modules/learnroom/domain/do/course.ts +++ b/apps/server/src/modules/learnroom/domain/do/course.ts @@ -1,5 +1,5 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { CourseFeatures } from '@shared/domain/entity'; +import { CourseFeatures, SyncAttribute } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; export interface CourseProps extends AuthorizableObject { @@ -34,6 +34,8 @@ export interface CourseProps extends AuthorizableObject { groupIds: EntityId[]; syncedWithGroup?: EntityId; + + excludeFromSync?: SyncAttribute[]; } export class Course extends DomainObject { @@ -96,4 +98,12 @@ export class Course extends DomainObject { get syncedWithGroup(): EntityId | undefined { return this.props.syncedWithGroup; } + + set excludeFromSync(values: SyncAttribute[] | undefined) { + this.props.excludeFromSync = values ? [...new Set(values)] : undefined; + } + + get excludeFromSync(): SyncAttribute[] | undefined { + return this.props.excludeFromSync; + } } 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 194917950f1..958016f1d8d 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 @@ -6,7 +6,14 @@ import { classEntityFactory } from '@modules/class/entity/testing'; 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 { + Course as CourseEntity, + CourseFeatures, + CourseGroup, + SchoolEntity, + SyncAttribute, + User, +} from '@shared/domain/entity'; import { SortOrder } from '@shared/domain/interface'; import { cleanupCollections, @@ -154,6 +161,7 @@ describe(CourseMikroOrmRepo.name, () => { color: '#ACACAC', copyingSince: new Date(), syncedWithGroup: groupEntity.id, + excludeFromSync: [SyncAttribute.TEACHERS], shareToken: 'shareToken', untilDate: new Date(), startDate: new Date(), diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts index fdb0925ccc2..032a9b47227 100644 --- a/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts @@ -39,6 +39,7 @@ export class CourseEntityMapper { copyingSince: entity.copyingSince, shareToken: entity.shareToken, syncedWithGroup: entity.syncedWithGroup?.id, + excludeFromSync: entity.excludeFromSync, }); return course; @@ -77,6 +78,7 @@ export class CourseEntityMapper { copyingSince: props.copyingSince, shareToken: props.shareToken, syncedWithGroup: props.syncedWithGroup, + excludeFromSync: props.excludeFromSync, }; return courseEntityData; diff --git a/apps/server/src/modules/learnroom/service/course-sync.service.spec.ts b/apps/server/src/modules/learnroom/service/course-sync.service.spec.ts index 8afbcd87bb6..c0a92dccd26 100644 --- a/apps/server/src/modules/learnroom/service/course-sync.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-sync.service.spec.ts @@ -3,7 +3,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Group, GroupUser } from '@modules/group'; import { RoleDto, RoleService } from '@modules/role'; import { Test, TestingModule } from '@nestjs/testing'; -import { groupFactory, roleDtoFactory } from '@shared/testing'; +import { SyncAttribute } from '@shared/domain/entity'; +import { groupFactory, roleDtoFactory, setupEntities, userFactory } from '@shared/testing'; import { Course, COURSE_REPO, @@ -39,6 +40,7 @@ describe(CourseSyncService.name, () => { service = module.get(CourseSyncService); roleService = module.get(RoleService); courseRepo = module.get(COURSE_REPO); + await setupEntities(); }); afterAll(async () => { @@ -59,7 +61,7 @@ describe(CourseSyncService.name, () => { }; }; - it('should save a course without a synchronized group', async () => { + it('should stop group sync', async () => { const { course } = setup(); await service.stopSynchronization(course); @@ -68,6 +70,7 @@ describe(CourseSyncService.name, () => { new Course({ ...course.getProps(), syncedWithGroup: undefined, + excludeFromSync: undefined, }) ); }); @@ -82,7 +85,7 @@ describe(CourseSyncService.name, () => { }; }; - it('should throw an unprocessable entity exception', async () => { + it('should throw an exception', async () => { const { course } = setup(); await expect(service.stopSynchronization(course)).rejects.toThrow(CourseNotSynchronizedLoggableException); @@ -91,20 +94,27 @@ describe(CourseSyncService.name, () => { }); describe('startSynchronization', () => { - describe('when a course is not synchronized with a group', () => { + describe('when starting partial synchonization with a group', () => { const setup = () => { - const teacherId = new ObjectId().toHexString(); + const syncingUser = userFactory.asTeacher().buildWithId(); + + const courseTeacherId = new ObjectId().toHexString(); const course: Course = courseFactory.build({ classIds: [new ObjectId().toHexString()], groupIds: [new ObjectId().toHexString()], - substitutionTeacherIds: [teacherId], }); const studentRole = roleDtoFactory.build({ id: 'student-role-id' }); const teacherRole = roleDtoFactory.build({ id: 'teacher-role-id' }); - const students: GroupUser[] = [{ roleId: 'student-role-id', userId: 'student-user-id' }]; - const teachers: GroupUser[] = [{ roleId: 'teacher-role-id', userId: 'teacher-user-id' }]; + + const groupStudentId = new ObjectId().toHexString(); + const students: GroupUser[] = [{ roleId: 'student-role-id', userId: groupStudentId }]; + + const groupTeacherId = new ObjectId().toHexString(); + const teachers: GroupUser[] = [{ roleId: 'teacher-role-id', userId: groupTeacherId }]; + const group: Group = groupFactory.build({ users: [...students, ...teachers] }); const groupWithoutTeachers: Group = groupFactory.build({ users: [...students] }); + roleService.findByName.mockResolvedValueOnce(studentRole).mockResolvedValueOnce(teacherRole); return { @@ -113,14 +123,16 @@ describe(CourseSyncService.name, () => { students, teachers, groupWithoutTeachers, - teacherId, + groupTeacherId, + courseTeacherId, + syncingUser, }; }; - it('should save a course with synchronized group, students, and teachers', async () => { - const { course, group, students, teachers, teacherId } = setup(); + it('should start partial synchronization of a course with a group', async () => { + const { course, group, students, syncingUser } = setup(); - await service.startSynchronization(course, group); + await service.startSynchronization(course, group, syncingUser); expect(courseRepo.saveAll).toHaveBeenCalledWith<[Course[]]>([ new Course({ @@ -130,31 +142,64 @@ describe(CourseSyncService.name, () => { startDate: group.validPeriod?.from, untilDate: group.validPeriod?.until, studentIds: students.map((student) => student.userId), - teacherIds: teachers.map((teacher) => teacher.userId), + teacherIds: [syncingUser.id], classIds: [], groupIds: [], - substitutionTeacherIds: [teacherId], + excludeFromSync: [SyncAttribute.TEACHERS], }), ]); }); + }); - it('should set an empty list of students if no teachers are present', async () => { - const { course, groupWithoutTeachers, teacherId } = setup(); + describe('when starting full synchonization with a group', () => { + const setup = () => { + const teacherId = new ObjectId().toHexString(); + const courseTeacher = userFactory.asTeacher().buildWithId(); + const course: Course = courseFactory.build({ + classIds: [new ObjectId().toHexString()], + groupIds: [new ObjectId().toHexString()], + substitutionTeacherIds: [teacherId], + }); - await service.startSynchronization(course, groupWithoutTeachers); + const studentRole = roleDtoFactory.build({ id: 'student-role-id' }); + const teacherRole = roleDtoFactory.build({ id: 'teacher-role-id' }); + const students: GroupUser[] = [{ roleId: 'student-role-id', userId: 'student-user-id' }]; + const teachers: GroupUser[] = [ + { roleId: 'teacher-role-id', userId: 'teacher-user-id' }, + { roleId: 'teacher-role-id', userId: 'teacher-user-id-1' }, + { roleId: 'teacher-role-id', userId: courseTeacher.id }, + ]; + const group: Group = groupFactory.build({ users: [...students, ...teachers] }); + const groupWithoutTeachers: Group = groupFactory.build({ users: [...students] }); + roleService.findByName.mockResolvedValueOnce(studentRole).mockResolvedValueOnce(teacherRole); + + return { + course, + group, + students, + teachers, + groupWithoutTeachers, + teacherId, + courseTeacher, + }; + }; + + it('should start full synchronization of a course with a group', async () => { + const { course, group, students, teachers, courseTeacher } = setup(); + + await service.startSynchronization(course, group, courseTeacher); expect(courseRepo.saveAll).toHaveBeenCalledWith<[Course[]]>([ new Course({ ...course.getProps(), - syncedWithGroup: groupWithoutTeachers.id, + syncedWithGroup: group.id, name: course.name, - startDate: groupWithoutTeachers.validPeriod?.from, - untilDate: groupWithoutTeachers.validPeriod?.until, - studentIds: [], - teacherIds: [], + startDate: group.validPeriod?.from, + untilDate: group.validPeriod?.until, + studentIds: students.map((student) => student.userId), + teacherIds: teachers.map((teacher) => teacher.userId), classIds: [], groupIds: [], - substitutionTeacherIds: [teacherId], }), ]); }); @@ -166,19 +211,21 @@ describe(CourseSyncService.name, () => { const group: Group = groupFactory.build(); const students: GroupUser[] = [{ roleId: 'student-role-id', userId: 'student-user-id' }]; const teachers: GroupUser[] = [{ roleId: 'teacher-role-id', userId: 'teacher-user-id' }]; + const someTeacher = userFactory.build(); return { course, group, students, teachers, + someTeacher, }; }; - it('should throw a CourseAlreadySynchronizedLoggableException', async () => { - const { course, group } = setup(); + it('should throw an exception', async () => { + const { course, group, someTeacher } = setup(); - await expect(service.startSynchronization(course, group)).rejects.toThrow( + await expect(service.startSynchronization(course, group, someTeacher)).rejects.toThrow( CourseAlreadySynchronizedLoggableException ); @@ -213,7 +260,6 @@ describe(CourseSyncService.name, () => { classIds: [new ObjectId().toHexString()], groupIds: [new ObjectId().toHexString()], substitutionTeacherIds: [substituteTeacherId], - syncedWithGroup: newGroup.id, }); courseRepo.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); @@ -229,7 +275,7 @@ describe(CourseSyncService.name, () => { }; }; - it('should synchronize with the group', async () => { + it('should synchronize with the new group', async () => { const { course, newGroup, studentId, teacherId, substituteTeacherId } = setup(); await service.synchronizeCourseWithGroup(newGroup); @@ -350,7 +396,7 @@ describe(CourseSyncService.name, () => { }); }); - describe('when the last teacher gets removed from a synced group', () => { + describe('when the teachers are not synced from group', () => { const setup = () => { const substituteTeacherId = new ObjectId().toHexString(); const studentUserId = new ObjectId().toHexString(); @@ -358,6 +404,7 @@ describe(CourseSyncService.name, () => { const studentRoleId: string = new ObjectId().toHexString(); const studentRole: RoleDto = roleDtoFactory.build({ id: studentRoleId }); const teacherRole: RoleDto = roleDtoFactory.build(); + const newGroup: Group = groupFactory.build({ users: [ new GroupUser({ @@ -368,10 +415,10 @@ describe(CourseSyncService.name, () => { }); const course: Course = courseFactory.build({ - teacherIds: [teacherUserId], - studentIds: [studentUserId], syncedWithGroup: newGroup.id, substitutionTeacherIds: [substituteTeacherId], + teacherIds: [teacherUserId], + excludeFromSync: [], }); courseRepo.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); roleService.findByName.mockResolvedValueOnce(studentRole); @@ -382,13 +429,14 @@ describe(CourseSyncService.name, () => { newGroup, teacherUserId, substituteTeacherId, + studentUserId, }; }; - it('should keep the last teacher, remove all students', async () => { + it('should not sync group students', async () => { const { course, newGroup, teacherUserId, substituteTeacherId } = setup(); - await service.synchronizeCourseWithGroup(newGroup, newGroup); + await service.synchronizeCourseWithGroup(newGroup); expect(courseRepo.saveAll).toHaveBeenCalledWith<[Course[]]>([ new Course({ ...course.getProps(), @@ -400,6 +448,70 @@ describe(CourseSyncService.name, () => { syncedWithGroup: course.syncedWithGroup, classIds: [], groupIds: [], + excludeFromSync: [], + substitutionTeacherIds: [substituteTeacherId], + }), + ]); + }); + }); + + describe('when the teachers are not synced from group (partial sync)', () => { + const setup = () => { + const substituteTeacherId = new ObjectId().toHexString(); + const studentUserId = new ObjectId().toHexString(); + const teacherUserId = new ObjectId().toHexString(); + const studentRoleId: string = new ObjectId().toHexString(); + const studentRole: RoleDto = roleDtoFactory.build({ id: studentRoleId }); + const teacherRole: RoleDto = roleDtoFactory.build(); + const teacherRoleId: string = new ObjectId().toHexString(); + const newGroup: Group = groupFactory.build({ + users: [ + new GroupUser({ + userId: studentUserId, + roleId: studentRoleId, + }), + new GroupUser({ + userId: substituteTeacherId, + roleId: teacherRoleId, + }), + ], + }); + + const course: Course = courseFactory.build({ + teacherIds: [teacherUserId], + syncedWithGroup: newGroup.id, + substitutionTeacherIds: [substituteTeacherId], + excludeFromSync: [SyncAttribute.TEACHERS], + }); + courseRepo.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); + roleService.findByName.mockResolvedValueOnce(studentRole); + roleService.findByName.mockResolvedValueOnce(teacherRole); + + return { + course, + newGroup, + teacherUserId, + substituteTeacherId, + studentUserId, + }; + }; + + it('should not sync group teachers', async () => { + const { course, newGroup, substituteTeacherId, teacherUserId, studentUserId } = setup(); + + await service.synchronizeCourseWithGroup(newGroup); + expect(courseRepo.saveAll).toHaveBeenCalledWith<[Course[]]>([ + new Course({ + ...course.getProps(), + name: course.name, + startDate: newGroup.validPeriod?.from, + untilDate: newGroup.validPeriod?.until, + studentIds: [studentUserId], + teacherIds: [teacherUserId], + syncedWithGroup: course.syncedWithGroup, + classIds: [], + groupIds: [], + excludeFromSync: [SyncAttribute.TEACHERS], substitutionTeacherIds: [substituteTeacherId], }), ]); diff --git a/apps/server/src/modules/learnroom/service/course-sync.service.ts b/apps/server/src/modules/learnroom/service/course-sync.service.ts index 92c6c96e1d7..d5d0928f4e7 100644 --- a/apps/server/src/modules/learnroom/service/course-sync.service.ts +++ b/apps/server/src/modules/learnroom/service/course-sync.service.ts @@ -1,8 +1,8 @@ import { Group, GroupUser } from '@modules/group'; import { RoleService } from '@modules/role'; import { Inject, Injectable } from '@nestjs/common'; +import { Role, SyncAttribute, User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; import { Course, COURSE_REPO, @@ -18,11 +18,19 @@ export class CourseSyncService { private readonly roleService: RoleService ) {} - public async startSynchronization(course: Course, group: Group): Promise { + public async startSynchronization(course: Course, group: Group, user: User): Promise { if (course.syncedWithGroup) { throw new CourseAlreadySynchronizedLoggableException(course.id); } + const isTeacher = user.getRoles().some((role: Role) => role.name === RoleName.TEACHER); + const isInGroup = group.users.some((groupUser) => groupUser.userId === user.id); + + if (isTeacher && !isInGroup) { + course.excludeFromSync = [SyncAttribute.TEACHERS]; + course.teachers = [user.id]; + } + await this.synchronize([course], group); } @@ -32,6 +40,7 @@ export class CourseSyncService { } course.syncedWithGroup = undefined; + course.excludeFromSync = undefined; await this.courseRepo.save(course); } @@ -42,36 +51,39 @@ export class CourseSyncService { } private async synchronize(courses: Course[], group: Group, oldGroup?: Group): Promise { - if (courses.length) { - const [studentRole, teacherRole] = await Promise.all([ - this.roleService.findByName(RoleName.STUDENT), - this.roleService.findByName(RoleName.TEACHER), - ]); - const students = group.users.filter((groupUser: GroupUser) => groupUser.roleId === studentRole.id); - const teachers = group.users.filter((groupUser: GroupUser) => groupUser.roleId === teacherRole.id); - - const coursesToSync = courses.map((course) => { - course.syncedWithGroup = group.id; - if (oldGroup && oldGroup.name === course.name) { - course.name = group.name; - } - course.startDate = group.validPeriod?.from; - course.untilDate = group.validPeriod?.until; - - if (teachers.length >= 1) { - course.students = students.map((user: GroupUser): EntityId => user.userId); - course.teachers = teachers.map((user: GroupUser): EntityId => user.userId); - } else { - course.students = []; - } - - course.classes = []; - course.groups = []; - - return course; - }); - - await this.courseRepo.saveAll(coursesToSync); + const [studentRole, teacherRole] = await Promise.all([ + this.roleService.findByName(RoleName.STUDENT), + this.roleService.findByName(RoleName.TEACHER), + ]); + + const studentIds = group.users + .filter((user: GroupUser) => user.roleId === studentRole.id) + .map((student) => student.userId); + const teacherIds = group.users + .filter((user: GroupUser) => user.roleId === teacherRole.id) + .map((teacher) => teacher.userId); + + for (const course of courses) { + course.syncedWithGroup = group.id; + course.startDate = group.validPeriod?.from; + course.untilDate = group.validPeriod?.until; + course.classes = []; + course.groups = []; + + if (oldGroup?.name === course.name) { + course.name = group.name; + } + + const excludedFromSync = new Set(course.excludeFromSync || []); + + if (excludedFromSync.has(SyncAttribute.TEACHERS)) { + course.students = studentIds; + } else { + course.teachers = teacherIds.length > 0 ? teacherIds : course.teachers; + course.students = teacherIds.length > 0 ? studentIds : []; + } } + + await this.courseRepo.saveAll(courses); } } 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 1d366afe06d..857f3c586f8 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 @@ -145,7 +145,7 @@ describe(CourseSyncUc.name, () => { await uc.startSynchronization(user.id, course.id, group.id); - expect(courseSyncService.startSynchronization).toHaveBeenCalledWith(course, group); + expect(courseSyncService.startSynchronization).toHaveBeenCalledWith(course, group, user); }); }); }); 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 4e018e3bedb..a59db53b023 100644 --- a/apps/server/src/modules/learnroom/uc/course-sync.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts @@ -42,6 +42,6 @@ export class CourseSyncUc { AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) ); - await this.courseSyncService.startSynchronization(course, group); + await this.courseSyncService.startSynchronization(course, group, user); } } diff --git a/apps/server/src/shared/domain/entity/course.entity.ts b/apps/server/src/shared/domain/entity/course.entity.ts index 3bad3a7e62a..b244fdbf601 100644 --- a/apps/server/src/shared/domain/entity/course.entity.ts +++ b/apps/server/src/shared/domain/entity/course.entity.ts @@ -1,6 +1,7 @@ import { Collection, Entity, Enum, Index, ManyToMany, ManyToOne, OneToMany, Property, Unique } from '@mikro-orm/core'; import { ClassEntity } from '@modules/class/entity/class.entity'; import { GroupEntity } from '@modules/group/entity/group.entity'; + import { InternalServerErrorException } from '@nestjs/common/exceptions/internal-server-error.exception'; import { EntityWithSchool, Learnroom } from '@shared/domain/interface'; import { EntityId, LearnroomMetadata, LearnroomTypes } from '../types'; @@ -11,6 +12,10 @@ import { SchoolEntity } from './school.entity'; import type { TaskParent } from './task.entity'; import type { User } from './user.entity'; +export enum SyncAttribute { + TEACHERS = 'teachers', +} + export interface CourseProperties { name?: string; description?: string; @@ -27,6 +32,7 @@ export interface CourseProperties { classes?: ClassEntity[]; groups?: GroupEntity[]; syncedWithGroup?: GroupEntity; + excludeFromSync?: SyncAttribute[]; } // that is really really shit default handling :D constructor, getter, js default, em default...what the hell @@ -105,6 +111,9 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit @ManyToOne(() => GroupEntity, { nullable: true }) syncedWithGroup?: GroupEntity; + @Enum({ nullable: true, array: true }) + excludeFromSync?: SyncAttribute[]; + constructor(props: CourseProperties) { super(); this.name = props.name ?? DEFAULT.name; @@ -121,6 +130,7 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit this.classes.set(props.classes || []); this.groups.set(props.groups || []); if (props.syncedWithGroup) this.syncedWithGroup = props.syncedWithGroup; + if (props.excludeFromSync) this.excludeFromSync = props.excludeFromSync; } public getStudentIds(): EntityId[] { diff --git a/apps/server/src/shared/domain/types/school-feature.enum.ts b/apps/server/src/shared/domain/types/school-feature.enum.ts index da6f3cdf8fd..15da5924d0d 100644 --- a/apps/server/src/shared/domain/types/school-feature.enum.ts +++ b/apps/server/src/shared/domain/types/school-feature.enum.ts @@ -2,7 +2,7 @@ export enum SchoolFeature { ROCKET_CHAT = 'rocketChat', VIDEOCONFERENCE = 'videoconference', NEXTCLOUD = 'nextcloud', - /** @deprecated */ + /** @deprecated use STUDENT_LIST Permission instead */ STUDENTVISIBILITY = 'studentVisibility', LDAP_UNIVENTION_MIGRATION = 'ldapUniventionMigrationSchool', OAUTH_PROVISIONING_ENABLED = 'oauthProvisioningEnabled', diff --git a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts index 128d742001d..df17fe2acc6 100644 --- a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts @@ -76,6 +76,7 @@ describe('course repo', () => { 'classes', 'groups', 'syncedWithGroup', + 'excludeFromSync', ].sort(); expect(keysOfFirstElements).toEqual(expectedResult); }); diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index 1cf160a0fb6..9bcf81778ed 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -27,3 +27,4 @@ export * from './userloginmigration'; export * from './videoconference'; export * from './contextexternaltool'; export * from './externaltool'; +export { MongoDbScope, ScopeAggregateResult } from './mongodb-scope'; diff --git a/apps/server/src/shared/repo/mongodb-scope.spec.ts b/apps/server/src/shared/repo/mongodb-scope.spec.ts new file mode 100644 index 00000000000..9d8ffc9fa75 --- /dev/null +++ b/apps/server/src/shared/repo/mongodb-scope.spec.ts @@ -0,0 +1,49 @@ +import { IFindOptions, SortOrder } from '../domain/interface'; +import { MongoDbScope } from './mongodb-scope'; + +describe(MongoDbScope.name, () => { + class TestScope extends MongoDbScope {} + + describe('build', () => { + describe('when no options are given', () => { + it('should return the default facet query', () => { + const result = new TestScope().build(); + + expect(result).toEqual([ + { + $facet: { + total: [{ $count: 'count' }], + data: [{ $skip: 0 }], + }, + }, + ]); + }); + }); + + describe('when options are given', () => { + it('should return the facet query with pagination and order', () => { + const options: IFindOptions = { + pagination: { + skip: 12, + limit: 50, + }, + order: { + name: SortOrder.asc, + tree: SortOrder.desc, + }, + }; + + const result = new TestScope(options).build(); + + expect(result).toEqual([ + { + $facet: { + total: [{ $count: 'count' }], + data: [{ $sort: { name: 1, tree: -1 } }, { $skip: 12 }, { $limit: 50 }], + }, + }, + ]); + }); + }); + }); +}); diff --git a/apps/server/src/shared/repo/mongodb-scope.ts b/apps/server/src/shared/repo/mongodb-scope.ts new file mode 100644 index 00000000000..3cf7c727946 --- /dev/null +++ b/apps/server/src/shared/repo/mongodb-scope.ts @@ -0,0 +1,37 @@ +import { EntityDictionary } from '@mikro-orm/core'; +import { IFindOptions, SortOrder, SortOrderNumberType } from '../domain/interface'; + +export abstract class MongoDbScope { + protected pipeline: unknown[] = []; + + constructor(protected options?: IFindOptions) {} + + build(): unknown[] { + const optionsPipeline: unknown[] = []; + + if (this.options?.order) { + const sortObject: SortOrderNumberType = Object.fromEntries( + Object.entries(this.options?.order).map(([key, value]) => [key, value === SortOrder.asc ? 1 : -1]) + ); + + optionsPipeline.push({ $sort: sortObject }); + } + + optionsPipeline.push({ $skip: this.options?.pagination?.skip || 0 }); + + if (this.options?.pagination?.limit) { + optionsPipeline.push({ $limit: this.options.pagination.limit }); + } + + this.pipeline.push({ + $facet: { + total: [{ $count: 'count' }], + data: optionsPipeline, + }, + }); + + return this.pipeline; + } +} + +export type ScopeAggregateResult = [{ total: [{ count: number }]; data: EntityDictionary[] }]; diff --git a/backup/setup/groups.json b/backup/setup/groups.json index f57f515cb8c..db80207dc93 100644 --- a/backup/setup/groups.json +++ b/backup/setup/groups.json @@ -256,5 +256,48 @@ "$date": "2024-07-24T12:03:08.936Z" } } - } + }, + { + "_id": { + "$oid": "6720db23ee053f7be286494d" + }, + "createdAt": { + "$date": "2023-10-17T12:15:26.458Z" + }, + "updatedAt": { + "$date": "2023-10-17T12:15:26.461Z" + }, + "name": "Cypress-Test-Group-Partial-Course-Sync", + "type": "class", + "users": [ + { + "user": { + "$oid": "5fa2c77eb229544f2c696725" + }, + "role": { + "$oid": "0000d186816abba584714c98" + } + }, + { + "user": { + "$oid": "5fa2cccab229544f2c696917" + }, + "role": { + "$oid": "0000d186816abba584714c99" + } + } + ], + "organization": { + "$oid": "5fa2c5ccb229544f2c69666c" + }, + "externalSource": { + "externalId": "fd84869b-56e8-41d2-a3dd-6c7239068ed5", + "system": { + "$oid": "0000d186816abba584714c93" + }, + "lastSyncedAt": { + "$date": "2024-07-24T12:03:08.936Z" + } + } + } ] diff --git a/src/services/user-group/model.js b/src/services/user-group/model.js index d05fb217050..a7912f692a9 100644 --- a/src/services/user-group/model.js +++ b/src/services/user-group/model.js @@ -7,6 +7,10 @@ const { Schema } = mongoose; const COURSE_FEATURES = { VIDEOCONFERENCE: 'videoconference', }; + +const SYNC_ATTRIBUTE = { + TEACHERS: 'teachers', +}; // not all pros exist in new entity const getUserGroupSchema = (additional = {}) => { const schema = { @@ -62,6 +66,7 @@ const courseSchema = getUserGroupSchema({ isCopyFrom: { type: Schema.Types.ObjectId, default: null }, features: [{ type: String, enum: Object.values(COURSE_FEATURES) }], syncedWithGroup: { type: Schema.Types.ObjectId }, + excludeFromSync: [{ type: String, enum: Object.values(SYNC_ATTRIBUTE) }], ...externalSourceSchema, }); @@ -154,4 +159,5 @@ module.exports = { courseGroupModel, classModel, getClassDisplayName, + SYNC_ATTRIBUTE, };