Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BC-8547 - change room roles #5450

Merged
merged 28 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc7f9bf
membership service
Metauriel Jan 15, 2025
9da8ddb
add skeleton for controller and uc
Metauriel Jan 17, 2025
dcbaa82
define api for changing roles in rooms
Metauriel Jan 17, 2025
79c943a
make uc functional
Metauriel Jan 20, 2025
619bd48
prevent owner manipulation
Metauriel Jan 20, 2025
24dcc39
add ROOM_MEMBERS_CHANGE_ROLE to room admin and owner
Metauriel Jan 20, 2025
2689e74
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 20, 2025
e25dadb
update imports
Metauriel Jan 20, 2025
3410be1
BC-8545 - add school role to RoomMemberList (#5441)
NFriedo Jan 20, 2025
0a6ebbd
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 20, 2025
1e50fdf
fix build
Metauriel Jan 20, 2025
0357e4a
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 21, 2025
9d46859
test build fix
Metauriel Jan 21, 2025
7d311d2
fix tests after mergeconflict
Metauriel Jan 21, 2025
d8583ae
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 21, 2025
d4c8423
update import
Metauriel Jan 21, 2025
12e12ba
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 22, 2025
2669e2f
solve review comments
Metauriel Jan 22, 2025
cad88b0
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 22, 2025
814bba4
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 23, 2025
baf031f
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 23, 2025
2806037
BC-8508 - Remove exports from shared/*/index.ts (#5454)
bergatco Jan 23, 2025
29581dc
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 23, 2025
717b807
import fix after merge conflict
Metauriel Jan 23, 2025
a565139
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 23, 2025
4ce5c32
Merge branch 'main' into BC-8547-change-room-roles
Metauriel Jan 23, 2025
34ac7f2
more import fixes...
Metauriel Jan 23, 2025
1b3f8f5
Merge branch 'main' of https://github.com/hpi-schul-cloud/schulcloud-…
hoeppner-dataport Jan 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 roomEditorRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'roomadmin' },
{
$addToSet: {
permissions: {
$each: ['ROOM_MEMBERS_CHANGE_ROLE'],
},
},
}
);

if (roomEditorRoleUpdate.modifiedCount > 0) {
Metauriel marked this conversation as resolved.
Show resolved Hide resolved
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 roomEditorRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'roomadmin' },
{
$pull: {
permissions: {
$in: ['ROOM_MEMBERS_CHANGE_ROLE'],
},
},
}
);

if (roomEditorRoleUpdate.modifiedCount > 0) {
Metauriel marked this conversation as resolved.
Show resolved Hide resolved
console.info('Rollback: Permission ROOM_DELETE removed from role roomadmin.');
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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 { RoleDto, RoleService } from '@modules/role';
import { RoomService } from '@modules/room/domain';
import { roomFactory } from '@modules/room/testing';
Expand All @@ -9,6 +9,7 @@ import { UserService } from '@modules/user';
import { BadRequestException } from '@nestjs/common/exceptions';
import { Test, TestingModule } from '@nestjs/testing';
import { RoleName } from '@shared/domain/interface';
import { ObjectId } from 'bson';
import { groupFactory } from '@testing/factory/domainobject';
import { roleDtoFactory } from '@testing/factory/role-dto.factory';
import { roleFactory } from '@testing/factory/role.factory';
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 () => {
hoeppner-dataport marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -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 member not found');
hoeppner-dataport marked this conversation as resolved.
Show resolved Hide resolved
}

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';
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 { RoomMapper } from './mapper/room.mapper';
import { RoomUc } from './room.uc';
import { RoomMemberListResponse } from './dto/response/room-member-list.response';
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
Loading