Skip to content

Commit

Permalink
BC-8547 - change room roles (#5450)
Browse files Browse the repository at this point in the history
* endpoint to change the role of a roommember
* can not be used to change the role of the owner
* can not be used to assign the owner role
* add ROOM_MEMBERS_CHANGE_ROLE to room admin and owner
  • Loading branch information
Metauriel authored Jan 24, 2025
1 parent 7b88ebd commit 2de08dd
Show file tree
Hide file tree
Showing 13 changed files with 565 additions and 12 deletions.
69 changes: 69 additions & 0 deletions apps/server/src/migrations/mikro-orm/Migration20250120131625.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* eslint-disable no-console */
/* eslint-disable filename-rules/match */
import { Migration } from '@mikro-orm/migrations-mongodb';

export class Migration20250120131625 extends Migration {
public async up(): Promise<void> {
const roomOwnerRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'roomowner' },
{
$addToSet: {
permissions: {
$each: ['ROOM_MEMBERS_CHANGE_ROLE'],
},
},
}
);

if (roomOwnerRoleUpdate.modifiedCount > 0) {
console.info('Permissions ROOM_MEMBERS_CHANGE_ROLE added to role roomowner.');
}

const roomAdminRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'roomadmin' },
{
$addToSet: {
permissions: {
$each: ['ROOM_MEMBERS_CHANGE_ROLE'],
},
},
}
);

if (roomAdminRoleUpdate.modifiedCount > 0) {
console.info('Permissions ROOM_MEMBERS_CHANGE_ROLE added to role roomadmin.');
}
}

public async down(): Promise<void> {
const roomOwnerRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'roomowner' },
{
$pull: {
permissions: {
$in: ['ROOM_MEMBERS_CHANGE_ROLE'],
},
},
}
);

if (roomOwnerRoleUpdate.modifiedCount > 0) {
console.info('Rollback: Permission ROOM_CREATE removed from role roomowner.');
}

const roomAdminRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'roomadmin' },
{
$pull: {
permissions: {
$in: ['ROOM_MEMBERS_CHANGE_ROLE'],
},
},
}
);

if (roomAdminRoleUpdate.modifiedCount > 0) {
console.info('Rollback: Permission ROOM_DELETE removed from role roomadmin.');
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { MongoMemoryDatabaseModule } from '@infra/database';
import { Group, GroupService, GroupTypes } from '@modules/group';
import { Group, GroupService, GroupTypes, GroupUser } from '@modules/group';
import { groupFactory } from '@modules/group/testing';
import { RoleDto, RoleService } from '@modules/role';
import { roleDtoFactory } from '@modules/role/testing';
import { RoomService } from '@modules/room/domain';
import { RoomService } from '@modules/room';
import { roomFactory } from '@modules/room/testing';
import { schoolFactory } from '@modules/school/testing';
import { UserService } from '@modules/user';
Expand All @@ -14,6 +14,7 @@ import { RoleName } from '@shared/domain/interface';
import { roleFactory } from '@testing/factory/role.factory';
import { userDoFactory } from '@testing/factory/user.do.factory';
import { userFactory } from '@testing/factory/user.factory';
import { ObjectId } from 'bson';
import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do';
import { RoomMembershipRepo } from '../repo/room-membership.repo';
import { roomMembershipFactory } from '../testing';
Expand Down Expand Up @@ -318,6 +319,97 @@ describe('RoomMembershipService', () => {
});
});

describe('changeRoleOfRoomMembers', () => {
describe('when roomMembership does not exist', () => {
it('should throw an exception', async () => {
roomMembershipRepo.findByRoomId.mockResolvedValue(null);

await expect(
service.changeRoleOfRoomMembers(new ObjectId().toHexString(), [], RoleName.ROOMEDITOR)
).rejects.toThrowError(BadRequestException);
});
});

describe('when roomMembership exists', () => {
const setup = () => {
const user = userFactory.buildWithId();
const otherUser = userFactory.buildWithId();
const userNotInRoom = userFactory.buildWithId();
const school = schoolFactory.build();
const viewerRole = roleFactory.buildWithId({ name: RoleName.ROOMVIEWER });
const editorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR });
const group = groupFactory.build({
type: GroupTypes.ROOM,
organizationId: school.id,
users: [
{ userId: user.id, roleId: viewerRole.id },
{ userId: otherUser.id, roleId: viewerRole.id },
],
});
const room = roomFactory.build({ schoolId: school.id });
const roomMembership = roomMembershipFactory.build({
roomId: room.id,
userGroupId: group.id,
schoolId: school.id,
});

roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership);
groupService.findById.mockResolvedValue(group);
roleService.findByName.mockResolvedValue(editorRole);

return {
user,
otherUser,
userNotInRoom,
room,
roomMembership,
group,
viewerRole,
editorRole,
};
};

it('should change role of user to editor', async () => {
const { user, room, group, editorRole } = setup();

await service.changeRoleOfRoomMembers(room.id, [user.id], RoleName.ROOMEDITOR);

expect(groupService.save).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
users: expect.arrayContaining([{ userId: user.id, roleId: editorRole.id }]) as GroupUser[],
})
);
});

it('should not change role of other user', async () => {
const { user, otherUser, room, group, viewerRole } = setup();

await service.changeRoleOfRoomMembers(room.id, [user.id], RoleName.ROOMEDITOR);

expect(groupService.save).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
users: expect.arrayContaining([{ userId: otherUser.id, roleId: viewerRole.id }]) as GroupUser[],
})
);
});

it('should ignore changing a user that is not in the room', async () => {
const { userNotInRoom, room, group } = setup();

await service.changeRoleOfRoomMembers(room.id, [userNotInRoom.id], RoleName.ROOMEDITOR);

expect(groupService.save).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
users: expect.not.arrayContaining([expect.objectContaining({ userId: userNotInRoom.id })]) as GroupUser[],
})
);
});
});
});

describe('deleteRoomMembership', () => {
describe('when roomMembership does not exist', () => {
const setup = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class RoomMembershipService {
public async removeMembersFromRoom(roomId: EntityId, userIds: EntityId[]): Promise<void> {
const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId);
if (roomMembership === null) {
throw new BadRequestException('Room member not found');
throw new BadRequestException('Room membership not found');
}

const group = await this.groupService.findById(roomMembership.userGroupId);
Expand All @@ -103,6 +103,26 @@ export class RoomMembershipService {
await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId);
}

public async changeRoleOfRoomMembers(roomId: EntityId, userIds: EntityId[], roleName: RoleName): Promise<void> {
const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId);
if (roomMembership === null) {
throw new BadRequestException('Room membership not found');
}

const group = await this.groupService.findById(roomMembership.userGroupId);
const role = await this.roleService.findByName(roleName);

group.users.forEach((groupUser) => {
if (userIds.includes(groupUser.userId)) {
groupUser.roleId = role.id;
}
});

await this.groupService.save(group);

return Promise.resolve();
}

public async getRoomMembershipAuthorizablesByUserId(userId: EntityId): Promise<RoomMembershipAuthorizable[]> {
const groupPage = await this.groupService.findGroups({ userId, groupTypes: [GroupTypes.ROOM] });
const groupIds = groupPage.data.map((group) => group.id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { RoleName, RoomRole } from '@shared/domain/interface';
import { IsArray, IsEnum, IsMongoId } from 'class-validator';

export type AssignableRoomRole = Exclude<RoomRole, RoleName.ROOMOWNER>;
export enum AssignableRoomRoleEnum {
ROOMADMIN = RoleName.ROOMADMIN,
ROOMEDITOR = RoleName.ROOMEDITOR,
ROOMVIEWER = RoleName.ROOMVIEWER,
}

export class ChangeRoomRoleBodyParams {
@ApiProperty({
description: 'The IDs of the users',
required: true,
})
@IsArray()
@IsMongoId({ each: true })
public userIds!: string[];

@ApiProperty({
description: 'The role to assign to the users. Must be a Room Role role other than ROOMOWNER.',
required: true,
enum: AssignableRoomRoleEnum,
})
@IsEnum(AssignableRoomRoleEnum)
public roleName!: AssignableRoomRole;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { BadRequestException } from '@nestjs/common';
import { ErrorLogMessage } from '@shared/common/error';
import { Loggable } from '@shared/common/loggable';

export class CantChangeOwnersRoleLoggableException extends BadRequestException implements Loggable {
constructor(private readonly currentUserId: string, private readonly roomId: string) {
super();
}

public getLogMessage(): ErrorLogMessage {
const message: ErrorLogMessage = {
type: 'CANT_CHANGE_OWNERS_ROLE',
stack: this.stack,
data: {
currentUserId: this.currentUserId,
roomId: this.roomId,
errorMessage:
'You cannot change the role of the room owner. If you want to change the owner, please transfer the ownership to another user instead.',
},
};

return message;
}
}
Empty file.
21 changes: 21 additions & 0 deletions apps/server/src/modules/room/api/room.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { RoomListResponse } from './dto/response/room-list.response';
import { RoomMemberListResponse } from './dto/response/room-member-list.response';
import { RoomMapper } from './mapper/room.mapper';
import { RoomUc } from './room.uc';
import { ChangeRoomRoleBodyParams } from './dto/request/change-room-role.body.params';

@ApiTags('Room')
@JwtAuthentication()
Expand Down Expand Up @@ -164,6 +165,26 @@ export class RoomController {
await this.roomUc.addMembersToRoom(currentUser.userId, urlParams.roomId, bodyParams.userIds);
}

@Patch(':roomId/members/roles')
@ApiOperation({ summary: 'Change the roles that members have within the room' })
@ApiResponse({ status: HttpStatus.OK, description: 'Adding successful', type: String })
@ApiResponse({ status: HttpStatus.BAD_REQUEST, type: ApiValidationError })
@ApiResponse({ status: HttpStatus.UNAUTHORIZED, type: UnauthorizedException })
@ApiResponse({ status: HttpStatus.FORBIDDEN, type: ForbiddenException })
@ApiResponse({ status: '5XX', type: ErrorResponse })
public async changeRolesOfMembers(
@CurrentUser() currentUser: ICurrentUser,
@Param() urlParams: RoomUrlParams,
@Body() bodyParams: ChangeRoomRoleBodyParams
): Promise<void> {
await this.roomUc.changeRolesOfMembers(
currentUser.userId,
urlParams.roomId,
bodyParams.userIds,
bodyParams.roleName
);
}

@Patch(':roomId/members/remove')
@ApiOperation({ summary: 'Remove members from a room' })
@ApiResponse({ status: HttpStatus.OK, description: 'Removing successful', type: String })
Expand Down
43 changes: 36 additions & 7 deletions apps/server/src/modules/room/api/room.uc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception';
import { Page, UserDO } from '@shared/domain/domainobject';
import { IFindOptions, Permission } from '@shared/domain/interface';
import { IFindOptions, Permission, RoleName } from '@shared/domain/interface';
import { EntityId } from '@shared/domain/types';
import { Room, RoomService } from '../domain';
import { RoomConfig } from '../room.config';
import { CreateRoomBodyParams } from './dto/request/create-room.body.params';
import { UpdateRoomBodyParams } from './dto/request/update-room.body.params';
import { RoomMemberResponse } from './dto/response/room-member.response';
import { CantChangeOwnersRoleLoggableException } from './loggables/cant-change-roomowners-role.error.loggable';

@Injectable()
export class RoomUc {
Expand Down Expand Up @@ -125,12 +126,6 @@ export class RoomUc {
return memberResponses;
}

public async addMembersToRoom(currentUserId: EntityId, roomId: EntityId, userIds: Array<EntityId>): Promise<void> {
this.checkFeatureEnabled();
await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]);
await this.roomMembershipService.addMembersToRoom(roomId, userIds);
}

private mapToMember(member: UserWithRoomRoles, user: UserDO): RoomMemberResponse {
return new RoomMemberResponse({
userId: member.userId,
Expand All @@ -142,6 +137,40 @@ export class RoomUc {
});
}

public async addMembersToRoom(currentUserId: EntityId, roomId: EntityId, userIds: Array<EntityId>): Promise<void> {
this.checkFeatureEnabled();
await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_ADD]);
await this.roomMembershipService.addMembersToRoom(roomId, userIds);
}

public async changeRolesOfMembers(
currentUserId: EntityId,
roomId: EntityId,
userIds: Array<EntityId>,
roleName: RoleName
): Promise<void> {
this.checkFeatureEnabled();
const roomAuthorizable = await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [
Permission.ROOM_MEMBERS_CHANGE_ROLE,
]);
this.preventChangingOwnersRole(roomAuthorizable, userIds, currentUserId);
await this.roomMembershipService.changeRoleOfRoomMembers(roomId, userIds, roleName);
return Promise.resolve();
}

private preventChangingOwnersRole(
roomAuthorizable: RoomMembershipAuthorizable,
userIdsToChange: EntityId[],
currentUserId: EntityId
): void {
const owner = roomAuthorizable.members.find((member) =>
member.roles.some((role) => role.name === RoleName.ROOMOWNER)
);
if (owner && userIdsToChange.includes(owner.userId)) {
throw new CantChangeOwnersRoleLoggableException(roomAuthorizable.roomId, currentUserId);
}
}

public async removeMembersFromRoom(currentUserId: EntityId, roomId: EntityId, userIds: EntityId[]): Promise<void> {
this.checkFeatureEnabled();
await this.checkRoomAuthorization(currentUserId, roomId, Action.write, [Permission.ROOM_MEMBERS_REMOVE]);
Expand Down
Loading

0 comments on commit 2de08dd

Please sign in to comment.