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-7971 - introduce room members module #5291

Merged
merged 33 commits into from
Oct 23, 2024
Merged

BC-7971 - introduce room members module #5291

merged 33 commits into from
Oct 23, 2024

Conversation

EzzatOmar
Copy link
Contributor

@EzzatOmar EzzatOmar commented Oct 15, 2024

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

  • DEV: If api was changed - generate-client:server was executed in vue frontend and changes were tested and put in a PR with the same branch name.
  • QA: In addition to review, the code has been manually tested (if manual testing is possible)
  • All points were discussed with the ticket creator, support-team or product owner. The code upholds all quality guidelines from the PR-template.

Notice: Please remove the WIP label if the PR is ready to review, otherwise nobody will review it.

export class RoomMemberRepo {
constructor(private readonly em: EntityManager) {}

async findById(id: EntityId): Promise<RoomMemberEntity | null> {
Copy link
Contributor

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';
Copy link
Contributor

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({
Copy link
Contributor

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.

Copy link
Contributor

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) {
Copy link
Contributor

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 });
Copy link
Contributor

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
Copy link
Contributor

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]);
Copy link
Contributor

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use authorisationService instead

}

public removeMember(userId: ObjectId): void {
this.props.members = this.props.members.filter((member) => member.userId !== userId);
Copy link
Contributor

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] });
Copy link
Contributor

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());
Copy link
Contributor

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.

Copy link
Contributor Author

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';
Copy link
Contributor

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';
Copy link
Contributor

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.

Copy link
Contributor Author

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,
Copy link
Contributor

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
Copy link
Contributor

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
Copy link
Contributor

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],
Copy link
Contributor

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...

Copy link
Contributor Author

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 {
Copy link
Contributor

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes moved to RoomMember

@EzzatOmar
Copy link
Contributor Author

I made the RoomMember Module very dumb. Only 2 hard references.
The DO Layer is responsible to check for data consistence. I have implemented 2 more methods in the group service to support our use case.

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.

Copy link
Contributor

@Metauriel Metauriel left a 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],
Copy link
Contributor

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?

Copy link

@EzzatOmar
Copy link
Contributor Author

Tests included.
Cleanup fixed.

@EzzatOmar EzzatOmar enabled auto-merge (squash) October 23, 2024 14:06
@EzzatOmar EzzatOmar merged commit bbca0b9 into main Oct 23, 2024
78 checks passed
@EzzatOmar EzzatOmar deleted the BC-7971-room-members branch October 23, 2024 14:47
Copy link
Contributor

@uidp uidp left a 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,
};
Copy link
Contributor

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',
}

Copy link
Contributor

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,
};

Copy link
Contributor

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);
Copy link
Contributor

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);
Copy link
Contributor

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.

Copy link
Contributor Author

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 });
Copy link
Contributor

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,
Copy link
Contributor

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(
Copy link
Contributor

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.

Copy link
Contributor Author

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;
Copy link
Contributor

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',
Copy link
Contributor

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?

Copy link
Contributor Author

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.

hoeppner-dataport pushed a commit that referenced this pull request Oct 24, 2024
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants