Skip to content

Commit

Permalink
BC-8139 - add or remove guestrole according to room-membership updates (
Browse files Browse the repository at this point in the history
#5382)

* add group filter for multiple userIds
* implement guest role management, when users get added to a room
* implement guest role management, when users get removed from a room
* guest role management: when user is at least in one room of the school it should have the guest role
  • Loading branch information
hoeppner-dataport authored Dec 6, 2024
1 parent 8a4deae commit 2104505
Show file tree
Hide file tree
Showing 10 changed files with 198 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EntityId } from '@shared/domain/types';

export interface GroupFilter {
userId?: EntityId;
userIds?: EntityId[];
schoolId?: EntityId;
systemId?: EntityId;
groupTypes?: GroupTypes[];
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/modules/group/repo/group.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class GroupRepo extends BaseDomainObjectRepo<Group, GroupEntity> {
public async findGroups(filter: GroupFilter, options?: IFindOptions<Group>): Promise<Page<Group>> {
const scope: GroupScope = new GroupScope();
scope.byUserId(filter.userId);
scope.byUserIds(filter.userIds);
scope.byOrganizationId(filter.schoolId);
scope.bySystemId(filter.systemId);

Expand Down
17 changes: 12 additions & 5 deletions apps/server/src/modules/group/repo/group.scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,42 @@ import { Scope } from '@shared/repo/scope';
import { GroupEntity, GroupEntityTypes } from '../entity';

export class GroupScope extends Scope<GroupEntity> {
byTypes(types: GroupEntityTypes[] | undefined): this {
public byTypes(types: GroupEntityTypes[] | undefined): this {
if (types) {
this.addQuery({ type: { $in: types } });
}
return this;
}

byOrganizationId(id: EntityId | undefined): this {
public byOrganizationId(id: EntityId | undefined): this {
if (id) {
this.addQuery({ organization: id });
}
return this;
}

bySystemId(id: EntityId | undefined): this {
public bySystemId(id: EntityId | undefined): this {
if (id) {
this.addQuery({ externalSource: { system: new ObjectId(id) } });
}
return this;
}

byUserId(id: EntityId | undefined): this {
public byUserId(id: EntityId | undefined): this {
if (id) {
this.addQuery({ users: { user: new ObjectId(id) } });
}
return this;
}

byNameQuery(nameQuery: string | undefined): this {
public byUserIds(ids: EntityId[] | undefined): this {
if (ids) {
this.addQuery({ users: { user: { $in: ids.map((id) => new ObjectId(id)) } } });
}
return this;
}

public byNameQuery(nameQuery: string | undefined): this {
if (nameQuery) {
const escapedName = nameQuery.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim();
this.addQuery({ name: new RegExp(escapedName, 'i') });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { RoomMembershipDomainMapper } from './room-membership-domain.mapper';
export class RoomMembershipRepo {
constructor(private readonly em: EntityManager) {}

async findByRoomId(roomId: EntityId): Promise<RoomMembership | null> {
public async findByRoomId(roomId: EntityId): Promise<RoomMembership | null> {
const roomMembershipEntities = await this.em.findOne(RoomMembershipEntity, { roomId });
if (!roomMembershipEntities) return null;

Expand All @@ -19,28 +19,28 @@ export class RoomMembershipRepo {
return roomMemberships;
}

async findByRoomIds(roomIds: EntityId[]): Promise<RoomMembership[]> {
public async findByRoomIds(roomIds: EntityId[]): Promise<RoomMembership[]> {
const entities = await this.em.find(RoomMembershipEntity, { roomId: { $in: roomIds } });
const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity));

return roomMemberships;
}

async findByGroupId(groupId: EntityId): Promise<RoomMembership[]> {
public async findByGroupId(groupId: EntityId): Promise<RoomMembership[]> {
const entities = await this.em.find(RoomMembershipEntity, { userGroupId: groupId });
const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity));

return roomMemberships;
}

async findByGroupIds(groupIds: EntityId[]): Promise<RoomMembership[]> {
public async findByGroupIds(groupIds: EntityId[]): Promise<RoomMembership[]> {
const entities = await this.em.find(RoomMembershipEntity, { userGroupId: { $in: groupIds } });
const roomMemberships = entities.map((entity) => RoomMembershipDomainMapper.mapEntityToDo(entity));

return roomMemberships;
}

async save(roomMembership: RoomMembership | RoomMembership[]): Promise<void> {
public async save(roomMembership: RoomMembership | RoomMembership[]): Promise<void> {
const roomMemberships = Utils.asArray(roomMembership);

roomMemberships.forEach((member) => {
Expand All @@ -51,7 +51,7 @@ export class RoomMembershipRepo {
await this.em.flush();
}

async delete(roomMembership: RoomMembership | RoomMembership[]): Promise<void> {
public async delete(roomMembership: RoomMembership | RoomMembership[]): Promise<void> {
const roomMemberships = Utils.asArray(roomMembership);

roomMemberships.forEach((member) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { CqrsModule } from '@nestjs/cqrs';
import { AuthorizationModule } from '../authorization';
import { RoleModule } from '../role';
import { RoomModule } from '../room/room.module';
import { UserModule } from '../user';
import { RoomMembershipRule } from './authorization/room-membership.rule';
import { RoomMembershipRepo } from './repo/room-membership.repo';
import { RoomMembershipService } from './service/room-membership.service';

@Module({
imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule, RoomModule],
imports: [AuthorizationModule, CqrsModule, GroupModule, RoleModule, RoomModule, UserModule],
providers: [RoomMembershipService, RoomMembershipRepo, RoomMembershipRule],
exports: [RoomMembershipService],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { GroupService, GroupTypes } from '@modules/group';
import { RoleService } from '@modules/role';
import { MongoMemoryDatabaseModule } from '@infra/database';
import { Group, GroupService, GroupTypes } from '@modules/group';
import { RoleDto, RoleService } from '@modules/role';
import { RoomService } from '@modules/room/domain';
import { roomFactory } from '@modules/room/testing';
import { schoolFactory } from '@modules/school/testing';
import { UserService } from '@modules/user';
import { BadRequestException } from '@nestjs/common/exceptions';
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 { RoomService } from '@src/modules/room/domain';
import { groupFactory, roleDtoFactory, roleFactory, userDoFactory, userFactory } from '@shared/testing';
import { RoomMembershipAuthorizable } from '../do/room-membership-authorizable.do';
import { RoomMembershipRepo } from '../repo/room-membership.repo';
import { roomMembershipFactory } from '../testing';
Expand All @@ -20,6 +22,7 @@ describe('RoomMembershipService', () => {
let groupService: DeepMocked<GroupService>;
let roleService: DeepMocked<RoleService>;
let roomService: DeepMocked<RoomService>;
let userService: DeepMocked<UserService>;

beforeAll(async () => {
module = await Test.createTestingModule({
Expand All @@ -42,6 +45,10 @@ describe('RoomMembershipService', () => {
provide: RoomService,
useValue: createMock<RoomService>(),
},
{
provide: UserService,
useValue: createMock<UserService>(),
},
],
}).compile();

Expand All @@ -50,6 +57,7 @@ describe('RoomMembershipService', () => {
groupService = module.get(GroupService);
roleService = module.get(RoleService);
roomService = module.get(RoomService);
userService = module.get(UserService);
});

afterAll(async () => {
Expand Down Expand Up @@ -113,9 +121,14 @@ describe('RoomMembershipService', () => {
describe('when roomMembership exists', () => {
const setup = () => {
const user = userFactory.buildWithId();
const group = groupFactory.build({ type: GroupTypes.ROOM });
const room = roomFactory.build();
const roomMembership = roomMembershipFactory.build({ roomId: room.id, userGroupId: group.id });
const school = schoolFactory.build();
const group = groupFactory.build({ type: GroupTypes.ROOM, organizationId: school.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);

Expand All @@ -136,6 +149,14 @@ describe('RoomMembershipService', () => {
{ userId: user.id, roleName: RoleName.ROOMEDITOR },
]);
});

it('should add user to school', async () => {
const { user, room } = setup();

await service.addMembersToRoom(room.id, [{ userId: user.id, roleName: RoleName.ROOMEDITOR }]);

expect(userService.addSecondarySchoolToUsers).toHaveBeenCalledWith([user.id], room.schoolId);
});
});
});

Expand Down Expand Up @@ -176,6 +197,7 @@ describe('RoomMembershipService', () => {

roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership);
groupService.findById.mockResolvedValue(group);
groupService.findGroups.mockResolvedValue({ total: 1, data: [group] });

return {
user,
Expand All @@ -193,6 +215,94 @@ describe('RoomMembershipService', () => {
expect(groupService.removeUsersFromGroup).toHaveBeenCalledWith(group.id, [user.id]);
});
});

const setupUserWithSecondarySchool = () => {
const secondarySchool = schoolFactory.build();
const otherSchool = schoolFactory.build();
const role = roleFactory.buildWithId({ name: RoleName.TEACHER });
const guestTeacher = roleFactory.buildWithId({ name: RoleName.GUESTTEACHER });
const externalUser = userDoFactory.buildWithId({
roles: [role],
secondarySchools: [{ schoolId: secondarySchool.id, role: new RoleDto(guestTeacher) }],
});

return { secondarySchool, externalUser, otherSchool };
};

const setupGroupAndRoom = (schoolId: string) => {
const group = groupFactory.build({ type: GroupTypes.ROOM });
const room = roomFactory.build({ schoolId });
const roomMembership = roomMembershipFactory.build({
roomId: room.id,
userGroupId: group.id,
schoolId,
});

return { group, room, roomMembership };
};

const mockGroupsAtSchoolAfterRemoval = (groups: Group[]) => {
groupService.findGroups.mockResolvedValue({ total: groups.length, data: groups });
};

it('should pass the schoolId of the room', async () => {
const { secondarySchool, externalUser } = setupUserWithSecondarySchool();

const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR });

const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id);
group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id });

roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership);
groupService.findById.mockResolvedValue(group);
groupService.removeUsersFromGroup.mockResolvedValue(group);
mockGroupsAtSchoolAfterRemoval([]);

await service.removeMembersFromRoom(room.id, [externalUser.id as string]);

expect(groupService.findGroups).toHaveBeenCalledWith(expect.objectContaining({ schoolId: secondarySchool.id }));
});

describe('when after removal: user is not in any room of that secondary school', () => {
it('should remove user from secondary school', async () => {
const { secondarySchool, externalUser } = setupUserWithSecondarySchool();

const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id);
const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR });
group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id });

roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership);
groupService.findById.mockResolvedValue(group);
groupService.removeUsersFromGroup.mockResolvedValue(group);
mockGroupsAtSchoolAfterRemoval([]);

await service.removeMembersFromRoom(room.id, [externalUser.id as string]);

expect(userService.removeSecondarySchoolFromUsers).toHaveBeenCalledWith([externalUser.id], secondarySchool.id);
});
});

describe('when after removal: user is still in a room of that secondary school', () => {
it('should not remove user from secondary school', async () => {
const { secondarySchool, externalUser } = setupUserWithSecondarySchool();

const roomEditorRole = roleFactory.buildWithId({ name: RoleName.ROOMEDITOR });

const { room, group, roomMembership } = setupGroupAndRoom(secondarySchool.id);
group.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id });
const { group: group2 } = setupGroupAndRoom(secondarySchool.id);
group2.addUser({ userId: externalUser.id as string, roleId: roomEditorRole.id });

roomMembershipRepo.findByRoomId.mockResolvedValue(roomMembership);
groupService.findById.mockResolvedValue(group);
groupService.removeUsersFromGroup.mockResolvedValue(group);
mockGroupsAtSchoolAfterRemoval([group2]);

await service.removeMembersFromRoom(room.id, [externalUser.id as string]);

expect(userService.removeSecondarySchoolFromUsers).not.toHaveBeenCalled();
});
});
});

describe('deleteRoomMembership', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ObjectId } from '@mikro-orm/mongodb';
import { Group, GroupService, GroupTypes } from '@modules/group';
import { RoleDto, RoleService } from '@modules/role';
import { UserService } from '@modules/user';
import { BadRequestException, Injectable } from '@nestjs/common';
import { RoleName } from '@shared/domain/interface';
import { EntityId } from '@shared/domain/types';
Expand All @@ -15,14 +16,15 @@ export class RoomMembershipService {
private readonly groupService: GroupService,
private readonly roomMembershipRepo: RoomMembershipRepo,
private readonly roleService: RoleService,
private readonly roomService: RoomService
private readonly roomService: RoomService,
private readonly userService: UserService
) {}

private async createNewRoomMembership(
roomId: EntityId,
userId: EntityId,
roleName: RoleName.ROOMEDITOR | RoleName.ROOMVIEWER
) {
): Promise<RoomMembership> {
const room = await this.roomService.getSingleRoom(roomId);

const group = await this.groupService.createGroup(
Expand Down Expand Up @@ -65,7 +67,7 @@ export class RoomMembershipService {
return roomMembershipAuthorizable;
}

public async deleteRoomMembership(roomId: EntityId) {
public async deleteRoomMembership(roomId: EntityId): Promise<void> {
const roomMembership = await this.roomMembershipRepo.findByRoomId(roomId);
if (roomMembership === null) return;

Expand All @@ -90,6 +92,9 @@ export class RoomMembershipService {

await this.groupService.addUsersToGroup(roomMembership.userGroupId, userIdsAndRoles);

const userIds = userIdsAndRoles.map((user) => user.userId);
await this.userService.addSecondarySchoolToUsers(userIds, roomMembership.schoolId);

return roomMembership.id;
}

Expand All @@ -101,6 +106,8 @@ export class RoomMembershipService {

const group = await this.groupService.findById(roomMembership.userGroupId);
await this.groupService.removeUsersFromGroup(group.id, userIds);

await this.handleGuestRoleRemoval(userIds, roomMembership.schoolId);
}

public async getRoomMembershipAuthorizablesByUserId(userId: EntityId): Promise<RoomMembershipAuthorizable[]> {
Expand Down Expand Up @@ -141,4 +148,15 @@ export class RoomMembershipService {

return roomMembershipAuthorizable;
}

private async handleGuestRoleRemoval(userIds: EntityId[], schoolId: EntityId): Promise<void> {
const { data: groups } = await this.groupService.findGroups({ userIds, groupTypes: [GroupTypes.ROOM], schoolId });

const userIdsInGroups = groups.flatMap((group) => group.users.map((groupUser) => groupUser.userId));
const removeUserIds = userIds.filter((userId) => !userIdsInGroups.includes(userId));

if (removeUserIds.length > 0) {
await this.userService.removeSecondarySchoolFromUsers(removeUserIds, schoolId);
}
}
}
Loading

0 comments on commit 2104505

Please sign in to comment.