Skip to content

Commit

Permalink
BC-7971 - introduce room members module (#5291)
Browse files Browse the repository at this point in the history
The Room Member module manages the association between users and rooms, handling permissions and roles within rooms. This module is designed to be injected into the Room module for managing user access and roles within rooms.

Added:
* RoomMember Module
* Room roles and permissions
* Rule for Rooms
* Group service: new type 'room'
* Group service: new service methods
* Migration: insert 2 new roles and 2 new permisisons

Changed:
* Room Module runs authorization in UC
* On new Room also a RoomMember and a Group is created with default user
  • Loading branch information
EzzatOmar authored Oct 23, 2024
1 parent 05f11b5 commit bbca0b9
Show file tree
Hide file tree
Showing 47 changed files with 1,720 additions and 62 deletions.
29 changes: 29 additions & 0 deletions apps/server/src/migrations/mikro-orm/Migration202410041210124.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Migration } from '@mikro-orm/migrations-mongodb';

export class Migration202410041210124 extends Migration {
async up(): Promise<void> {
// 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<void> {
// 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');
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum GroupTypeResponse {
CLASS = 'class',
COURSE = 'course',
ROOM = 'room',
OTHER = 'other',
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PeriodResponse } from '../dto/response/period.response';
const typeMapping: Record<GroupTypes, GroupTypeResponse> = {
[GroupTypes.CLASS]: GroupTypeResponse.CLASS,
[GroupTypes.COURSE]: GroupTypeResponse.COURSE,
[GroupTypes.ROOM]: GroupTypeResponse.ROOM,
[GroupTypes.OTHER]: GroupTypeResponse.OTHER,
};

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/modules/group/domain/group-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum GroupTypes {
CLASS = 'class',
COURSE = 'course',
ROOM = 'room',
OTHER = 'other',
}
1 change: 1 addition & 0 deletions apps/server/src/modules/group/entity/group.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GroupValidPeriodEmbeddable } from './group-valid-period.embeddable';
export enum GroupEntityTypes {
CLASS = 'class',
COURSE = 'course',
ROOM = 'room',
OTHER = 'other',
}

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/modules/group/group.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/modules/group/repo/group-domain.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable, GroupValidPeriodEmb
const GroupEntityTypesToGroupTypesMapping: Record<GroupEntityTypes, GroupTypes> = {
[GroupEntityTypes.CLASS]: GroupTypes.CLASS,
[GroupEntityTypes.COURSE]: GroupTypes.COURSE,
[GroupEntityTypes.ROOM]: GroupTypes.ROOM,
[GroupEntityTypes.OTHER]: GroupTypes.OTHER,
};

export const GroupTypesToGroupEntityTypesMapping: Record<GroupTypes, GroupEntityTypes> = {
[GroupTypes.CLASS]: GroupEntityTypes.CLASS,
[GroupTypes.COURSE]: GroupEntityTypes.COURSE,
[GroupTypes.ROOM]: GroupEntityTypes.ROOM,
[GroupTypes.OTHER]: GroupEntityTypes.OTHER,
};

Expand Down
12 changes: 7 additions & 5 deletions apps/server/src/modules/group/repo/group.repo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 () => {
Expand All @@ -152,9 +152,11 @@ describe('GroupRepo', () => {
it('should return only groups of the given group types', async () => {
const { userId } = await setup();

const result: Page<Group> = await repo.findGroups({ userId, groupTypes: [GroupTypes.CLASS] });
const resultClass: Page<Group> = await repo.findGroups({ userId, groupTypes: [GroupTypes.CLASS] });
expect(resultClass.data).toEqual([expect.objectContaining<Partial<Group>>({ type: GroupTypes.CLASS })]);

expect(result.data).toEqual([expect.objectContaining<Partial<Group>>({ type: GroupTypes.CLASS })]);
const resultRoom: Page<Group> = await repo.findGroups({ userId, groupTypes: [GroupTypes.ROOM] });
expect(resultRoom.data).toEqual([expect.objectContaining<Partial<Group>>({ type: GroupTypes.ROOM })]);
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down
89 changes: 87 additions & 2 deletions apps/server/src/modules/group/service/group.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ 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';

describe('GroupService', () => {
let module: TestingModule;
let service: GroupService;

let roleService: DeepMocked<RoleService>;
let userService: DeepMocked<UserService>;
let groupRepo: DeepMocked<GroupRepo>;
let eventBus: DeepMocked<EventBus>;

Expand All @@ -25,6 +29,14 @@ describe('GroupService', () => {
provide: GroupRepo,
useValue: createMock<GroupRepo>(),
},
{
provide: UserService,
useValue: createMock<UserService>(),
},
{
provide: RoleService,
useValue: createMock<RoleService>(),
},
{
provide: EventBus,
useValue: createMock<EventBus>(),
Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
});
});
});
});
43 changes: 39 additions & 4 deletions apps/server/src/modules/group/service/group.service.ts
Original file line number Diff line number Diff line change
@@ -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<Group> {
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<Group> {
const group: Group | null = await this.groupRepo.findGroupById(id);
Expand Down Expand Up @@ -57,4 +65,31 @@ export class GroupService implements AuthorizationLoaderServiceGeneric<Group> {

await this.eventBus.publish(new GroupDeletedEvent(group));
}

public async createGroup(name: string, type: GroupTypes, organizationId?: EntityId): Promise<Group> {
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<void> {
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);
}
}
Loading

0 comments on commit bbca0b9

Please sign in to comment.