diff --git a/.changeset/silly-kings-approve.md b/.changeset/silly-kings-approve.md new file mode 100644 index 000000000000..abe68fb8d92a --- /dev/null +++ b/.changeset/silly-kings-approve.md @@ -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. diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 8b545b0b8e2a..7adf810a2358 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -10,6 +10,7 @@ import { isRoomsIsMemberProps, isRoomsCleanHistoryProps, isRoomsOpenProps, + isRoomsMembersOrderedByRoleProps, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -17,6 +18,7 @@ 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'; @@ -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'; @@ -857,6 +860,58 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'rooms.membersOrderedByRole', + { authRequired: true, validateParams: isRoomsMembersOrderedByRoleProps }, + { + async get() { + const findResult = await findRoomByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + 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 }, diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index c9d5b0e31075..c9b5354623b7 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -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'; @@ -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'); @@ -60,6 +62,8 @@ async function createUsersSubscriptions({ const memberIds = []; + const memberIdAndRolePriorityMap: Record = {}; + const membersCursor = Users.findUsersByUsernames>(members, { projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 }, }); @@ -95,6 +99,10 @@ async function createUsersSubscriptions({ ...getDefaultSubscriptionPref(member), }, }); + + if (extra.roles) { + memberIdAndRolePriorityMap[member._id] = calculateRoomRolePriorityFromRoles(extra.roles); + } } if (!['d', 'l'].includes(room.t)) { @@ -102,6 +110,7 @@ async function createUsersSubscriptions({ } const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs); + await Users.assignRoomRolePrioritiesByUserIdPriorityMap(memberIdAndRolePriorityMap, room._id); Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted')); diff --git a/apps/meteor/app/lib/server/functions/syncRolePrioritiesForRoomIfRequired.ts b/apps/meteor/app/lib/server/functions/syncRolePrioritiesForRoomIfRequired.ts new file mode 100644 index 000000000000..a22f8634890a --- /dev/null +++ b/apps/meteor/app/lib/server/functions/syncRolePrioritiesForRoomIfRequired.ts @@ -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) { + 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, + ); + + 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(); + + 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); +}; diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts new file mode 100644 index 000000000000..91b7332afc24 --- /dev/null +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -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; + skip?: number; + limit?: number; + filter?: string; + sort?: Record; + exceptions?: string[]; + extraQuery?: Document[]; +}; + +type UserWithRoleData = IUser & { + roles: IRole['_id'][]; +}; + +export async function findUsersOfRoomOrderedByRole({ + rid, + status, + skip = 0, + limit = 0, + filter = '', + sort = {}, + exceptions = [], + extraQuery = [], +}: FindUsersParam): Promise<{ members: UserWithRoleData[]; total: number }> { + const searchFields = settings.get('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( + [ + { + $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, + }; +} diff --git a/apps/meteor/server/lib/roles/addUserRoles.ts b/apps/meteor/server/lib/roles/addUserRoles.ts index 1333f0af6237..4e098b6fc447 100644 --- a/apps/meteor/server/lib/roles/addUserRoles.ts +++ b/apps/meteor/server/lib/roles/addUserRoles.ts @@ -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'; @@ -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); } diff --git a/apps/meteor/server/lib/roles/removeUserFromRoles.ts b/apps/meteor/server/lib/roles/removeUserFromRoles.ts index de12ec91e66a..db4bdab48413 100644 --- a/apps/meteor/server/lib/roles/removeUserFromRoles.ts +++ b/apps/meteor/server/lib/roles/removeUserFromRoles.ts @@ -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'; @@ -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); } diff --git a/apps/meteor/server/lib/roles/syncRoomRolePriority.ts b/apps/meteor/server/lib/roles/syncRoomRolePriority.ts new file mode 100644 index 000000000000..ec78d43210b0 --- /dev/null +++ b/apps/meteor/server/lib/roles/syncRoomRolePriority.ts @@ -0,0 +1,51 @@ +import type { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; +import { ROOM_ROLE_PRIORITY_MAP } from '@rocket.chat/core-typings'; +import { Subscriptions, Users } from '@rocket.chat/models'; + +/** + * Retrieves the role priority for a given role. + * @param role The role ID. + * @returns The priority of the role. + */ +export const getRoomRolePriorityForRole = (role: IRole['_id']): number => + ROOM_ROLE_PRIORITY_MAP[role as keyof typeof ROOM_ROLE_PRIORITY_MAP] ?? ROOM_ROLE_PRIORITY_MAP.default; + +/** + * Calculates the minimum role priority from a list of roles. + * @param roles The array of role IDs. + * @returns The minimum role priority. + */ +export const calculateRoomRolePriorityFromRoles = (roles: IRole['_id'][]): number => + roles.reduce((currentMin, role) => Math.min(currentMin, getRoomRolePriorityForRole(role)), ROOM_ROLE_PRIORITY_MAP.default); + +/** + * Updates the room role priority for a user in a given room. + * If roles are provided, it uses them directly; otherwise, it fetches the subscription for the user and room. + * @param userId The user's ID. + * @param rid The room's ID. + * @param roles Optional roles array. If not provided, the roles will be fetched. + */ +export const syncRoomRolePriorityForUserAndRoom = async ( + userId: IUser['_id'], + rid: IRoom['_id'], + roles?: IRole['_id'][], +): Promise => { + const updateRolePriority = async (userId: IUser['_id'], rid: IRoom['_id'], roles: IRole['_id'][]): Promise => { + const rolePriority = calculateRoomRolePriorityFromRoles(roles); + await Users.addRoomRolePriorityByUserId(userId, rid, rolePriority); + }; + + if (roles) { + return updateRolePriority(userId, rid, roles); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userId, { + projection: { roles: 1, u: 1, rid: 1 }, + }); + + if (!subscription?.roles) { + return; + } + + return updateRolePriority(subscription.u._id, subscription.rid, subscription.roles); +}; diff --git a/apps/meteor/server/methods/addRoomLeader.ts b/apps/meteor/server/methods/addRoomLeader.ts index 64240bff65f0..3d907f688780 100644 --- a/apps/meteor/server/methods/addRoomLeader.ts +++ b/apps/meteor/server/methods/addRoomLeader.ts @@ -8,6 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -58,6 +59,8 @@ Meteor.methods({ } const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'leader'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['leader']) || ['leader']); + if (addRoleResponse.modifiedCount) { void notifyOnSubscriptionChangedById(subscription._id); } diff --git a/apps/meteor/server/methods/addRoomModerator.ts b/apps/meteor/server/methods/addRoomModerator.ts index da75038a3688..2d0a635df684 100644 --- a/apps/meteor/server/methods/addRoomModerator.ts +++ b/apps/meteor/server/methods/addRoomModerator.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; import { isFederationEnabled, isFederationReady, FederationMatrixInvalidConfigurationError } from '../services/federation/utils'; declare module '@rocket.chat/ddp-client' { @@ -73,6 +74,8 @@ Meteor.methods({ } const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'moderator'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['moderator']) || ['moderator']); + if (addRoleResponse.modifiedCount) { void notifyOnSubscriptionChangedById(subscription._id); } diff --git a/apps/meteor/server/methods/addRoomOwner.ts b/apps/meteor/server/methods/addRoomOwner.ts index d0a23efea024..c982193d7446 100644 --- a/apps/meteor/server/methods/addRoomOwner.ts +++ b/apps/meteor/server/methods/addRoomOwner.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; import { isFederationReady, isFederationEnabled, FederationMatrixInvalidConfigurationError } from '../services/federation/utils'; declare module '@rocket.chat/ddp-client' { @@ -73,6 +74,8 @@ Meteor.methods({ } const addRoleResponse = await Subscriptions.addRoleById(subscription._id, 'owner'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.concat(['owner']) || ['owner']); + if (addRoleResponse.modifiedCount) { void notifyOnSubscriptionChangedById(subscription._id); } diff --git a/apps/meteor/server/methods/removeRoomLeader.ts b/apps/meteor/server/methods/removeRoomLeader.ts index 8a8f92d08fa0..dbd5a08ac980 100644 --- a/apps/meteor/server/methods/removeRoomLeader.ts +++ b/apps/meteor/server/methods/removeRoomLeader.ts @@ -8,6 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -58,6 +59,8 @@ Meteor.methods({ } const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'leader'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.filter((r) => r !== 'leader') || []); + if (removeRoleResponse.modifiedCount) { void notifyOnSubscriptionChangedById(subscription._id); } diff --git a/apps/meteor/server/methods/removeRoomModerator.ts b/apps/meteor/server/methods/removeRoomModerator.ts index bcb50076c834..c57956ff0914 100644 --- a/apps/meteor/server/methods/removeRoomModerator.ts +++ b/apps/meteor/server/methods/removeRoomModerator.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -66,6 +67,8 @@ Meteor.methods({ } const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'moderator'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.filter((r) => r !== 'moderator') || []); + if (removeRoleResponse.modifiedCount) { void notifyOnSubscriptionChangedById(subscription._id); } diff --git a/apps/meteor/server/methods/removeRoomOwner.ts b/apps/meteor/server/methods/removeRoomOwner.ts index bc4ddf7964ee..62df677c5890 100644 --- a/apps/meteor/server/methods/removeRoomOwner.ts +++ b/apps/meteor/server/methods/removeRoomOwner.ts @@ -8,6 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; +import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -72,6 +73,8 @@ Meteor.methods({ } const removeRoleResponse = await Subscriptions.removeRoleById(subscription._id, 'owner'); + await syncRoomRolePriorityForUserAndRoom(userId, rid, subscription.roles?.filter((r) => r !== 'owner') || []); + if (removeRoleResponse.modifiedCount) { void notifyOnSubscriptionChangedById(subscription._id); } diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts index 7a4d3c406db2..74015738dae8 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts @@ -17,6 +17,7 @@ import { import { settings } from '../../../../../../app/settings/server'; import { getDefaultSubscriptionPref } from '../../../../../../app/utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../../../../app/utils/server/lib/getValidRoomName'; +import { syncRoomRolePriorityForUserAndRoom } from '../../../../../lib/roles/syncRoomRolePriority'; import { DirectMessageFederatedRoom, FederatedRoom } from '../../../domain/FederatedRoom'; import type { FederatedUser } from '../../../domain/FederatedUser'; import { extractServerNameFromExternalIdentifier } from '../../matrix/converters/room/RoomReceiver'; @@ -341,6 +342,8 @@ export class RocketChatRoomAdapter { } } + await syncRoomRolePriorityForUserAndRoom(uid, rid); + if (settings.get('UI_DisplayRoles')) { this.notifyUIAboutRoomRolesChange(targetFederatedUser, federatedRoom, toAdd, toRemove); } diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 7e4846717656..de69d88e4fc4 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -3,6 +3,8 @@ import path from 'path'; import type { Credentials } from '@rocket.chat/api-client'; import type { IMessage, IRoom, ITeam, IUpload, IUser, ImageAttachmentProps, SettingValue } from '@rocket.chat/core-typings'; +import { TEAM_TYPE } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { assert, expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; @@ -12,7 +14,7 @@ import { sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; import { imgURL } from '../../data/interactions'; import { getSettingValueById, updateEEPermission, updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; -import { deleteTeam } from '../../data/teams.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; import { password } from '../../data/user'; import type { TestUser } from '../../data/users.helper'; import { createUser, deleteUser, login } from '../../data/users.helper'; @@ -3633,4 +3635,492 @@ describe('[Rooms]', () => { }); }); }); + + describe('[/rooms.membersOrderedByRole]', () => { + let testChannel: IRoom; + let ownerUser: IUser; + let moderatorUser: IUser; + let memberUser1: IUser; + let memberUser2: IUser; + + let ownerCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before(async () => { + [ownerUser, moderatorUser, memberUser1, memberUser2] = await Promise.all([ + createUser({ username: `a_${Random.id()}`, roles: ['admin'] }), + createUser({ username: `b_${Random.id()}` }), + createUser({ username: `c_${Random.id()}` }), + createUser({ username: `d_${Random.id()}` }), + ]); + + ownerCredentials = await login(ownerUser.username, password); + + // Create a public channel + const roomCreationResponse = await createRoom({ + type: 'c', + name: `rooms.membersOrderedByRole.test.${Date.now()}`, + credentials: ownerCredentials, + }); + testChannel = roomCreationResponse.body.channel; + + await Promise.all( + [moderatorUser._id, memberUser1._id, memberUser2._id].map((userId) => + request + .post(api('channels.invite')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId, + }) + .expect(200), + ), + ); + + await request + .post(api('channels.addModerator')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: moderatorUser._id, + }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await Promise.all([ownerUser, moderatorUser, memberUser1, memberUser2].map((user) => deleteUser(user))); + }); + + it('should return a list of members ordered by owner, moderator, then members by default', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.be.an('array'); + + const [first, second, ...rest] = response.body.members; + expect(first.username).to.equal(ownerUser.username); + expect(second.username).to.equal(moderatorUser.username); + + const memberUsernames = rest.map((m: any) => m.username); + expect(memberUsernames).to.include(memberUser1.username); + expect(memberUsernames).to.include(memberUser2.username); + + expect(response.body).to.have.property('total'); + expect(response.body.total).to.be.gte(4); + }); + + it('should support sorting by role in descending priority', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: '{"rolePriority":-1}', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const [first, second, third, fourth] = response.body.members; + + expect(first.username).to.equal(memberUser1.username); + expect(second.username).to.equal(memberUser2.username); + expect(third.username).to.equal(moderatorUser.username); + expect(fourth.username).to.equal(ownerUser.username); + }); + + it('should support pagination', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.have.lengthOf(2); + expect(response.body.total).to.be.gte(4); + }); + + it('should return matched members when using filter param', async () => { + const response = await request + .get(api(`rooms.membersOrderedByRole`)) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: memberUser1.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.have.lengthOf(1); + expect(response.body.members[0]).have.property('username', memberUser1.username); + }); + + it('should return empty list if no matches (e.g., filter by status that no one has)', async () => { + const response = await request + .get(api(`rooms.membersOrderedByRole`)) + .set(credentials) + .query({ + 'roomId': testChannel._id, + 'status[]': 'SomeRandomStatus', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.be.an.empty('array'); + }); + + it('should support custom sorting by username descending', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: JSON.stringify({ username: -1 }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const usernames = response.body.members.map((m: any) => m.username); + + const expected = [ + ownerUser.username, // since owner + moderatorUser.username, // since moderator + memberUser2.username, + memberUser1.username, + ]; + + expect(usernames).to.deep.equal(expected); + }); + + describe('Additional Visibility Tests', () => { + let outsiderUser: IUser; + let insideUser: IUser; + let nonTeamUser: IUser; + let outsiderCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let insideCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let nonTeamCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + let privateChannel: IRoom; + let publicChannel: IRoom; + let publicTeam: ITeam; + let privateTeam: ITeam; + let privateChannelInPublicTeam: IRoom; + let publicChannelInPublicTeam: IRoom; + let privateChannelInPrivateTeam: IRoom; + let publicChannelInPrivateTeam: IRoom; + + before(async () => { + [outsiderUser, insideUser, nonTeamUser] = await Promise.all([ + createUser({ username: `e_${Random.id()}` }), + createUser({ username: `f_${Random.id()}` }), + createUser({ username: `g_${Random.id()}` }), + ]); + [outsiderCredentials, insideCredentials, nonTeamCredentials] = await Promise.all([ + login(outsiderUser.username, password), + login(insideUser.username, password), + login(nonTeamUser.username, password), + ]); + + // Create a public team and a private team + [publicTeam, privateTeam] = await Promise.all([ + createTeam(insideCredentials, `rooms.membersOrderedByRole.team.public.${Random.id()}`, TEAM_TYPE.PUBLIC, [ + outsiderUser.username as string, + ]), + createTeam(insideCredentials, `rooms.membersOrderedByRole.team.private.${Random.id()}`, TEAM_TYPE.PRIVATE, [ + outsiderUser.username as string, + ]), + ]); + + const [ + privateInPublicResponse, + publicInPublicResponse, + privateInPrivateResponse, + publicInPrivateResponse, + privateRoomResponse, + publicRoomResponse, + ] = await Promise.all([ + createRoom({ + type: 'p', + name: `teamPublic.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPublic.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: publicTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `teamPrivate.privateChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'c', + name: `teamPrivate.publicChannel.${Date.now()}`, + credentials: insideCredentials, + extraData: { + teamId: privateTeam._id, + }, + }), + createRoom({ + type: 'p', + name: `rooms.membersOrderedByRole.private.${Date.now()}`, + credentials: insideCredentials, + }), + createRoom({ + type: 'c', + name: `rooms.membersOrderedByRole.public.${Date.now()}`, + credentials: insideCredentials, + }), + ]); + + privateChannelInPublicTeam = privateInPublicResponse.body.group; + publicChannelInPublicTeam = publicInPublicResponse.body.channel; + privateChannelInPrivateTeam = privateInPrivateResponse.body.group; + publicChannelInPrivateTeam = publicInPrivateResponse.body.channel; + privateChannel = privateRoomResponse.body.group; + publicChannel = publicRoomResponse.body.channel; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateChannel._id }), + deleteRoom({ type: 'c', roomId: publicChannel._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPublicTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPublicTeam._id }), + deleteRoom({ type: 'p', roomId: privateChannelInPrivateTeam._id }), + deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), + ]); + + await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + + await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); + }); + + it('should not fetch private room members by user not part of room', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch private room members by user who is part of the room', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: privateChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch public room members by user who is part of the room', async () => { + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch public room members by user not part of room - because public', async () => { + await updatePermission('view-c-room', ['admin', 'user', 'guest']); + const response = await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: publicChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body.success).to.be.true; + expect(response.body.members).to.be.an('array'); + }); + + it('should fetch a private channel members inside a public team by someone part of the room ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a private channel members inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a public team by someone not part of the team ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a public channel members inside a public team by someone part of the room ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a public team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a public team by someone not part of the team ', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPublicTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a private team by someone part of the room', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should fetch a public channel members inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a public channel members inside a private team by someone not part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: publicChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should fetch a private channel members inside a private team by someone part of the room', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(insideCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + }); + }); + + it('should not fetch a private channel members inside a private team by someone not part of the room, but part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(outsiderCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should not fetch a private channel members inside a private team by someone not part of team', async () => { + await request + .get(api('rooms.membersOrderedByRole')) + .set(nonTeamCredentials) + .query({ roomId: privateChannelInPrivateTeam._id }) + .expect('Content-Type', 'application/json') + .expect(404) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + }); }); diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 4203184d4c1c..4c1b3ca39258 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -93,6 +93,8 @@ export interface IRoom extends IRocketChatRecord { sidepanel?: { items: [SidepanelItem, SidepanelItem?]; }; + + rolePrioritiesCreated?: boolean; } export const isSidepanelItem = (item: any): item is SidepanelItem => { @@ -417,3 +419,9 @@ export interface IRoomWithRetentionPolicy extends IRoom { overrideGlobal?: boolean; }; } + +export const ROOM_ROLE_PRIORITY_MAP = { + owner: 0, + moderator: 500, + default: 10000, +}; diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 63f23c2ed5bb..0a2905b2ed9b 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -216,6 +216,7 @@ export interface IUser extends IRocketChatRecord { _pendingAvatarUrl?: string; requirePasswordChange?: boolean; requirePasswordChangeReason?: string; + roomRolePriorities?: Record; isOAuthUser?: boolean; // client only field } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 7db171804a41..58977dbc471f 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -313,4 +313,6 @@ export interface IRoomsModel extends IBaseModel { countByCreatedOTR(options?: CountDocumentsOptions): Promise; countByBroadcast(options?: CountDocumentsOptions): Promise; countByE2E(options?: CountDocumentsOptions): Promise; + markRolePrioritesCreatedForRoom(rid: IRoom['_id']): Promise; + hasCreatedRolePrioritiesForRoom(rid: IRoom['_id']): Promise; } diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index b7b0b618b2c8..af7d498bb61f 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -251,6 +251,10 @@ export interface IUsersModel extends IBaseModel { addRoomByUserId(userId: string, rid: string): Promise; addRoomByUserIds(uids: string[], rid: string): Promise; removeRoomByRoomIds(rids: string[]): Promise; + addRoomRolePriorityByUserId(userId: string, rid: string, rolePriority: number): Promise; + removeRoomRolePriorityByUserId(userId: string, rid: string): Promise; + assignRoomRolePrioritiesByUserIdPriorityMap(rolePrioritiesMap: Record, rid: string): Promise; + unassignRoomRolePrioritiesByRoomId(rid: string): Promise; getLoginTokensByUserId(userId: string): FindCursor; addPersonalAccessTokenToUser(data: { userId: string; loginTokenObject: IPersonalAccessToken }): Promise; removePersonalAccessTokenOfUser(data: { diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 5393187f9f46..0aebeb19912c 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -2206,4 +2206,12 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { { returnDocument: 'after' }, ); } + + markRolePrioritesCreatedForRoom(rid: IRoom['_id']) { + return this.updateOne({ _id: rid }, { $set: { rolePrioritiesCreated: true } }); + } + + async hasCreatedRolePrioritiesForRoom(rid: IRoom['_id']) { + return this.countDocuments({ _id: rid, rolePrioritiesCreated: true }); + } } diff --git a/packages/models/src/models/Users.js b/packages/models/src/models/Users.js index 891b927bb9cd..03e4e333afb2 100644 --- a/packages/models/src/models/Users.js +++ b/packages/models/src/models/Users.js @@ -1187,6 +1187,10 @@ export class UsersRaw extends BaseRaw { }, { $pullAll: { __rooms: rids }, + $unset: rids.reduce((acc, rid) => { + acc[`roomRolePriorities.${rid}`] = ''; + return acc; + }, {}), }, ); } @@ -1363,6 +1367,7 @@ export class UsersRaw extends BaseRaw { }, { $pull: { __rooms: rid }, + $unset: { [`roomRolePriorities.${rid}`]: '' }, }, options, ); @@ -1518,6 +1523,7 @@ export class UsersRaw extends BaseRaw { }, { $set: { __rooms: [] }, + $unset: { roomRolePriorities: '' }, }, ); } @@ -1530,6 +1536,7 @@ export class UsersRaw extends BaseRaw { }, { $pull: { __rooms: rid }, + $unset: { [`roomRolePriorities.${rid}`]: '' }, }, ); } @@ -1565,6 +1572,64 @@ export class UsersRaw extends BaseRaw { }, { $pullAll: { __rooms: rids }, + $unset: rids.reduce((acc, rid) => { + acc[`roomRolePriorities.${rid}`] = ''; + return acc; + }, {}), + }, + ); + } + + addRoomRolePriorityByUserId(userId, rid, priority) { + return this.updateOne( + { + _id: userId, + }, + { + $set: { + [`roomRolePriorities.${rid}`]: priority, + }, + }, + ); + } + + removeRoomRolePriorityByUserId(userId, rid) { + return this.updateOne( + { + _id: userId, + }, + { + $unset: { + [`roomRolePriorities.${rid}`]: '', + }, + }, + ); + } + + async assignRoomRolePrioritiesByUserIdPriorityMap(userIdAndrolePriorityMap, rid) { + const bulk = this.col.initializeUnorderedBulkOp(); + + for (const [userId, priority] of Object.entries(userIdAndrolePriorityMap)) { + bulk.find({ _id: userId }).updateOne({ $set: { [`roomRolePriorities.${rid}`]: priority } }); + } + + if (bulk.length > 0) { + const result = await bulk.execute(); + return result.modifiedCount; + } + + return 0; + } + + unassignRoomRolePrioritiesByRoomId(rid) { + return this.updateMany( + { + __rooms: rid, + }, + { + $unset: { + [`roomRolePriorities.${rid}`]: '', + }, }, ); } diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 5cd4a1367c89..902f20aec394 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload, IE2EEMessage, ITeam } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload, IE2EEMessage, ITeam, IRole } from '@rocket.chat/core-typings'; import { ajv } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -628,6 +628,48 @@ const roomsOpenSchema = { export const isRoomsOpenProps = ajv.compile(roomsOpenSchema); +type MembersOrderedByRoleProps = { + roomId?: IRoom['_id']; + roomName?: IRoom['name']; + status?: string[]; + filter?: string; +}; + +export type RoomsMembersOrderedByRoleProps = PaginatedRequest; + +const membersOrderedByRoleRolePropsSchema = { + properties: { + roomId: { + type: 'string', + }, + roomName: { + type: 'string', + }, + status: { + type: 'array', + items: { + type: 'string', + }, + }, + filter: { + type: 'string', + }, + count: { + type: 'integer', + }, + offset: { + type: 'integer', + }, + sort: { + type: 'string', + }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}; + +export const isRoomsMembersOrderedByRoleProps = ajv.compile(membersOrderedByRoleRolePropsSchema); + export type RoomsEndpoints = { '/v1/rooms.autocomplete.channelAndPrivate': { GET: (params: RoomsAutoCompleteChannelAndPrivateProps) => { @@ -798,4 +840,10 @@ export type RoomsEndpoints = { '/v1/rooms.open': { POST: (params: RoomsOpenProps) => void; }; + + '/v1/rooms.membersOrderedByRole': { + GET: (params: RoomsMembersOrderedByRoleProps) => PaginatedResult<{ + members: (IUser & { roles?: IRole['_id'][] })[]; + }>; + }; };