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

feat: add endpoint rooms.membersOrderedByRole #34153

Open
wants to merge 16 commits into
base: develop
Choose a base branch
from
Open
6 changes: 6 additions & 0 deletions .changeset/silly-kings-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/rest-typings': minor
---

Adds `rooms.membersOrderedByRole` endpoint to retrieve members of groups and channels sorted according to their respective role in the room.
55 changes: 55 additions & 0 deletions apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
isRoomsIsMemberProps,
isRoomsCleanHistoryProps,
isRoomsOpenProps,
isRoomsMembersOrderedByRoleProps,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';

import { isTruthy } from '../../../../lib/isTruthy';
import { omit } from '../../../../lib/utils/omit';
import * as dataExport from '../../../../server/lib/dataExport';
import { eraseRoom } from '../../../../server/lib/eraseRoom';
import { findUsersOfRoomOrderedByRole } from '../../../../server/lib/findUsersOfRoomOrderedByRole';
import { openRoom } from '../../../../server/lib/openRoom';
import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom';
import { unmuteUserInRoom } from '../../../../server/methods/unmuteUserInRoom';
Expand All @@ -26,6 +28,7 @@ import { saveRoomSettings } from '../../../channel-settings/server/methods/saveR
import { createDiscussion } from '../../../discussion/server/methods/createDiscussion';
import { FileUpload } from '../../../file-upload/server';
import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMessage';
import { syncRolePrioritiesForRoomIfRequired } from '../../../lib/server/functions/syncRolePrioritiesForRoomIfRequired';
import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom';
import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper';
import { settings } from '../../../settings/server';
Expand Down Expand Up @@ -857,6 +860,58 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'rooms.membersOrderedByRole',
{ authRequired: true, validateParams: isRoomsMembersOrderedByRoleProps },
{
async get() {
const findResult = await findRoomByIdOrName({
abhinavkrin marked this conversation as resolved.
Show resolved Hide resolved
params: this.queryParams,
checkedArchived: false,
});
abhinavkrin marked this conversation as resolved.
Show resolved Hide resolved

if (!(await canAccessRoomAsync(findResult, this.user))) {
return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room');
}

if (findResult.t !== 'c' && findResult.t !== 'p') {
return API.v1.failure('error-room-type-not-supported');
}

if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) {
return API.v1.unauthorized();
}

// Ensures that role priorities for the specified room are synchronized correctly.
// This function acts as a soft migration. If the `roomRolePriorities` field
// for the room has already been created and is up-to-date, no updates will be performed.
// If not, it will synchronize the role priorities of the users of the room.
await syncRolePrioritiesForRoomIfRequired(findResult._id);

const { offset: skip, count: limit } = await getPaginationItems(this.queryParams);
const { sort = {} } = await this.parseJsonQuery();

const { status, filter } = this.queryParams;

const { members, total } = await findUsersOfRoomOrderedByRole({
rid: findResult._id,
...(status && { status: { $in: status } }),
skip,
limit,
filter,
sort,
});

return API.v1.success({
members,
count: members.length,
offset: skip,
total,
});
},
},
);

API.v1.addRoute(
'rooms.muteUser',
{ authRequired: true, validateParams: isRoomsMuteUnmuteUserProps },
Expand Down
9 changes: 9 additions & 0 deletions apps/meteor/app/lib/server/functions/createRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createDirectRoom } from './createDirectRoom';
import { callbacks } from '../../../../lib/callbacks';
import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback';
import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig';
import { calculateRoomRolePriorityFromRoles, syncRoomRolePriorityForUserAndRoom } from '../../../../server/lib/roles/syncRoomRolePriority';
import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref';
import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName';
import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../lib/notifyListener';
Expand Down Expand Up @@ -48,6 +49,7 @@ async function createUsersSubscriptions({
};

const { insertedId } = await Subscriptions.createWithRoomAndUser(room, owner, extra);
await syncRoomRolePriorityForUserAndRoom(owner._id, room._id, ['owner']);

if (insertedId) {
await notifyOnRoomChanged(room, 'inserted');
Expand All @@ -60,6 +62,8 @@ async function createUsersSubscriptions({

const memberIds = [];

const memberIdAndRolePriorityMap: Record<IUser['_id'], number> = {};

const membersCursor = Users.findUsersByUsernames<Pick<IUser, '_id' | 'username' | 'settings' | 'federated' | 'roles'>>(members, {
projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 },
});
Expand Down Expand Up @@ -95,13 +99,18 @@ async function createUsersSubscriptions({
...getDefaultSubscriptionPref(member),
},
});

if (extra.roles) {
memberIdAndRolePriorityMap[member._id] = calculateRoomRolePriorityFromRoles(extra.roles);
}
}

if (!['d', 'l'].includes(room.t)) {
await Users.addRoomByUserIds(memberIds, room._id);
}

const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs);
await Users.assignRoomRolePrioritiesByUserIdPriorityMap(memberIdAndRolePriorityMap, room._id);

Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted'));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { IRole, IRoom, IUser } from '@rocket.chat/core-typings';
import { ROOM_ROLE_PRIORITY_MAP } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';

export const getRoomRolePriorityForRole = (role: IRole['_id']) =>
(ROOM_ROLE_PRIORITY_MAP as { [key: IRole['_id']]: number })[role] ?? ROOM_ROLE_PRIORITY_MAP.default;

export const calculateRoomRolePriorityFromRoles = (roles: IRole['_id'][]) => {
return roles.reduce((acc, roleId) => Math.min(acc, getRoomRolePriorityForRole(roleId)), ROOM_ROLE_PRIORITY_MAP.default);
};

const READ_BATCH_SIZE = 1000;

async function assignRoomRolePrioritiesFromMap(userIdAndroomRolePrioritiesMap: Map<IUser['_id'], IUser['roomRolePriorities']>) {
const bulk = Users.col.initializeUnorderedBulkOp();

for await (const [userId, roomRolePriorities] of userIdAndroomRolePrioritiesMap.entries()) {
userIdAndroomRolePrioritiesMap.delete(userId);

if (roomRolePriorities) {
const updateFields = Object.entries(roomRolePriorities).reduce(
(operations, rolePriorityData) => {
const [rid, rolePriority] = rolePriorityData;
operations[`roomRolePriorities.${rid}`] = rolePriority;
return operations;
},
{} as Record<string, number>,
);

bulk.find({ _id: userId }).updateOne({
$set: updateFields,
});
}
}

if (bulk.length > 0) {
await bulk.execute();
}
}

export const syncRolePrioritiesForRoomIfRequired = async (rid: IRoom['_id']) => {
const userIdAndroomRolePrioritiesMap = new Map<IUser['_id'], IUser['roomRolePriorities']>();

if (await Rooms.hasCreatedRolePrioritiesForRoom(rid)) {
return;
}

const cursor = Subscriptions.find(
{ rid, roles: { $exists: true } },
{
projection: { 'rid': 1, 'roles': 1, 'u._id': 1 },
sort: { _id: 1 },
},
).batchSize(READ_BATCH_SIZE);

for await (const sub of cursor) {
if (!sub.roles?.length) {
continue;
}

const userId = sub.u._id;
const roomId = sub.rid;
const priority = calculateRoomRolePriorityFromRoles(sub.roles);

const existingPriorities = userIdAndroomRolePrioritiesMap.get(userId) || {};
existingPriorities[roomId] = priority;
userIdAndroomRolePrioritiesMap.set(userId, existingPriorities);
}

// Flush any remaining priorities in the map
await assignRoomRolePrioritiesFromMap(userIdAndroomRolePrioritiesMap);

await Rooms.markRolePrioritesCreatedForRoom(rid);
};
128 changes: 128 additions & 0 deletions apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { type IUser, type IRole, ROOM_ROLE_PRIORITY_MAP } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { Document, FilterOperators } from 'mongodb';

import { settings } from '../../app/settings/server';

type FindUsersParam = {
rid: string;
status?: FilterOperators<string>;
skip?: number;
limit?: number;
filter?: string;
sort?: Record<string, any>;
exceptions?: string[];
extraQuery?: Document[];
};

type UserWithRoleData = IUser & {
roles: IRole['_id'][];
};

export async function findUsersOfRoomOrderedByRole({
KevLehman marked this conversation as resolved.
Show resolved Hide resolved
rid,
status,
skip = 0,
limit = 0,
filter = '',
sort = {},
exceptions = [],
extraQuery = [],
}: FindUsersParam): Promise<{ members: UserWithRoleData[]; total: number }> {
const searchFields = settings.get<string>('Accounts_SearchFields').trim().split(',');
const termRegex = new RegExp(escapeRegExp(filter), 'i');
const orStmt = filter && searchFields.length ? searchFields.map((field) => ({ [field.trim()]: termRegex })) : [];

const { rolePriority: rolePrioritySort, username: usernameSort } = sort;

const sortCriteria = {
rolePriority: rolePrioritySort ?? 1,
statusConnection: -1,
...(usernameSort ?? {
...(settings.get('UI_Use_Real_Name') ? { name: 1 } : { username: 1 }),
}),
};

const matchUserFilter = {
$and: [
{
__rooms: rid,
active: true,
username: {
$exists: true,
...(exceptions.length > 0 && { $nin: exceptions }),
},
...(status && { status }),
...(filter && orStmt.length > 0 && { $or: orStmt }),
},
...extraQuery,
],
};

const membersResult = Users.col.aggregate<UserWithRoleData>(
[
{
$match: matchUserFilter,
},
{
$project: {
_id: 1,
name: 1,
username: 1,
nickname: 1,
status: 1,
avatarETag: 1,
_updatedAt: 1,
federated: 1,
rolePriority: {
$ifNull: [`$roomRolePriorities.${rid}`, ROOM_ROLE_PRIORITY_MAP.default],
},
},
},
{ $sort: sortCriteria },
...(skip > 0 ? [{ $skip: skip }] : []),
...(limit > 0 ? [{ $limit: limit }] : []),
{
$lookup: {
from: Subscriptions.getCollectionName(),
as: 'subscription',
let: { userId: '$_id', roomId: rid },
pipeline: [
{
$match: {
$expr: {
$and: [{ $eq: ['$rid', '$$roomId'] }, { $eq: ['$u._id', '$$userId'] }],
},
},
},
{ $project: { roles: 1 } },
],
},
},
{
$addFields: {
roles: { $arrayElemAt: ['$subscription.roles', 0] },
},
},
{
$project: {
subscription: 0,
},
},
],
{
allowDiskUse: true,
},
);

const [members, totalCount] = await Promise.all([membersResult.toArray(), Users.countDocuments(matchUserFilter)]);

return {
members: members.map((member: any) => {
delete member.rolePriority;
return member;
}),
total: totalCount,
};
}
2 changes: 2 additions & 0 deletions apps/meteor/server/lib/roles/addUserRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services';
import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings';
import { Roles, Subscriptions, Users } from '@rocket.chat/models';

import { syncRoomRolePriorityForUserAndRoom } from './syncRoomRolePriority';
import { validateRoleList } from './validateRoleList';
import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener';

Expand Down Expand Up @@ -33,6 +34,7 @@ export const addUserRolesAsync = async (userId: IUser['_id'], roles: IRole['_id'

if (role.scope === 'Subscriptions' && scope) {
const addRolesResponse = await Subscriptions.addRolesByUserId(userId, [role._id], scope);
await syncRoomRolePriorityForUserAndRoom(userId, scope);
if (addRolesResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(scope, userId);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/server/lib/roles/removeUserFromRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services';
import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Roles } from '@rocket.chat/models';

import { syncRoomRolePriorityForUserAndRoom } from './syncRoomRolePriority';
import { validateRoleList } from './validateRoleList';
import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener';

Expand Down Expand Up @@ -31,6 +32,7 @@ export const removeUserFromRolesAsync = async (userId: IUser['_id'], roles: IRol

if (role.scope === 'Subscriptions' && scope) {
const removeRolesResponse = await Subscriptions.removeRolesByUserId(userId, [roleId], scope);
await syncRoomRolePriorityForUserAndRoom(userId, scope);
if (removeRolesResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(scope, userId);
}
Expand Down
Loading
Loading