-
Notifications
You must be signed in to change notification settings - Fork 17
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-7971 - introduce room members module #5291
Conversation
export class RoomMemberRepo { | ||
constructor(private readonly em: EntityManager) {} | ||
|
||
async findById(id: EntityId): Promise<RoomMemberEntity | null> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The repo should return a RoomMember (DO) instead of the RoomMemberEntity.
There already is the DO, as well as the mapper, but both are so far unused. The Repo should be responsible for mapping the entity to a DO before returning it, therefore removing the entity from the interface of the repository entirely, making the repo the only class that knows about the Entity.
import { RoleRepo } from '@shared/repo'; | ||
import { Action, AuthorizationService } from '@src/modules/authorization'; | ||
import { Group, GroupService, GroupTypes } from '@src/modules/group'; | ||
import { GroupEntity, GroupEntityTypes, GroupUserEmbeddable } from '@src/modules/group/entity'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
forbidden import. The Module should not expose its entities, and you should not import something deeper than the index file of the module that denotes its public parts.
id: new ObjectId().toHexString(), | ||
}); | ||
const savedGroup = await this.groupService.save(newGroup); | ||
const groupEntity = new GroupEntity({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this mapping shouldnt be part of the logic. If its absolutely necessary to do, it needs to be done in the datalayer, using a defined entity mapper. Here in the service, you should only work with the domain objects, Group in this case.
You will probably run into the issue that the RoomMemberRepo in turn will also not be allowed to know about and use these Entities and Mappers. Generally we decided to use ids instead of full DO representations to keep the modules and domains seperated, and not having to import anything that shouldnt be shared, and instead orchestrate multiple services to get all the data together that we need.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thinking this through, im not sure what the ideal solution is. I see two possible approaches.
The first one, is to use an id instead of a reference in the DO, and simply use it as an inbetween step to fetching the group next. That is definetly clean dependencywise, but also feels pretty cluncy.
The second approach is to always have the group populated in the RoomMembers object, and pushing the responsability for mapping this into the repository (since the repository should create Domain Objects).
Again, the goal should be to not knowing about the GroupEntity or the GroupDomainMapper. The cleanest would be to have only the id in the entity, and after loading the entity calling the group service from the Repo to fetch the DO for the Group, and then creating the GroupMembers DO with it. The disadvantage here is that we need two calls to the database.
a more "pragmatic" approach would be to break some rules, and simply import the things you need to do the mapping in the RoomMembers Repo. At least it would retain the borders and responsabilities of the layers, though it would mean a very close coupling to the group module. (and a break of our import rules)
An in-between solution could be to still do the populate in the database command, but without knowing what it means, and then fetching the DO from the other module via its id, relying on the identitymap to avoid another call to the database. But I dont know exactly if and how that could work, as I think the populate requires at least the entity to know the other entity, thus already breaking module borders... so I think this one seems unfeasable for now.
For this PR, im fine with either solution, as long as layer responsabilities are kept. But for the future we should figure out how to deal with such a case, where "id only" might not suffice as a hard rule.
return this.roomMembersRepo.save(roomMemberEntity); | ||
} | ||
|
||
public async hasAuthorization(roomId: EntityId, user: User, action: Action) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there shoudnt be a need for this function, generally the uc is calling the authorization service directly.
|
||
public async addMemberToRoom(roomId: EntityId, user: User, roleName: RoleName) { | ||
const role = await this.roleRepo.findByName(roleName); | ||
const groupUser = new GroupUserEmbeddable({ role, user }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a very data centric way of doing this. I think it would be more readable to simply fetch the DO (or even recieve it as argument), call something like addMember(userid, role) on it, and then call repo.save.
That way, instanciating data object can be hidden within the repo, or the DO if it requires it as well.
}); | ||
const authorizedRooms = rooms.data.filter((room) => permittedRoomSet.has(room.id)); | ||
|
||
// TODO: must find a way to pagainate correctly over the authorized roomIds |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I dont know how you would ever do that. But loading all rooms and then filtering seems extremely inefficient anyways, as you would have to load ALL rooms in the entire instance, which after a couple of years will be a LOT.
I would instead turn this around, first fetching all RoomMember entries that contain the user, then fetching all these rooms from the roomService by their ids, observing pagination.
|
||
const user = await this.authorizationService.getUserWithPermissions(userId); | ||
// NOTE: currently only teacher are allowed to create rooms. Could not find simpler way to check this. | ||
this.authorizationService.checkOneOfPermissions(user, [Permission.TASK_DASHBOARD_TEACHER_VIEW_V3]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
surely you can use a permission that has something to do with the operation we are doing?
Either introduce new permissions like ROOM_CREATE, or at the very least use something connected like COURSE_CREATE, assuming that everyone that may create a course is also allowed to create a room. though a new permission is most definetly cleaner.
|
||
// TODO check authorization | ||
const user = await this.authorizationService.getUserWithPermissions(userId); | ||
const hasAuthorization = await this.roomMemberService.hasAuthorization(roomId, user, Action.read); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use authorisationService instead
- Return do in room member service - Move authorization from room-member to room module - fix imports
} | ||
|
||
public removeMember(userId: ObjectId): void { | ||
this.props.members = this.props.members.filter((member) => member.userId !== userId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I couldnt find any code in the data layer that transforms this back into the usergroup. How do you make sure changes made here are saved into the database?
I guess either you should have an actual reference to a groupDO in here that you update, or the changes made to this array need to be mapped back into the group somewhere.
constructor(private readonly em: EntityManager, private readonly groupRepo: GroupRepo) {} | ||
|
||
async findByUserId(userId: EntityId): Promise<RoomMember[]> { | ||
const groups = await this.groupRepo.findGroups({ userId, groupTypes: [GroupTypes.ROOM] }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use the groupService instead. the groupRepo is private and should not be used outside the group module. It also shouldnt make a difference for you, since the repo also returns DOs.
const roomEntities = await this.em.find(RoomMemberEntity, scope.query); | ||
const roomMembers = await Promise.all( | ||
roomEntities.map(async (roomEntity) => { | ||
const group = groups.data.find((g) => g.id === roomEntity.userGroupId.toHexString()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems really complicated and strange. You already have the group DOs, and then you map them back to entities, in order to map the whole thing back to DOs. Why that step?
Is it just to populate the users? most of the time we wont need the users, so I would be in favor of just not populating them, and load them later when they are needed. For example, all authorisation can be done without populating (only need ids), all interactions with the main page of rooms can be done (doesnt need the user list at all, just the list of boards and such). Only interactions with the usermanagement of the rooms needs more information on the users.
So I would simply add the group itself to the DO with the information it contains (userIds), and not bother trying to populate more information into it that might not be needed for most operations. I think that also allows you to significantly reduce dependecies.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hope to have simplified some of the data transformations.
Due to the nature of module boundaries we have different db queries.
In general I see map, filter, reduce very readable . I don't like the Promise.All things which are removed.
But maybe I don't understand oop & ddd.
@@ -2,3 +2,5 @@ export { GroupModule } from './group.module'; | |||
export { GroupConfig } from './group.config'; | |||
export * from './domain'; | |||
export { GroupService } from './service'; | |||
export * from './repo'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the repo and entity are private, and shall not be exported.
import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; | ||
import { Injectable } from '@nestjs/common'; | ||
import { EntityId } from '@shared/domain/types'; | ||
import { Group, GroupRepo, GroupTypes, GroupDomainMapper } from '@src/modules/group'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GroupRepo and GroupDomainMapper are private. We should try to find a way to not need them here. If we reallly really do, we should talk in the ARC chapter about how to avoid that, and for now do a deep import with a comment and ticket to change it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I reversed the exposure of the model internal things. Repo, Entity, Mapper.
constructor( | ||
private readonly roomMembersRepo: RoomMemberRepo, | ||
private readonly groupService: GroupService, | ||
private readonly roleRepo: RoleRepo, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use RoleService instead
private readonly roomMembersRepo: RoomMemberRepo, | ||
private readonly groupService: GroupService, | ||
private readonly roleRepo: RoleRepo, | ||
private readonly em: EntityManager |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove this, domain layer should never be tied to datalayer details.
private readonly roomService: RoomService, | ||
private readonly roomMemberService: RoomMemberService, | ||
private readonly authorizationService: AuthorizationService, | ||
private readonly roomMembersRepo: RoomMemberRepo |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use a service instad.
|
||
@Module({ | ||
imports: [CqrsModule], | ||
providers: [RoomRepo, RoomService], | ||
imports: [CqrsModule, RoomMemberModule, GroupModule, RoleModule, AuthorizationModule], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wasnt the whole purpose of having a seperate roomMemberModule to keep it out of the room Module? where did you run into issues, and need it in the roomModule? we should see if thats avoidable, otherwise we should think about combining them into one module, since it kind of defeats the point...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; | ||
import { RoomMember } from '@src/modules/room-member'; | ||
|
||
export interface RoomAuthorizableProps extends AuthorizableObject { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should probably be part of the RoomMembers module, since for authorization we pretty much only need the members. Maybe we dont even need a seperate class, but can just use the RoomMembers DO to authorize rooms
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes moved to RoomMember
I made the RoomMember Module very dumb. Only 2 hard references. Making the RoomMember dumb made the coding much easier. But the drawback is more network latency due to module boundaries. Note: Tests are not done yet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this structure a lot better! still some cleaning up to do obviously (the room authorizable can be removed right?), but this is the right direction.
Regarding the promise.all, it allows for a bit better performance, since different database queries can happen in parallel. If you like you can check if there is some potential there, but I dont consider that necessary for this PR.
|
||
@Module({ | ||
imports: [CqrsModule], | ||
imports: [CqrsModule, RoomMemberModule, AuthorizationModule], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are these references still needed?
Quality Gate passedIssues Measures |
Tests included. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally I'm not happy with the entity name RoomMember
because it suggests that this is about a single room member instead of a group of members assigned to a single room. Maybe it would be better to name it RoomMembership
?
@@ -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, | |||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW I'm wondering why we aren't using the domain enum for the group type in the response. That should actually be possible and valid IMHO. But that's not a subject of this PR.
@@ -9,6 +9,7 @@ import { GroupValidPeriodEmbeddable } from './group-valid-period.embeddable'; | |||
export enum GroupEntityTypes { | |||
CLASS = 'class', | |||
COURSE = 'course', | |||
ROOM = 'room', | |||
OTHER = 'other', | |||
} | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also similar to the response above we could use the GroupType
enum from the domain here. Again maybe not in this PR.
[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, | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This kind of mapping wouldn't be necessary when we used GroupType
enum from domain.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the repo (see above)?
organizationId, | ||
}); | ||
|
||
await this.save(group); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For persistence operations it's maybe better to use the repo instead of the service methods. This can help prevent unwanted side effects.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One of the points discussed where that room members should not have access to the group internals.
That is why we have to use the do layer here.
constructor(private readonly em: EntityManager) {} | ||
|
||
async findByRoomId(roomId: EntityId): Promise<RoomMember | null> { | ||
const roomMemberEntities = await this.em.findOne(RoomMemberEntity, { roomId }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
singular roomMemberEntity
?
export class RoomMemberService { | ||
constructor( | ||
private readonly groupService: GroupService, | ||
private readonly roomMembersRepo: RoomMemberRepo, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
singular roomMemberRepo
? like the name of the class?
return roomMember; | ||
} | ||
|
||
private static buildRoomMemberAuthorizable( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why static
?
Also we should maybe put the private
methods below the public
methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this method is free of side effects
That is why I put that as static to indicate that
|
||
public async deleteRoomMember(roomId: EntityId) { | ||
const roomMember = await this.roomMembersRepo.findByRoomId(roomId); | ||
if (roomMember === null) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe roomMember == null
is more safe? I know "typescript-wise" this should be null
but I personally would prefer a more general check for existence.
@@ -9,6 +9,8 @@ export enum RoleName { | |||
DEMOTEACHER = 'demoTeacher', | |||
EXPERT = 'expert', | |||
HELPDESK = 'helpdesk', | |||
ROOM_VIEWER = 'room_viewer', | |||
ROOM_EDITOR = 'room_editor', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is snake_case the right convention for enum values?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes you are right. The keys can be reversed.
But the values are already written. Hmm. but we can migrate them.
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
Description
Each room needs to be linked with users including permissions.
The users should be batched into groups using the groups module.
This PR also add 2 new roles ROLE_VIEWER & ROLE_EDITOR.
Links to Tickets or other pull requests
BC-7971
Doc Page
Changes
Adds a new nest module "room-member-module".
This module is used by room module to manage members.
Approval for review
generate-client:server
was executed in vue frontend and changes were tested and put in a PR with the same branch name.