diff --git a/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts b/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts new file mode 100644 index 00000000000..c18d21eb11c --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration202410041210124.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration202410041210124 extends Migration { + async up(): Promise { + // Add ROOM_VIEWER role + await this.getCollection('roles').insertOne({ + name: 'room_viewer', + permissions: ['ROOM_VIEW'], + }); + console.info('Added ROOM_VIEWER role with ROOM_VIEW permission'); + + // Add ROOM_EDITOR role + await this.getCollection('roles').insertOne({ + name: 'room_editor', + permissions: ['ROOM_VIEW', 'ROOM_EDIT'], + }); + console.info('Added ROOM_EDITOR role with ROOM_VIEW and ROOM_EDIT permissions'); + } + + async down(): Promise { + // Remove ROOM_VIEWER role + await this.getCollection('roles').deleteOne({ name: 'ROOM_VIEWER' }); + console.info('Rollback: Removed ROOM_VIEWER role'); + + // Remove ROOM_EDITOR role + await this.getCollection('roles').deleteOne({ name: 'ROOM_EDITOR' }); + console.info('Rollback: Removed ROOM_EDITOR role'); + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-type.response.ts b/apps/server/src/modules/group/controller/dto/response/group-type.response.ts index 210cbbb797d..c4f461163e8 100644 --- a/apps/server/src/modules/group/controller/dto/response/group-type.response.ts +++ b/apps/server/src/modules/group/controller/dto/response/group-type.response.ts @@ -1,5 +1,6 @@ export enum GroupTypeResponse { CLASS = 'class', COURSE = 'course', + ROOM = 'room', OTHER = 'other', } 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 index d0fd7081e20..ddecf734e03 100644 --- a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -17,6 +17,7 @@ import { PeriodResponse } from '../dto/response/period.response'; const typeMapping: Record = { [GroupTypes.CLASS]: GroupTypeResponse.CLASS, [GroupTypes.COURSE]: GroupTypeResponse.COURSE, + [GroupTypes.ROOM]: GroupTypeResponse.ROOM, [GroupTypes.OTHER]: GroupTypeResponse.OTHER, }; diff --git a/apps/server/src/modules/group/domain/group-types.ts b/apps/server/src/modules/group/domain/group-types.ts index 23e11ca5af6..bac6120b522 100644 --- a/apps/server/src/modules/group/domain/group-types.ts +++ b/apps/server/src/modules/group/domain/group-types.ts @@ -1,5 +1,6 @@ export enum GroupTypes { CLASS = 'class', COURSE = 'course', + ROOM = 'room', OTHER = 'other', } diff --git a/apps/server/src/modules/group/entity/group.entity.ts b/apps/server/src/modules/group/entity/group.entity.ts index 78d04ea10ff..86fec84b6a4 100644 --- a/apps/server/src/modules/group/entity/group.entity.ts +++ b/apps/server/src/modules/group/entity/group.entity.ts @@ -9,6 +9,7 @@ import { GroupValidPeriodEmbeddable } from './group-valid-period.embeddable'; export enum GroupEntityTypes { CLASS = 'class', COURSE = 'course', + ROOM = 'room', OTHER = 'other', } diff --git a/apps/server/src/modules/group/group.module.ts b/apps/server/src/modules/group/group.module.ts index fe232eefa2b..252132b7ef0 100644 --- a/apps/server/src/modules/group/group.module.ts +++ b/apps/server/src/modules/group/group.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { GroupRepo } from './repo'; import { GroupService } from './service'; +import { UserModule } from '../user'; +import { RoleModule } from '../role'; @Module({ - imports: [CqrsModule], + imports: [UserModule, RoleModule, CqrsModule], providers: [GroupRepo, GroupService], exports: [GroupService], }) diff --git a/apps/server/src/modules/group/repo/group-domain.mapper.ts b/apps/server/src/modules/group/repo/group-domain.mapper.ts index 2a8247dccbf..0717d6c1ad9 100644 --- a/apps/server/src/modules/group/repo/group-domain.mapper.ts +++ b/apps/server/src/modules/group/repo/group-domain.mapper.ts @@ -9,12 +9,14 @@ import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable, GroupValidPeriodEmb const GroupEntityTypesToGroupTypesMapping: Record = { [GroupEntityTypes.CLASS]: GroupTypes.CLASS, [GroupEntityTypes.COURSE]: GroupTypes.COURSE, + [GroupEntityTypes.ROOM]: GroupTypes.ROOM, [GroupEntityTypes.OTHER]: GroupTypes.OTHER, }; export const GroupTypesToGroupEntityTypesMapping: Record = { [GroupTypes.CLASS]: GroupEntityTypes.CLASS, [GroupTypes.COURSE]: GroupEntityTypes.COURSE, + [GroupTypes.ROOM]: GroupEntityTypes.ROOM, [GroupTypes.OTHER]: GroupEntityTypes.OTHER, }; 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 0edee200fc4..0b260ebfc6c 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -100,11 +100,12 @@ describe('GroupRepo', () => { const setup = async () => { const userEntity: User = userFactory.buildWithId(); const userId: EntityId = userEntity.id; - const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(4, { users: [{ user: userEntity, role: roleFactory.buildWithId() }], }); groups[1].type = GroupEntityTypes.COURSE; groups[2].type = GroupEntityTypes.OTHER; + groups[3].type = GroupEntityTypes.ROOM; const nameQuery = groups[1].name.slice(-3); @@ -137,7 +138,6 @@ describe('GroupRepo', () => { expect(result.total).toEqual(groups.length); expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[1].id); }); it('should return groups according to name query', async () => { @@ -152,9 +152,11 @@ describe('GroupRepo', () => { it('should return only groups of the given group types', async () => { const { userId } = await setup(); - const result: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.CLASS] }); + const resultClass: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.CLASS] }); + expect(resultClass.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); - expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + const resultRoom: Page = await repo.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); + expect(resultRoom.data).toEqual([expect.objectContaining>({ type: GroupTypes.ROOM })]); }); }); @@ -513,7 +515,7 @@ describe('GroupRepo', () => { expect(result.total).toEqual(availableGroupsCount); expect(result.data.length).toEqual(1); - expect(result.data[0].id).toEqual(groups[2].id); + expect(result.data[0].id).toEqual(groups[1].id); }); it('should return groups according to name query', async () => { 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 ca115317e9c..ce69c4e385b 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -4,8 +4,11 @@ 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 { EntityId } from '@shared/domain/types'; -import { groupFactory } from '@shared/testing'; +import { groupFactory, roleDtoFactory, userDoFactory } from '@shared/testing'; +import { RoleService } from '@src/modules/role'; +import { UserService } from '@src/modules/user'; import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -13,7 +16,8 @@ import { GroupService } from './group.service'; describe('GroupService', () => { let module: TestingModule; let service: GroupService; - + let roleService: DeepMocked; + let userService: DeepMocked; let groupRepo: DeepMocked; let eventBus: DeepMocked; @@ -25,6 +29,14 @@ describe('GroupService', () => { provide: GroupRepo, useValue: createMock(), }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, { provide: EventBus, useValue: createMock(), @@ -33,6 +45,8 @@ describe('GroupService', () => { }).compile(); service = module.get(GroupService); + roleService = module.get(RoleService); + userService = module.get(UserService); groupRepo = module.get(GroupRepo); eventBus = module.get(EventBus); }); @@ -406,4 +420,75 @@ describe('GroupService', () => { }); }); }); + + describe('createGroup', () => { + describe('when creating a group with a school', () => { + it('should call repo.save', async () => { + await service.createGroup('name', GroupTypes.CLASS, 'schoolId'); + + expect(groupRepo.save).toHaveBeenCalledWith(expect.any(Group)); + }); + + it('should save the group properties', async () => { + await service.createGroup('name', GroupTypes.CLASS, 'schoolId'); + + expect(groupRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ name: 'name', type: GroupTypes.CLASS, organizationId: 'schoolId' }) + ); + }); + }); + }); + + describe('addUserToGroup', () => { + const setup = () => { + const roleDto = roleDtoFactory.buildWithId({ name: RoleName.STUDENT }); + roleService.findByName.mockResolvedValue(roleDto); + + const userDo = userDoFactory.build(); + userService.findById.mockResolvedValue(userDo); + + const group = groupFactory.build(); + groupRepo.findGroupById.mockResolvedValue(group); + + return { group, userDo, roleDto }; + }; + + describe('when adding a user to a group', () => { + it('should call roleService.findByName', async () => { + setup(); + await service.addUserToGroup('groupId', 'userId', RoleName.STUDENT); + + expect(roleService.findByName).toHaveBeenCalledWith(RoleName.STUDENT); + }); + + it('should call userService.findById', async () => { + setup(); + await service.addUserToGroup('groupId', 'userId', RoleName.STUDENT); + + expect(userService.findById).toHaveBeenCalledWith('userId'); + }); + + it('should call groupRepo.findGroupById', async () => { + setup(); + await service.addUserToGroup('groupId', 'userId', RoleName.STUDENT); + + expect(groupRepo.findGroupById).toHaveBeenCalledWith('groupId'); + }); + + it('should call group.addUser', async () => { + const { group, userDo, roleDto } = setup(); + jest.spyOn(group, 'addUser'); + await service.addUserToGroup('groupId', 'userId', RoleName.STUDENT); + + expect(group.addUser).toHaveBeenCalledWith(expect.objectContaining({ userId: userDo.id, roleId: roleDto.id })); + }); + + it('should call groupRepo.save', async () => { + const { group } = setup(); + await service.addUserToGroup('groupId', 'userId', RoleName.STUDENT); + + expect(groupRepo.save).toHaveBeenCalledWith(group); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index bbf41827624..2b91d182f65 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -1,16 +1,24 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { EventBus } from '@nestjs/cqrs'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page } from '@shared/domain/domainobject'; -import { IFindOptions } from '@shared/domain/interface'; +import { IFindOptions, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { Group, GroupDeletedEvent, GroupFilter } from '../domain'; +import { RoleService } from '@src/modules/role'; +import { UserService } from '@src/modules/user/service/user.service'; +import { Group, GroupDeletedEvent, GroupFilter, GroupTypes, GroupUser } from '../domain'; import { GroupRepo } from '../repo'; @Injectable() export class GroupService implements AuthorizationLoaderServiceGeneric { - constructor(private readonly groupRepo: GroupRepo, private readonly eventBus: EventBus) {} + constructor( + private readonly groupRepo: GroupRepo, + private readonly userService: UserService, + private readonly roleService: RoleService, + private readonly eventBus: EventBus + ) {} public async findById(id: EntityId): Promise { const group: Group | null = await this.groupRepo.findGroupById(id); @@ -57,4 +65,31 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { await this.eventBus.publish(new GroupDeletedEvent(group)); } + + public async createGroup(name: string, type: GroupTypes, organizationId?: EntityId): Promise { + const group = new Group({ + name, + users: [], + id: new ObjectId().toHexString(), + type, + organizationId, + }); + + await this.save(group); + + return group; + } + + 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.'); + 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 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const groupUser = new GroupUser({ roleId: role.id, userId: user.id! }); + + group.addUser(groupUser); + await this.save(group); + } } diff --git a/apps/server/src/modules/room-member/authorization/room-member.rule.spec.ts b/apps/server/src/modules/room-member/authorization/room-member.rule.spec.ts new file mode 100644 index 00000000000..1ca26adb941 --- /dev/null +++ b/apps/server/src/modules/room-member/authorization/room-member.rule.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { roleDtoFactory, setupEntities, userFactory } from '@shared/testing'; +import { Action, AuthorizationHelper, AuthorizationInjectionService } from '@src/modules/authorization'; +import { RoomMemberAuthorizable } from '../do/room-member-authorizable.do'; +import { RoomMemberRule } from './room-member.rule'; + +describe(RoomMemberRule.name, () => { + let service: RoomMemberRule; + let injectionService: AuthorizationInjectionService; + + beforeAll(async () => { + await setupEntities(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [RoomMemberRule, AuthorizationHelper, AuthorizationInjectionService], + }).compile(); + + service = await module.get(RoomMemberRule); + injectionService = await module.get(AuthorizationInjectionService); + }); + + describe('injection', () => { + it('should inject itself into authorisation module', () => { + expect(injectionService.getAuthorizationRules()).toContain(service); + }); + }); + + describe('isApplicable', () => { + describe('when entity is applicable', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roomMemberAuthorizable = new RoomMemberAuthorizable('', []); + + return { user, roomMemberAuthorizable }; + }; + + it('should return true', () => { + const { user, roomMemberAuthorizable } = setup(); + const result = service.isApplicable(user, roomMemberAuthorizable); + + expect(result).toStrictEqual(true); + }); + }); + + describe('when entity is not applicable', () => { + const setup = () => { + const user = userFactory.build(); + return { user }; + }; + + it('should return false', () => { + const { user } = setup(); + + const result = service.isApplicable(user, user); + + expect(result).toStrictEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when user is viewer member of room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roleDto = roleDtoFactory.build({ permissions: [Permission.ROOM_VIEW] }); + const roomMemberAuthorizable = new RoomMemberAuthorizable('', [{ roles: [roleDto], userId: user.id }]); + + return { user, roomMemberAuthorizable }; + }; + + it('should return "true" for read action', () => { + const { user, roomMemberAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMemberAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + + it('should return "false" for write action', () => { + const { user, roomMemberAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMemberAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); + + describe('when user is not member of room', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const roomMemberAuthorizable = new RoomMemberAuthorizable('', []); + + return { user, roomMemberAuthorizable }; + }; + + it('should return "false" for read action', () => { + const { user, roomMemberAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMemberAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + + it('should return "false" for write action', () => { + const { user, roomMemberAuthorizable } = setup(); + + const res = service.hasPermission(user, roomMemberAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room-member/authorization/room-member.rule.ts b/apps/server/src/modules/room-member/authorization/room-member.rule.ts new file mode 100644 index 00000000000..7a98c93a215 --- /dev/null +++ b/apps/server/src/modules/room-member/authorization/room-member.rule.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { AuthorizationInjectionService, Action, AuthorizationContext, Rule } from '@src/modules/authorization'; +import { RoomMemberAuthorizable } from '../do/room-member-authorizable.do'; + +@Injectable() +export class RoomMemberRule implements Rule { + constructor(private readonly authorisationInjectionService: AuthorizationInjectionService) { + this.authorisationInjectionService.injectAuthorizationRule(this); + } + + public isApplicable(user: User, object: unknown): boolean { + const isMatched = object instanceof RoomMemberAuthorizable; + + return isMatched; + } + + public hasPermission(user: User, object: RoomMemberAuthorizable, context: AuthorizationContext): boolean { + const { action } = context; + const permissionsThisUserHas = object.members + .filter((member) => member.userId === user.id) + .flatMap((member) => member.roles) + .flatMap((role) => role.permissions ?? []); + + if (action === Action.read) { + return permissionsThisUserHas.includes(Permission.ROOM_VIEW); + } + return permissionsThisUserHas.includes(Permission.ROOM_EDIT); + } +} diff --git a/apps/server/src/modules/room-member/do/room-member-authorizable.do.ts b/apps/server/src/modules/room-member/do/room-member-authorizable.do.ts new file mode 100644 index 00000000000..1728d0045b9 --- /dev/null +++ b/apps/server/src/modules/room-member/do/room-member-authorizable.do.ts @@ -0,0 +1,21 @@ +import { AuthorizableObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; +import { RoleDto } from '@src/modules/role'; + +export type UserWithRoomRoles = { + roles: RoleDto[]; + userId: EntityId; +}; + +export class RoomMemberAuthorizable implements AuthorizableObject { + public readonly id: EntityId = ''; + + public readonly roomId: EntityId; + + public readonly members: UserWithRoomRoles[]; + + public constructor(roomId: EntityId, members: UserWithRoomRoles[]) { + this.members = members; + this.roomId = roomId; + } +} diff --git a/apps/server/src/modules/room-member/do/room-member.do.spec.ts b/apps/server/src/modules/room-member/do/room-member.do.spec.ts new file mode 100644 index 00000000000..d7ed3fb03ac --- /dev/null +++ b/apps/server/src/modules/room-member/do/room-member.do.spec.ts @@ -0,0 +1,42 @@ +import { EntityId } from '@shared/domain/types'; +import { roomMemberFactory } from '../testing'; +import { RoomMember, RoomMemberProps } from './room-member.do'; + +describe('RoomMember', () => { + let roomMember: RoomMember; + const roomMemberId: EntityId = 'roomMemberId'; + const roomMemberProps: RoomMemberProps = { + id: roomMemberId, + roomId: 'roomId', + userGroupId: 'userGroupId', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + beforeEach(() => { + roomMember = new RoomMember(roomMemberProps); + }); + + it('should props without domainObject', () => { + const mockDomainObject = roomMemberFactory.build(); + // this tests the hotfix for the mikro-orm issue + // eslint-disable-next-line @typescript-eslint/dot-notation + roomMember['domainObject'] = mockDomainObject; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { domainObject, ...props } = roomMember.getProps(); + + expect(domainObject).toEqual(undefined); + expect(props).toEqual(roomMemberProps); + }); + + it('should get roomId', () => { + expect(roomMember.roomId).toEqual(roomMemberProps.roomId); + }); + + it('should get userGroupId', () => { + expect(roomMember.userGroupId).toEqual(roomMemberProps.userGroupId); + }); +}); diff --git a/apps/server/src/modules/room-member/do/room-member.do.ts b/apps/server/src/modules/room-member/do/room-member.do.ts new file mode 100644 index 00000000000..8aeebfffbda --- /dev/null +++ b/apps/server/src/modules/room-member/do/room-member.do.ts @@ -0,0 +1,34 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; + +export interface RoomMemberProps extends AuthorizableObject { + id: EntityId; + roomId: EntityId; + userGroupId: EntityId; + createdAt: Date; + updatedAt: Date; +} + +export class RoomMember extends DomainObject { + public constructor(props: RoomMemberProps) { + super(props); + } + + public getProps(): RoomMemberProps { + // Note: Propagated hotfix. Will be resolved with mikro-orm update. Look at the comment in board-node.do.ts. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { domainObject, ...copyProps } = this.props; + + return copyProps; + } + + public get roomId(): EntityId { + return this.props.roomId; + } + + public get userGroupId(): EntityId { + return this.props.userGroupId; + } +} diff --git a/apps/server/src/modules/room-member/index.ts b/apps/server/src/modules/room-member/index.ts new file mode 100644 index 00000000000..72c46691fd7 --- /dev/null +++ b/apps/server/src/modules/room-member/index.ts @@ -0,0 +1,7 @@ +import { RoomMemberEntity } from './repo/entity'; +import { RoomMemberRepo } from './repo/room-member.repo'; +import { RoomMemberService } from './service/room-member.service'; + +export * from './do/room-member.do'; +export * from './room-member.module'; +export { RoomMemberEntity, RoomMemberRepo, RoomMemberService }; diff --git a/apps/server/src/modules/room-member/repo/entity/index.ts b/apps/server/src/modules/room-member/repo/entity/index.ts new file mode 100644 index 00000000000..845b87253da --- /dev/null +++ b/apps/server/src/modules/room-member/repo/entity/index.ts @@ -0,0 +1 @@ +export * from './room-member.entity'; diff --git a/apps/server/src/modules/room-member/repo/entity/room-member.entity.ts b/apps/server/src/modules/room-member/repo/entity/room-member.entity.ts new file mode 100644 index 00000000000..95cbf802452 --- /dev/null +++ b/apps/server/src/modules/room-member/repo/entity/room-member.entity.ts @@ -0,0 +1,29 @@ +import { Entity, Property, Unique } from '@mikro-orm/core'; +import { AuthorizableObject } from '@shared/domain/domain-object'; +import { ObjectIdType } from '@shared/repo/types/object-id.type'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain/types'; +import { RoomMember } from '../../do/room-member.do'; + +export interface RoomMemberEntityProps extends AuthorizableObject { + id: EntityId; + roomId: EntityId; + userGroupId: EntityId; + createdAt: Date; + updatedAt: Date; +} + +@Entity({ tableName: 'room-members' }) +@Unique({ properties: ['roomId', 'userGroupId'] }) +export class RoomMemberEntity extends BaseEntityWithTimestamps implements RoomMemberEntityProps { + @Property() + @Unique() + @Property({ type: ObjectIdType }) + roomId!: EntityId; + + @Property({ type: ObjectIdType }) + userGroupId!: EntityId; + + @Property({ persist: false }) + domainObject: RoomMember | undefined; +} diff --git a/apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts b/apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts new file mode 100644 index 00000000000..d2629ebef65 --- /dev/null +++ b/apps/server/src/modules/room-member/repo/room-member-domain.mapper.spec.ts @@ -0,0 +1,93 @@ +import { RoomMember, RoomMemberProps } from '../do/room-member.do'; +import { roomMemberEntityFactory } from '../testing'; +import { RoomMemberEntity } from './entity'; +import { RoomMemberDomainMapper } from './room-member-domain.mapper'; + +describe('RoomMemberDomainMapper', () => { + describe('mapEntityToDo', () => { + it('should correctly map roomMemberEntity to RoomMember domain object', () => { + const roomMemberEntity = { + id: '1', + } as RoomMemberEntity; + + const result = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntity); + + expect(result).toBeInstanceOf(RoomMember); + expect(result.getProps()).toEqual({ + id: '1', + }); + }); + + it('should return existing domainObject if present, regardless of entity properties', () => { + const existingRoomMember = new RoomMember({ + id: '1', + roomId: 'r1', + userGroupId: 'ug1', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + }); + + const roomMemberEntity = { + id: '1', + domainObject: existingRoomMember, + } as RoomMemberEntity; + + const result = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntity); + + expect(result).toBe(existingRoomMember); + expect(result).toBeInstanceOf(RoomMember); + expect(result.getProps()).toEqual({ + id: '1', + roomId: 'r1', + userGroupId: 'ug1', + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + }); + expect(result.getProps().id).toBe('1'); + expect(result.getProps().id).toBe(roomMemberEntity.id); + }); + + it('should wrap the actual entity reference in the domain object', () => { + const roomMemberEntity = { + id: '1', + } as RoomMemberEntity; + + const result = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntity); + // @ts-expect-error check necessary + const { props } = result; + + expect(props === roomMemberEntity).toBe(true); + }); + }); + + describe('mapDoToEntity', () => { + describe('when domain object props are instanceof roomMemberEntity', () => { + it('should return the entity', () => { + const roomMemberEntity = roomMemberEntityFactory.build(); + const roomMember = new RoomMember(roomMemberEntity); + + const result = RoomMemberDomainMapper.mapDoToEntity(roomMember); + + expect(result).toBe(roomMemberEntity); + }); + }); + + describe('when domain object props are not instanceof roomMemberEntity', () => { + it('should convert them to an entity and return it', () => { + const roomMemberEntity: RoomMemberProps = { + id: '66d581c3ef74c548a4efea1d', + roomId: '66d581c3ef74c548a4efea1a', + userGroupId: '66d581c3ef74c548a4efea1b', + createdAt: new Date('2024-10-1'), + updatedAt: new Date('2024-10-1'), + }; + const room = new RoomMember(roomMemberEntity); + + const result = RoomMemberDomainMapper.mapDoToEntity(room); + + expect(result).toBeInstanceOf(RoomMemberEntity); + expect(result).toMatchObject(roomMemberEntity); + }); + }); + }); +}); diff --git a/apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts b/apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts new file mode 100644 index 00000000000..73163a07769 --- /dev/null +++ b/apps/server/src/modules/room-member/repo/room-member-domain.mapper.ts @@ -0,0 +1,37 @@ +import { RoomMember } from '../do/room-member.do'; +import { RoomMemberEntity } from './entity'; + +export class RoomMemberDomainMapper { + static mapEntityToDo(roomMemberEntity: RoomMemberEntity): RoomMember { + // check identity map reference + if (roomMemberEntity.domainObject) { + return roomMemberEntity.domainObject; + } + + const roomMember = new RoomMember(roomMemberEntity); + + // attach to identity map + roomMemberEntity.domainObject = roomMember; + + return roomMember; + } + + static mapDoToEntity(roomMember: RoomMember): RoomMemberEntity { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { props } = roomMember; + + if (!(props instanceof RoomMemberEntity)) { + const entity = new RoomMemberEntity(); + Object.assign(entity, props); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + roomMember.props = entity; + + return entity; + } + + return props; + } +} diff --git a/apps/server/src/modules/room-member/repo/room-member.repo.spec.ts b/apps/server/src/modules/room-member/repo/room-member.repo.spec.ts new file mode 100644 index 00000000000..3cf9364019e --- /dev/null +++ b/apps/server/src/modules/room-member/repo/room-member.repo.spec.ts @@ -0,0 +1,155 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { NotFoundError } from '@mikro-orm/core'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { RoomMember } from '../do/room-member.do'; +import { roomMemberEntityFactory, roomMemberFactory } from '../testing'; +import { RoomMemberEntity } from './entity'; +import { RoomMemberRepo } from './room-member.repo'; + +describe('RoomMemberRepo', () => { + let module: TestingModule; + let repo: RoomMemberRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [RoomMemberRepo], + }).compile(); + + repo = module.get(RoomMemberRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('findByRoomId', () => { + const setup = async () => { + const roomMemberEntity = roomMemberEntityFactory.buildWithId(); + await em.persistAndFlush([roomMemberEntity]); + em.clear(); + + return { roomMemberEntity }; + }; + + it('should find room member by roomId', async () => { + const { roomMemberEntity } = await setup(); + + const roomMember = await repo.findByRoomId(roomMemberEntity.roomId); + + expect(roomMember).toBeDefined(); + expect(roomMember?.getProps()).toEqual(roomMemberEntity); + }); + }); + + describe('findByRoomIds', () => { + const setup = async () => { + const roomId1 = new ObjectId().toHexString(); + const roomId2 = new ObjectId().toHexString(); + + const roomMemberEntities = [ + roomMemberEntityFactory.buildWithId({ roomId: roomId1 }), + roomMemberEntityFactory.buildWithId({ roomId: roomId1 }), + roomMemberEntityFactory.buildWithId({ roomId: roomId2 }), + ]; + + await em.persistAndFlush(roomMemberEntities); + em.clear(); + + return { roomMemberEntities, roomId1, roomId2 }; + }; + + it('should find room member by roomIds', async () => { + const { roomId1, roomId2 } = await setup(); + + const roomMembers = await repo.findByRoomIds([roomId1, roomId2]); + + expect(roomMembers).toHaveLength(3); + }); + }); + + describe('findByGroupId', () => { + const setup = async () => { + const groupId = new ObjectId().toHexString(); + const roomMemberEntities = [ + roomMemberEntityFactory.build({ userGroupId: groupId }), + roomMemberEntityFactory.build({ userGroupId: groupId }), + roomMemberEntityFactory.build({ userGroupId: new ObjectId().toHexString() }), + ]; + + await em.persistAndFlush(roomMemberEntities); + em.clear(); + + return { roomMemberEntities, groupId }; + }; + + it('should find room members by groupId', async () => { + const { groupId } = await setup(); + + const roomMembers = await repo.findByGroupId(groupId); + + expect(roomMembers).toHaveLength(2); + }); + }); + + describe('save', () => { + const setup = () => { + const roomMembers = roomMemberFactory.buildList(3); + return { roomMembers }; + }; + + it('should be able to persist a single room member', async () => { + const { roomMembers } = setup(); + + await repo.save(roomMembers[0]); + const result = await em.findOneOrFail(RoomMemberEntity, roomMembers[0].id); + + expect(roomMembers[0].getProps()).toMatchObject(result); + }); + + it('should be able to persist many room members', async () => { + const { roomMembers } = setup(); + + await repo.save(roomMembers); + const result = await em.find(RoomMemberEntity, { id: { $in: roomMembers.map((r) => r.id) } }); + + expect(result.length).toBe(roomMembers.length); + }); + }); + + describe('delete', () => { + const setup = async () => { + const roomMemberEntities = roomMemberEntityFactory.buildListWithId(3); + await em.persistAndFlush(roomMemberEntities); + const roomMembers = roomMemberEntities.map((entity) => new RoomMember(entity)); + em.clear(); + + return { roomMembers }; + }; + + it('should be able to delete a single room member', async () => { + const { roomMembers } = await setup(); + + await repo.delete(roomMembers[0]); + + await expect(em.findOneOrFail(RoomMemberEntity, roomMembers[0].id)).rejects.toThrow(NotFoundError); + }); + + it('should be able to delete many rooms', async () => { + const { roomMembers } = await setup(); + + await repo.delete(roomMembers); + + const remainingCount = await em.count(RoomMemberEntity); + expect(remainingCount).toBe(0); + }); + }); +}); diff --git a/apps/server/src/modules/room-member/repo/room-member.repo.ts b/apps/server/src/modules/room-member/repo/room-member.repo.ts new file mode 100644 index 00000000000..022383fffaf --- /dev/null +++ b/apps/server/src/modules/room-member/repo/room-member.repo.ts @@ -0,0 +1,64 @@ +import { Utils } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { RoomMember } from '../do/room-member.do'; +import { RoomMemberEntity } from './entity'; +import { RoomMemberDomainMapper } from './room-member-domain.mapper'; + +@Injectable() +export class RoomMemberRepo { + constructor(private readonly em: EntityManager) {} + + async findByRoomId(roomId: EntityId): Promise { + const roomMemberEntities = await this.em.findOne(RoomMemberEntity, { roomId }); + if (!roomMemberEntities) return null; + + const roomMembers = RoomMemberDomainMapper.mapEntityToDo(roomMemberEntities); + + return roomMembers; + } + + async findByRoomIds(roomIds: EntityId[]): Promise { + const entities = await this.em.find(RoomMemberEntity, { roomId: { $in: roomIds } }); + const roomMembers = entities.map((entity) => RoomMemberDomainMapper.mapEntityToDo(entity)); + + return roomMembers; + } + + async findByGroupId(groupId: EntityId): Promise { + const entities = await this.em.find(RoomMemberEntity, { userGroupId: groupId }); + const roomMembers = entities.map((entity) => RoomMemberDomainMapper.mapEntityToDo(entity)); + + return roomMembers; + } + + async findByGroupIds(groupIds: EntityId[]): Promise { + const entities = await this.em.find(RoomMemberEntity, { userGroupId: { $in: groupIds } }); + const roomMembers = entities.map((entity) => RoomMemberDomainMapper.mapEntityToDo(entity)); + + return roomMembers; + } + + async save(roomMember: RoomMember | RoomMember[]): Promise { + const roomMembers = Utils.asArray(roomMember); + + roomMembers.forEach((member) => { + const entity = RoomMemberDomainMapper.mapDoToEntity(member); + this.em.persist(entity); + }); + + await this.em.flush(); + } + + async delete(roomMember: RoomMember | RoomMember[]): Promise { + const roomMembers = Utils.asArray(roomMember); + + roomMembers.forEach((member) => { + const entity = RoomMemberDomainMapper.mapDoToEntity(member); + this.em.remove(entity); + }); + + await this.em.flush(); + } +} diff --git a/apps/server/src/modules/room-member/room-member.module.ts b/apps/server/src/modules/room-member/room-member.module.ts new file mode 100644 index 00000000000..b07ace89160 --- /dev/null +++ b/apps/server/src/modules/room-member/room-member.module.ts @@ -0,0 +1,15 @@ +import { GroupModule } from '@modules/group'; +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { AuthorizationModule } from '../authorization'; +import { RoleModule } from '../role'; +import { RoomMemberRule } from './authorization/room-member.rule'; +import { RoomMemberRepo } from './repo/room-member.repo'; +import { RoomMemberService } from './service/room-member.service'; + +@Module({ + imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule], + providers: [RoomMemberService, RoomMemberRepo, RoomMemberRule], + exports: [RoomMemberService], +}) +export class RoomMemberModule {} diff --git a/apps/server/src/modules/room-member/service/room-member.service.spec.ts b/apps/server/src/modules/room-member/service/room-member.service.spec.ts new file mode 100644 index 00000000000..e001634c8cf --- /dev/null +++ b/apps/server/src/modules/room-member/service/room-member.service.spec.ts @@ -0,0 +1,232 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RoleName } from '@shared/domain/interface'; +import { groupFactory, roleDtoFactory, userFactory } from '@shared/testing'; +import { MongoMemoryDatabaseModule } from '@src/infra/database'; +import { GroupService, GroupTypes } from '@src/modules/group'; +import { RoleService } from '@src/modules/role'; +import { roomFactory } from '@src/modules/room/testing'; +import { RoomMemberRepo } from '../repo/room-member.repo'; +import { roomMemberFactory } from '../testing'; +import { RoomMemberService } from './room-member.service'; +import { RoomMemberAuthorizable } from '../do/room-member-authorizable.do'; + +describe('RoomMemberService', () => { + let module: TestingModule; + let service: RoomMemberService; + let roomMemberRepo: DeepMocked; + let groupService: DeepMocked; + let roleService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [ + RoomMemberService, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: RoomMemberRepo, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RoomMemberService); + roomMemberRepo = module.get(RoomMemberRepo); + groupService = module.get(GroupService); + roleService = module.get(RoleService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('addMemberToRoom', () => { + describe('when room member does not exist', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const room = roomFactory.build(); + const group = groupFactory.build({ type: GroupTypes.ROOM }); + + roomMemberRepo.findByRoomId.mockResolvedValue(null); + groupService.createGroup.mockResolvedValue(group); + groupService.addUserToGroup.mockResolvedValue(); + roomMemberRepo.save.mockResolvedValue(); + + return { + user, + room, + }; + }; + + it('should create new room member when not exists', async () => { + const { user, room } = setup(); + await service.addMemberToRoom(room.id, user.id, RoleName.ROOM_EDITOR); + expect(roomMemberRepo.save).toHaveBeenCalled(); + }); + }); + + describe('when room member exists', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const group = groupFactory.build({ type: GroupTypes.ROOM }); + const room = roomFactory.build(); + const roomMember = roomMemberFactory.build({ roomId: room.id, userGroupId: group.id }); + + roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + + return { + user, + room, + roomMember, + group, + }; + }; + + it('should add user to existing room member', async () => { + const { user, room, group } = setup(); + await service.addMemberToRoom(room.id, user.id, RoleName.ROOM_EDITOR); + expect(groupService.addUserToGroup).toHaveBeenCalledWith(group.id, user.id, RoleName.ROOM_EDITOR); + }); + }); + }); + + describe('deleteRoomMember', () => { + describe('when room member does not exist', () => { + const setup = () => { + roomMemberRepo.findByRoomId.mockResolvedValue(null); + }; + + it('no nothing', async () => { + setup(); + await service.deleteRoomMember('roomId'); + expect(groupService.delete).not.toHaveBeenCalled(); + expect(roomMemberRepo.delete).not.toHaveBeenCalled(); + }); + }); + + describe('when room member exists', () => { + const setup = () => { + const group = groupFactory.build(); + const roomMember = roomMemberFactory.build({ userGroupId: group.id }); + roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + groupService.findById.mockResolvedValue(group); + + return { roomMember, group }; + }; + + it('should call delete group and room member', async () => { + const { roomMember, group } = setup(); + await service.deleteRoomMember(roomMember.roomId); + expect(groupService.delete).toHaveBeenCalledWith(group); + expect(roomMemberRepo.delete).toHaveBeenCalledWith(roomMember); + }); + }); + }); + + describe('getRoomMemberAuthorizable', () => { + const setup = () => { + const roomId = 'room123'; + const userId = 'user456'; + const groupId = 'group789'; + const roleId = 'role101'; + + const roomMember = roomMemberFactory.build({ roomId, userGroupId: groupId }); + const group = groupFactory.build({ id: groupId, users: [{ userId, roleId }] }); + const role = roleDtoFactory.build({ id: roleId }); + + roomMemberRepo.findByRoomId.mockResolvedValue(roomMember); + groupService.findById.mockResolvedValue(group); + roleService.findByIds.mockResolvedValue([role]); + + return { roomId, userId, groupId, roleId, roomMember, group, role }; + }; + + it('should return RoomMemberAuthorizable when room member exists', async () => { + const { roomId, userId, roleId } = setup(); + + const result = await service.getRoomMemberAuthorizable(roomId); + + expect(result).toBeInstanceOf(RoomMemberAuthorizable); + expect(result.roomId).toBe(roomId); + expect(result.members).toHaveLength(1); + expect(result.members[0].userId).toBe(userId); + expect(result.members[0].roles[0].id).toBe(roleId); + }); + + it('should return empty RoomMemberAuthorizable when room member not exists', async () => { + const roomId = 'nonexistent'; + roomMemberRepo.findByRoomId.mockResolvedValue(null); + + const result = await service.getRoomMemberAuthorizable(roomId); + + expect(result).toBeInstanceOf(RoomMemberAuthorizable); + expect(result.roomId).toBe(roomId); + expect(result.members).toHaveLength(0); + }); + }); + + describe('getRoomMemberAuthorizablesByUserId', () => { + const setup = () => { + const userId = 'user123'; + const groupId1 = 'group456'; + const groupId2 = 'group789'; + const roomId1 = 'room111'; + const roomId2 = 'room222'; + const roleId1 = 'role333'; + const roleId2 = 'role444'; + + const groups = [ + groupFactory.build({ id: groupId1, users: [{ userId, roleId: roleId1 }] }), + groupFactory.build({ id: groupId2, users: [{ userId, roleId: roleId2 }] }), + ]; + const roomMembers = [ + roomMemberFactory.build({ roomId: roomId1, userGroupId: groupId1 }), + roomMemberFactory.build({ roomId: roomId2, userGroupId: groupId2 }), + ]; + const roles = [roleDtoFactory.build({ id: roleId1 }), roleDtoFactory.build({ id: roleId2 })]; + + groupService.findGroups.mockResolvedValue({ data: groups, total: groups.length }); + roomMemberRepo.findByGroupIds.mockResolvedValue(roomMembers); + roleService.findByIds.mockResolvedValue(roles); + + return { userId, roomMembers, roles }; + }; + + it('should return RoomMemberAuthorizables for user', async () => { + const { userId, roomMembers, roles } = setup(); + + const result = await service.getRoomMemberAuthorizablesByUserId(userId); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(RoomMemberAuthorizable); + expect(result[0].roomId).toBe(roomMembers[0].roomId); + expect(result[0].members[0].userId).toBe(userId); + expect(result[0].members[0].roles[0].id).toBe(roles[0].id); + expect(result[1]).toBeInstanceOf(RoomMemberAuthorizable); + expect(result[1].roomId).toBe(roomMembers[1].roomId); + expect(result[1].members[0].userId).toBe(userId); + expect(result[1].members[0].roles[0].id).toBe(roles[1].id); + }); + + it('should return empty array when no groups found', async () => { + const { userId } = setup(); + groupService.findGroups.mockResolvedValue({ data: [], total: 0 }); + + const result = await service.getRoomMemberAuthorizablesByUserId(userId); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/apps/server/src/modules/room-member/service/room-member.service.ts b/apps/server/src/modules/room-member/service/room-member.service.ts new file mode 100644 index 00000000000..ae3d44fcfe4 --- /dev/null +++ b/apps/server/src/modules/room-member/service/room-member.service.ts @@ -0,0 +1,123 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { RoleName } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { Group, GroupService, GroupTypes } from '@src/modules/group'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { RoleDto, RoleService } from '@src/modules/role'; +import { RoomMember } from '../do/room-member.do'; +import { RoomMemberRepo } from '../repo/room-member.repo'; +import { RoomMemberAuthorizable, UserWithRoomRoles } from '../do/room-member-authorizable.do'; + +@Injectable() +export class RoomMemberService { + constructor( + private readonly groupService: GroupService, + private readonly roomMembersRepo: RoomMemberRepo, + private readonly roleService: RoleService + ) {} + + private async createNewRoomMember( + roomId: EntityId, + userId: EntityId, + roleName: RoleName.ROOM_EDITOR | RoleName.ROOM_VIEWER, + schoolId?: EntityId + ) { + const group = await this.groupService.createGroup(`Room Members for Room ${roomId}`, GroupTypes.ROOM, schoolId); + await this.groupService.addUserToGroup(group.id, userId, roleName); + + const roomMember = new RoomMember({ + id: new ObjectId().toHexString(), + roomId, + userGroupId: group.id, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await this.roomMembersRepo.save(roomMember); + + return roomMember; + } + + private static buildRoomMemberAuthorizable( + roomId: EntityId, + group: Group, + roleSet: RoleDto[] + ): RoomMemberAuthorizable { + const members = group.users.map((groupUser): UserWithRoomRoles => { + const roleDto = roleSet.find((role) => role.id === groupUser.roleId); + if (roleDto === undefined) throw new BadRequestException('Role not found'); + return { + roles: [roleDto], + userId: groupUser.userId, + }; + }); + + const roomMemberAuthorizable = new RoomMemberAuthorizable(roomId, members); + + return roomMemberAuthorizable; + } + + public async deleteRoomMember(roomId: EntityId) { + const roomMember = await this.roomMembersRepo.findByRoomId(roomId); + if (roomMember === null) return; + + const group = await this.groupService.findById(roomMember.userGroupId); + await this.groupService.delete(group); + await this.roomMembersRepo.delete(roomMember); + } + + public async addMemberToRoom( + roomId: EntityId, + userId: EntityId, + roleName: RoleName.ROOM_EDITOR | RoleName.ROOM_VIEWER, + schoolId?: EntityId + ): Promise { + const roomMember = await this.roomMembersRepo.findByRoomId(roomId); + if (roomMember === null) { + const newRoomMember = await this.createNewRoomMember(roomId, userId, roleName, schoolId); + return newRoomMember.id; + } + + await this.groupService.addUserToGroup(roomMember.userGroupId, userId, roleName); + return roomMember.id; + } + + public async getRoomMemberAuthorizablesByUserId(userId: EntityId): Promise { + const groupPage = await this.groupService.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); + const groupIds = groupPage.data.map((group) => group.id); + const roomMembers = await this.roomMembersRepo.findByGroupIds(groupIds); + const roleIds = groupPage.data.flatMap((group) => group.users.map((groupUser) => groupUser.roleId)); + const roleSet = await this.roleService.findByIds(roleIds); + const roomMemberAuthorizables = roomMembers + .map((item) => { + const group = groupPage.data.find((g) => g.id === item.userGroupId); + if (!group) return null; + return RoomMemberService.buildRoomMemberAuthorizable(item.roomId, group, roleSet); + }) + .filter((item): item is RoomMemberAuthorizable => item !== null); + + return roomMemberAuthorizables; + } + + public async getRoomMemberAuthorizable(roomId: EntityId): Promise { + const roomMember = await this.roomMembersRepo.findByRoomId(roomId); + if (roomMember === null) { + return new RoomMemberAuthorizable(roomId, []); + } + const group = await this.groupService.findById(roomMember.userGroupId); + const roleSet = await this.roleService.findByIds(group.users.map((groupUser) => groupUser.roleId)); + + const members = group.users.map((groupUser): UserWithRoomRoles => { + const roleDto = roleSet.find((role) => role.id === groupUser.roleId); + if (roleDto === undefined) throw new BadRequestException('Role not found'); + return { + roles: [roleDto], + userId: groupUser.userId, + }; + }); + + const roomMemberAuthorizable = new RoomMemberAuthorizable(roomId, members); + + return roomMemberAuthorizable; + } +} diff --git a/apps/server/src/modules/room-member/testing/index.ts b/apps/server/src/modules/room-member/testing/index.ts new file mode 100644 index 00000000000..7f1a2950ff5 --- /dev/null +++ b/apps/server/src/modules/room-member/testing/index.ts @@ -0,0 +1,2 @@ +export * from './room-member-entity.factory'; +export * from './room-member.factory'; diff --git a/apps/server/src/modules/room-member/testing/room-member-entity.factory.ts b/apps/server/src/modules/room-member/testing/room-member-entity.factory.ts new file mode 100644 index 00000000000..b63f70ecffd --- /dev/null +++ b/apps/server/src/modules/room-member/testing/room-member-entity.factory.ts @@ -0,0 +1,16 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityFactory } from '@shared/testing/factory/entity.factory'; +import { RoomMemberEntity, RoomMemberEntityProps } from '../repo/entity/room-member.entity'; + +export const roomMemberEntityFactory = EntityFactory.define( + RoomMemberEntity, + () => { + return { + id: new ObjectId().toHexString(), + roomId: new ObjectId().toHexString(), + userGroupId: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/room-member/testing/room-member.factory.ts b/apps/server/src/modules/room-member/testing/room-member.factory.ts new file mode 100644 index 00000000000..829f0f1708c --- /dev/null +++ b/apps/server/src/modules/room-member/testing/room-member.factory.ts @@ -0,0 +1,15 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RoomMember, RoomMemberProps } from '../do/room-member.do'; + +export const roomMemberFactory = BaseFactory.define(RoomMember, () => { + const props: RoomMemberProps = { + id: new ObjectId().toHexString(), + roomId: new ObjectId().toHexString(), + userGroupId: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + return props; +}); diff --git a/apps/server/src/modules/room/api/room.uc.spec.ts b/apps/server/src/modules/room/api/room.uc.spec.ts index 8d84d037763..94b2e8e5624 100644 --- a/apps/server/src/modules/room/api/room.uc.spec.ts +++ b/apps/server/src/modules/room/api/room.uc.spec.ts @@ -1,19 +1,24 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Page } from '@shared/domain/domainobject'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { IFindOptions } from '@shared/domain/interface'; -import { RoomUc } from './room.uc'; -import { RoomService, Room } from '../domain'; +import { setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationService } from '@src/modules/authorization'; +import { RoomMemberRepo, RoomMemberService } from '@src/modules/room-member'; +import { Room, RoomService } from '../domain'; +import { RoomColor } from '../domain/type'; import { roomFactory } from '../testing'; +import { RoomUc } from './room.uc'; describe('RoomUc', () => { let module: TestingModule; let uc: RoomUc; let configService: DeepMocked; let roomService: DeepMocked; - + let authorizationService: DeepMocked; + let roomMemberService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ @@ -26,12 +31,27 @@ describe('RoomUc', () => { provide: RoomService, useValue: createMock(), }, + { + provide: RoomMemberService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: RoomMemberRepo, + useValue: createMock(), + }, ], }).compile(); uc = module.get(RoomUc); configService = module.get(ConfigService); roomService = module.get(RoomService); + authorizationService = module.get(AuthorizationService); + roomMemberService = module.get(RoomMemberService); + await setupEntities(); }); afterAll(async () => { @@ -47,7 +67,7 @@ describe('RoomUc', () => { configService.get.mockReturnValue(true); const rooms: Room[] = roomFactory.buildList(2); const paginatedRooms: Page = new Page(rooms, rooms.length); - roomService.getRooms.mockResolvedValue(paginatedRooms); + roomService.getRoomsByIds.mockResolvedValue(paginatedRooms); const findOptions: IFindOptions = {}; return { @@ -64,15 +84,39 @@ describe('RoomUc', () => { it('should call roomService.getRooms with findOptions', async () => { const { findOptions } = setup(); + jest.spyOn(uc as any, 'getAuthorizedRoomIds').mockResolvedValue([]); await uc.getRooms('userId', findOptions); - expect(roomService.getRooms).toHaveBeenCalledWith(findOptions); + + expect(roomService.getRoomsByIds).toHaveBeenCalledWith([], findOptions); }); it('should return rooms when feature is enabled', async () => { const { paginatedRooms } = setup(); - const result = await uc.getRooms('userId', {}); + jest.spyOn(uc as any, 'getAuthorizedRoomIds').mockResolvedValue(paginatedRooms.data.map((room) => room.id)); + const result = await uc.getRooms('userId', {}); expect(result).toEqual(paginatedRooms); }); }); + + describe('createRoom', () => { + const setup = () => { + const user = userFactory.build(); + configService.get.mockReturnValue(true); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkOneOfPermissions.mockReturnValue(undefined); + const room = roomFactory.build(); + roomService.createRoom.mockResolvedValue(room); + roomMemberService.addMemberToRoom.mockRejectedValue(new Error('test')); + return { user, room }; + }; + + it('should cleanup room if room members throws error', async () => { + const { user, room } = setup(); + + await expect(uc.createRoom(user.id, { color: RoomColor.BLUE, name: 'test' })).rejects.toThrow(); + + expect(roomService.deleteRoom).toHaveBeenCalledWith(room); + }); + }); }); diff --git a/apps/server/src/modules/room/api/room.uc.ts b/apps/server/src/modules/room/api/room.uc.ts index f97581d6948..16c22c14fd5 100644 --- a/apps/server/src/modules/room/api/room.uc.ts +++ b/apps/server/src/modules/room/api/room.uc.ts @@ -1,9 +1,11 @@ -import { ConfigService } from '@nestjs/config'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; import { Page } from '@shared/domain/domainobject'; +import { IFindOptions, Permission, RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { IFindOptions } from '@shared/domain/interface'; -import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { Action, AuthorizationService } from '@src/modules/authorization'; +import { RoomMemberService } from '@src/modules/room-member'; import { Room, RoomCreateProps, RoomService, RoomUpdateProps } from '../domain'; import { RoomConfig } from '../room.config'; @@ -11,43 +13,48 @@ import { RoomConfig } from '../room.config'; export class RoomUc { constructor( private readonly configService: ConfigService, - private readonly roomService: RoomService + private readonly roomService: RoomService, + private readonly roomMemberService: RoomMemberService, + private readonly authorizationService: AuthorizationService ) {} public async getRooms(userId: EntityId, findOptions: IFindOptions): Promise> { this.checkFeatureEnabled(); + const authorizedRoomIds = await this.getAuthorizedRoomIds(userId, Action.read); + const rooms = await this.roomService.getRoomsByIds(authorizedRoomIds, findOptions); - // TODO check authorization - // const user: User = await this.authorizationService.getUserWithPermissions(userId); - - const rooms = await this.roomService.getRooms(findOptions); return rooms; } public async createRoom(userId: EntityId, props: RoomCreateProps): Promise { this.checkFeatureEnabled(); - // TODO check authorization - + const user = await this.authorizationService.getUserWithPermissions(userId); const room = await this.roomService.createRoom(props); + // NOTE: currently only teacher are allowed to create rooms. Could not find simpler way to check this. + this.authorizationService.checkOneOfPermissions(user, [Permission.COURSE_CREATE]); + await this.roomMemberService + .addMemberToRoom(room.id, user.id, RoleName.ROOM_EDITOR, user.school.id) + .catch(async (err) => { + await this.roomService.deleteRoom(room); + throw err; + }); return room; } public async getSingleRoom(userId: EntityId, roomId: EntityId): Promise { this.checkFeatureEnabled(); - - // TODO check authorization - const room = await this.roomService.getSingleRoom(roomId); + + await this.checkRoomAuthorization(userId, roomId, Action.read); return room; } public async updateRoom(userId: EntityId, roomId: EntityId, props: RoomUpdateProps): Promise { this.checkFeatureEnabled(); - - // TODO check authorization - const room = await this.roomService.getSingleRoom(roomId); + + await this.checkRoomAuthorization(userId, roomId, Action.write); await this.roomService.updateRoom(room, props); return room; @@ -55,14 +62,29 @@ export class RoomUc { public async deleteRoom(userId: EntityId, roomId: EntityId): Promise { this.checkFeatureEnabled(); - - // TODO check authorization - const room = await this.roomService.getSingleRoom(roomId); + await this.checkRoomAuthorization(userId, roomId, Action.write); await this.roomService.deleteRoom(room); } + private async getAuthorizedRoomIds(userId: EntityId, action: Action): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + const roomAuthorizables = await this.roomMemberService.getRoomMemberAuthorizablesByUserId(userId); + + const authorizedRoomIds = roomAuthorizables.filter((item) => + this.authorizationService.hasPermission(user, item, { action, requiredPermissions: [] }) + ); + + return authorizedRoomIds.map((item) => item.roomId); + } + + private async checkRoomAuthorization(userId: EntityId, roomId: EntityId, action: Action): Promise { + const roomMemberAuthorizable = await this.roomMemberService.getRoomMemberAuthorizable(roomId); + const user = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, roomMemberAuthorizable, { action, requiredPermissions: [] }); + } + private checkFeatureEnabled(): void { if (!this.configService.get('FEATURE_ROOMS_ENABLED')) { throw new FeatureDisabledLoggableException('FEATURE_ROOMS_ENABLED'); diff --git a/apps/server/src/modules/room/api/test/room-create.api.spec.ts b/apps/server/src/modules/room/api/test/room-create.api.spec.ts index c897e686988..a2a44adc20a 100644 --- a/apps/server/src/modules/room/api/test/room-create.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-create.api.spec.ts @@ -1,8 +1,11 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, roleFactory } from '@shared/testing'; import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; +import { RoomMemberEntity } from '@src/modules/room-member'; +import { GroupEntity } from '@src/modules/group/entity'; +import { Permission, RoleName } from '@shared/domain/interface'; import { RoomEntity } from '../../repo'; describe('Room Controller (API)', () => { @@ -65,12 +68,16 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([teacherAccount, teacherUser]); + const role = roleFactory.buildWithId({ + name: RoleName.ROOM_EDITOR, + permissions: [Permission.ROOM_EDIT, Permission.ROOM_VIEW], + }); + await em.persistAndFlush([teacherAccount, teacherUser, role]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); - return { loggedInClient }; + return { loggedInClient, teacherUser, role }; }; describe('when the required parameters are given', () => { @@ -80,10 +87,28 @@ describe('Room Controller (API)', () => { const response = await loggedInClient.post(undefined, params); const roomId = (response.body as { id: string }).id; - expect(response.status).toBe(HttpStatus.CREATED); await expect(em.findOneOrFail(RoomEntity, roomId)).resolves.toMatchObject({ id: roomId, color: 'red' }); }); + + it('should have room creator as room editor', async () => { + const { loggedInClient, teacherUser } = await setup(); + + const params = { name: 'Room #1', color: 'red' }; + + const response = await loggedInClient.post(undefined, params); + const roomId = (response.body as { id: string }).id; + const roomMember = await em.findOneOrFail(RoomMemberEntity, { roomId }); + + const userGroup = await em.findOneOrFail(GroupEntity, { + id: roomMember.userGroupId, + }); + + expect(roomMember).toBeDefined(); + expect(userGroup).toBeDefined(); + expect(userGroup.users).toHaveLength(1); + expect(userGroup.users[0].user.id).toBe(teacherUser.id); + }); }); describe('when a start date is given', () => { @@ -174,5 +199,27 @@ describe('Room Controller (API)', () => { }); }); }); + + describe('when the user has not required permissions', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser }; + }; + + describe('when the required parameters are given', () => { + it('should not create the room', async () => { + const { loggedInClient } = await setup(); + const params = { name: 'Room #1', color: 'red' }; + + const response = await loggedInClient.post(undefined, params); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts index ff9055f571e..b19d523f752 100644 --- a/apps/server/src/modules/room/api/test/room-delete.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-delete.api.spec.ts @@ -1,7 +1,16 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, +} from '@shared/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; import { RoomEntity } from '../../repo'; import { roomEntityFactory } from '../../testing/room-entity.factory'; @@ -85,8 +94,17 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const room = roomEntityFactory.build(); + const role = roleFactory.buildWithId({ + name: RoleName.ROOM_EDITOR, + permissions: [Permission.ROOM_EDIT], + }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([room, teacherAccount, teacherUser]); + const userGroup = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ role, user: teacherUser }], + }); + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([room, roomMember, teacherAccount, teacherUser, userGroup, role]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -99,7 +117,6 @@ describe('Room Controller (API)', () => { const { loggedInClient, room } = await setup(); const response = await loggedInClient.delete(room.id); - expect(response.status).toBe(HttpStatus.NO_CONTENT); await expect(em.findOneOrFail(RoomEntity, room.id)).rejects.toThrow(NotFoundException); }); @@ -116,5 +133,39 @@ describe('Room Controller (API)', () => { }); }); }); + + describe('when the user has not the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, room }; + }; + + describe('when the room exists', () => { + it('should return 403', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.delete(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when the room does not exist', () => { + it('should return a 404 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.delete(someId); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/room/api/test/room-get.api.spec.ts b/apps/server/src/modules/room/api/test/room-get.api.spec.ts index de20ce624c5..fe842db3d7f 100644 --- a/apps/server/src/modules/room/api/test/room-get.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-get.api.spec.ts @@ -1,7 +1,16 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, +} from '@shared/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; @@ -84,11 +93,22 @@ describe('Room Controller (API)', () => { describe('when the user has the required permissions', () => { const setup = async () => { const room = roomEntityFactory.build(); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([room, teacherAccount, teacherUser]); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const role = roleFactory.buildWithId({ + name: RoleName.ROOM_VIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const userGroupEntity = groupEntityFactory.buildWithId({ + type: GroupEntityTypes.ROOM, + users: [{ role, user: studentUser }], + organization: studentUser.school, + externalSource: undefined, + }); + const roomMember = roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }); + await em.persistAndFlush([room, studentAccount, studentUser, role, userGroupEntity, roomMember]); em.clear(); - const loggedInClient = await testApiClient.login(teacherAccount); + const loggedInClient = await testApiClient.login(studentAccount); const expectedResponse = { id: room.id, @@ -108,7 +128,6 @@ describe('Room Controller (API)', () => { const { loggedInClient, room, expectedResponse } = await setup(); const response = await loggedInClient.get(room.id); - expect(response.status).toBe(HttpStatus.OK); expect(response.body).toEqual(expectedResponse); }); @@ -125,5 +144,28 @@ describe('Room Controller (API)', () => { }); }); }); + + describe('when the user has not the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, room }; + }; + + describe('when the room exists', () => { + it('should return 403', async () => { + const { loggedInClient, room } = await setup(); + + const response = await loggedInClient.get(room.id); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/room/api/test/room-index.api.spec.ts b/apps/server/src/modules/room/api/test/room-index.api.spec.ts index e42fcb54bd3..ca0a1c22658 100644 --- a/apps/server/src/modules/room/api/test/room-index.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-index.api.spec.ts @@ -1,8 +1,18 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; -import { serverConfig, type ServerConfig, ServerTestModule } from '@src/modules/server'; +import { Permission } from '@shared/domain/interface/permission.enum'; +import { RoleName } from '@shared/domain/interface/rolename.enum'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, +} from '@shared/testing'; +import { GroupEntityTypes } from '@src/modules/group/entity/group.entity'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing/room-member-entity.factory'; +import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; import { roomEntityFactory } from '../../testing/room-entity.factory'; import { RoomListResponse } from '../dto/response/room-list.response'; @@ -62,7 +72,7 @@ describe('Room Controller (API)', () => { }); }); - describe('when the user has the required permissions', () => { + describe('when the user has not the required permissions', () => { const setup = async () => { const rooms = roomEntityFactory.buildListWithId(2); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); @@ -92,6 +102,72 @@ describe('Room Controller (API)', () => { return { loggedInClient, expectedResponse }; }; + it('should return an empty list of rooms', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.status).toBe(HttpStatus.OK); + expect((response.body as RoomListResponse).total).toEqual(0); + }); + + it('should return a list of rooms with pagination', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get().query({ skip: 1, limit: 1 }); + expect(response.status).toBe(HttpStatus.OK); + expect(response.body as RoomListResponse).toEqual({ + data: [], + limit: 1, + skip: 1, + total: 0, + }); + }); + }); + + describe('when the user has the required permissions', () => { + const setup = async () => { + const rooms = roomEntityFactory.buildListWithId(2); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const role = roleFactory.buildWithId({ + name: RoleName.ROOM_VIEWER, + permissions: [Permission.ROOM_VIEW], + }); + const userGroupEntity = groupEntityFactory.buildWithId({ + users: [{ role, user: studentUser }], + type: GroupEntityTypes.ROOM, + organization: studentUser.school, + externalSource: undefined, + }); + const roomMembers = rooms.map((room) => + roomMemberEntityFactory.build({ userGroupId: userGroupEntity.id, roomId: room.id }) + ); + await em.persistAndFlush([...rooms, ...roomMembers, studentAccount, studentUser, userGroupEntity]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const data = rooms.map((room) => { + return { + id: room.id, + name: room.name, + color: room.color, + startDate: room.startDate?.toISOString(), + endDate: room.endDate?.toISOString(), + createdAt: room.createdAt.toISOString(), + updatedAt: room.updatedAt.toISOString(), + }; + }); + const expectedResponse = { + data, + limit: 1000, + skip: 0, + total: rooms.length, + }; + + return { loggedInClient, expectedResponse }; + }; + it('should return a list of rooms', async () => { const { loggedInClient, expectedResponse } = await setup(); diff --git a/apps/server/src/modules/room/api/test/room-update.api.spec.ts b/apps/server/src/modules/room/api/test/room-update.api.spec.ts index 139a2eaba8c..a756962f5af 100644 --- a/apps/server/src/modules/room/api/test/room-update.api.spec.ts +++ b/apps/server/src/modules/room/api/test/room-update.api.spec.ts @@ -1,7 +1,15 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections } from '@shared/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + groupEntityFactory, + roleFactory, +} from '@shared/testing'; +import { roomMemberEntityFactory } from '@src/modules/room-member/testing'; import { ServerTestModule, serverConfig, type ServerConfig } from '@src/modules/server'; import { RoomEntity } from '../../repo'; import { roomEntityFactory } from '../../testing'; @@ -90,8 +98,16 @@ describe('Room Controller (API)', () => { startDate: new Date('2024-10-01'), endDate: new Date('2024-10-20'), }); + const role = roleFactory.buildWithId({ + name: RoleName.ROOM_EDITOR, + permissions: [Permission.ROOM_EDIT], + }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); - await em.persistAndFlush([room, teacherAccount, teacherUser]); + const userGroup = groupEntityFactory.buildWithId({ + users: [{ role, user: teacherUser }], + }); + const roomMember = roomMemberEntityFactory.build({ roomId: room.id, userGroupId: userGroup.id }); + await em.persistAndFlush([room, roomMember, teacherAccount, teacherUser, userGroup, role]); em.clear(); const loggedInClient = await testApiClient.login(teacherAccount); @@ -275,5 +291,44 @@ describe('Room Controller (API)', () => { }); }); }); + + describe('when the user has not the required permissions', () => { + const setup = async () => { + const room = roomEntityFactory.build({ + startDate: new Date('2024-10-01'), + endDate: new Date('2024-10-20'), + }); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + await em.persistAndFlush([room, teacherAccount, teacherUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, room }; + }; + + describe('when the room does not exist', () => { + it('should return a 404 error', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + const params = { name: 'Room #101', color: 'green' }; + + const response = await loggedInClient.patch(someId, params); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); + + describe('when the required parameters are given', () => { + it('should return a 403 error', async () => { + const { loggedInClient, room } = await setup(); + const params = { name: 'Room #101', color: 'green' }; + + const response = await loggedInClient.patch(room.id, params); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/room/domain/service/room.service.spec.ts b/apps/server/src/modules/room/domain/service/room.service.spec.ts index 2735aee6b20..5edb5710a03 100644 --- a/apps/server/src/modules/room/domain/service/room.service.spec.ts +++ b/apps/server/src/modules/room/domain/service/room.service.spec.ts @@ -1,11 +1,12 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; -import { RoomService } from './room.service'; +import { EntityId } from '@shared/domain/types'; import { RoomRepo } from '../../repo'; -import { Room, RoomCreateProps, RoomUpdateProps } from '../do'; import { roomFactory } from '../../testing'; +import { Room, RoomCreateProps, RoomUpdateProps } from '../do'; import { RoomColor } from '../type'; +import { RoomService } from './room.service'; describe('RoomService', () => { let module: TestingModule; @@ -147,4 +148,30 @@ describe('RoomService', () => { expect(roomRepo.delete).toHaveBeenCalledWith(room); }); }); + + describe('getRoomsByIds', () => { + it('should return rooms for given ids', async () => { + const roomIds: EntityId[] = ['1', '2', '3']; + const mockRooms: Room[] = [ + { id: '1', name: 'Room 1' }, + { id: '2', name: 'Room 2' }, + { id: '3', name: 'Room 3' }, + ] as Room[]; + const mockPage: Page = { + data: mockRooms, + total: 3, + }; + + jest.spyOn(roomRepo, 'findRoomsByIds').mockResolvedValue(mockPage); + + const result = await service.getRoomsByIds(roomIds, {}); + + expect(roomRepo.findRoomsByIds).toHaveBeenCalledWith(roomIds, {}); + expect(result).toEqual(mockPage); + expect(result.data.length).toBe(3); + expect(result.data[0].id).toBe('1'); + expect(result.data[1].id).toBe('2'); + expect(result.data[2].id).toBe('3'); + }); + }); }); diff --git a/apps/server/src/modules/room/domain/service/room.service.ts b/apps/server/src/modules/room/domain/service/room.service.ts index 865b9697d60..9f0a974ae5b 100644 --- a/apps/server/src/modules/room/domain/service/room.service.ts +++ b/apps/server/src/modules/room/domain/service/room.service.ts @@ -16,6 +16,12 @@ export class RoomService { return rooms; } + public async getRoomsByIds(roomIds: EntityId[], findOptions: IFindOptions): Promise> { + const rooms: Page = await this.roomRepo.findRoomsByIds(roomIds, findOptions); + + return rooms; + } + public async createRoom(props: RoomCreateProps): Promise { const roomProps: RoomProps = { id: new ObjectId().toHexString(), diff --git a/apps/server/src/modules/room/index.ts b/apps/server/src/modules/room/index.ts index 81ffcd1df70..7be3bdc7474 100644 --- a/apps/server/src/modules/room/index.ts +++ b/apps/server/src/modules/room/index.ts @@ -1,3 +1,4 @@ export * from './domain'; export { RoomConfig } from './room.config'; export * from './room.module'; +export * from './repo/entity'; diff --git a/apps/server/src/modules/room/repo/room.repo.spec.ts b/apps/server/src/modules/room/repo/room.repo.spec.ts index 6dac9069800..d89fd630b4e 100644 --- a/apps/server/src/modules/room/repo/room.repo.spec.ts +++ b/apps/server/src/modules/room/repo/room.repo.spec.ts @@ -130,4 +130,44 @@ describe('RoomRepo', () => { expect(remainingCount).toBe(0); }); }); + + describe('findRoomsByIds', () => { + const setup = async () => { + const roomEntities = roomEntityFactory.buildListWithId(5); + await em.persistAndFlush(roomEntities); + em.clear(); + + const rooms = roomEntities.map((entity) => RoomDomainMapper.mapEntityToDo(entity)); + const roomIds = rooms.map((room) => room.id); + + return { rooms, roomIds }; + }; + + it('should return rooms with matching ids', async () => { + const { roomIds } = await setup(); + + const result = await repo.findRoomsByIds(roomIds.slice(0, 3), {}); + + expect(result.data.length).toBe(3); + expect(result.data.map((room) => room.id)).toEqual(expect.arrayContaining(roomIds.slice(0, 3))); + }); + + it('should return paginated results', async () => { + const { roomIds } = await setup(); + + const result = await repo.findRoomsByIds(roomIds, { pagination: { skip: 2, limit: 2 } }); + + expect(result.data.length).toBe(2); + expect(result.total).toBe(5); + }); + + it('should return empty array if no matching ids', async () => { + await setup(); + + const result = await repo.findRoomsByIds(['nonexistent-id'], {}); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + }); + }); }); diff --git a/apps/server/src/modules/room/repo/room.repo.ts b/apps/server/src/modules/room/repo/room.repo.ts index 440293ac6a3..6cc2aa0c371 100644 --- a/apps/server/src/modules/room/repo/room.repo.ts +++ b/apps/server/src/modules/room/repo/room.repo.ts @@ -32,6 +32,26 @@ export class RoomRepo { return page; } + public async findRoomsByIds(roomIds: EntityId[], findOptions: IFindOptions): Promise> { + const scope = new RoomScope(); + scope.allowEmptyQuery(true); + scope.byIds(roomIds); + + const options = { + offset: findOptions?.pagination?.skip, + limit: findOptions?.pagination?.limit, + orderBy: { name: QueryOrder.ASC }, + }; + + const [entities, total] = await this.em.findAndCount(RoomEntity, scope.query, options); + + const domainObjects: Room[] = entities.map((entity) => RoomDomainMapper.mapEntityToDo(entity)); + + const page = new Page(domainObjects, total); + + return page; + } + public async findById(id: EntityId): Promise { const entity = await this.em.findOneOrFail(RoomEntity, id); const domainobject = RoomDomainMapper.mapEntityToDo(entity); diff --git a/apps/server/src/modules/room/repo/room.scope.ts b/apps/server/src/modules/room/repo/room.scope.ts index 86f317f8d6f..090abf60293 100644 --- a/apps/server/src/modules/room/repo/room.scope.ts +++ b/apps/server/src/modules/room/repo/room.scope.ts @@ -1,4 +1,13 @@ +import { EntityId } from '@shared/domain/types'; import { Scope } from '@shared/repo/scope'; import { RoomEntity } from './entity'; -export class RoomScope extends Scope {} +export class RoomScope extends Scope { + byIds(ids?: EntityId[]): this { + if (ids) { + this.addQuery({ id: { $in: ids } }); + } + + return this; + } +} diff --git a/apps/server/src/modules/room/room-api.module.ts b/apps/server/src/modules/room/room-api.module.ts index c9344a6c848..5af3626655a 100644 --- a/apps/server/src/modules/room/room-api.module.ts +++ b/apps/server/src/modules/room/room-api.module.ts @@ -1,11 +1,12 @@ import { AuthorizationModule } from '@modules/authorization'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +import { RoomMemberModule } from '../room-member/room-member.module'; import { RoomController, RoomUc } from './api'; import { RoomModule } from './room.module'; @Module({ - imports: [RoomModule, AuthorizationModule, LoggerModule], + imports: [RoomModule, AuthorizationModule, LoggerModule, RoomMemberModule], controllers: [RoomController], providers: [RoomUc], }) diff --git a/apps/server/src/modules/room/room.module.ts b/apps/server/src/modules/room/room.module.ts index 8c31af94b89..b500abb782f 100644 --- a/apps/server/src/modules/room/room.module.ts +++ b/apps/server/src/modules/room/room.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; -import { RoomRepo } from './repo'; import { RoomService } from './domain/service'; +import { RoomRepo } from './repo'; @Module({ imports: [CqrsModule], diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index b71d53420bf..537d17b30d5 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -11,6 +11,7 @@ import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; import { RoomEntity } from '@modules/room/repo/entity'; +import { RoomMemberEntity } from '@src/modules/room-member/repo/entity/room-member.entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { SystemEntity } from '@modules/system/entity/system.entity'; import { TldrawDrawing } from '@modules/tldraw/entities'; @@ -76,6 +77,7 @@ export const ALL_ENTITIES = [ RocketChatUserEntity, Role, RoomEntity, + RoomMemberEntity, SchoolEntity, SchoolExternalToolEntity, SchoolNews, diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index 24b1536a4d2..125e027653f 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -100,6 +100,8 @@ export enum Permission { ROLE_CREATE = 'ROLE_CREATE', ROLE_EDIT = 'ROLE_EDIT', ROLE_VIEW = 'ROLE_VIEW', + ROOM_EDIT = 'ROOM_EDIT', + ROOM_VIEW = 'ROOM_VIEW', SCHOOL_CHAT_MANAGE = 'SCHOOL_CHAT_MANAGE', SCHOOL_CREATE = 'SCHOOL_CREATE', SCHOOL_EDIT = 'SCHOOL_EDIT', diff --git a/apps/server/src/shared/domain/interface/rolename.enum.ts b/apps/server/src/shared/domain/interface/rolename.enum.ts index fe6cd18bccd..508771014d5 100644 --- a/apps/server/src/shared/domain/interface/rolename.enum.ts +++ b/apps/server/src/shared/domain/interface/rolename.enum.ts @@ -9,6 +9,8 @@ export enum RoleName { DEMOTEACHER = 'demoTeacher', EXPERT = 'expert', HELPDESK = 'helpdesk', + ROOM_VIEWER = 'room_viewer', + ROOM_EDITOR = 'room_editor', STUDENT = 'student', SUPERHERO = 'superhero', TEACHER = 'teacher', diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index d8778bc0d98..9d14285eb33 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -241,5 +241,14 @@ "created_at": { "$date": "2024-10-22T14:50:10.445Z" } + }, + { + "_id": { + "$oid": "6718c7e97459fd3674d36a29" + }, + "name": "Migration202410041210124", + "created_at": { + "$date": "2024-10-23T09:54:49.077Z" + } } ]