diff --git a/.changeset/thick-swans-drop.md b/.changeset/thick-swans-drop.md new file mode 100644 index 000000000000..e4e670e2b64d --- /dev/null +++ b/.changeset/thick-swans-drop.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +Added `membersByHighestRole` endpoints, which enables users to retrieve room members sorted by their room-scoped roles diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 1c84926edb63..dd85f79a5560 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -1,5 +1,5 @@ import { Team, Room } from '@rocket.chat/core-services'; -import type { IRoom, ISubscription, IUser, RoomType, IUpload } from '@rocket.chat/core-typings'; +import type { IRoom, ISubscription, IUser, RoomType, IUpload, UserStatus } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; import { isChannelsAddAllProps, @@ -24,6 +24,7 @@ import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; import { findUsersOfRoom } from '../../../../server/lib/findUsersOfRoom'; +import { findUsersOfRoomByHighestRole } from '../../../../server/lib/findUsersOfRoomByHighestRole'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { removeUserFromRoomMethod } from '../../../../server/methods/removeUserFromRoom'; import { canAccessRoomAsync } from '../../../authorization/server'; @@ -1094,6 +1095,41 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'channels.membersByHighestRole', + { authRequired: true }, + { + async get() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { + return API.v1.unauthorized(); + } + + const { count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + const { status, filter } = this.queryParams; + + const { members, total } = await findUsersOfRoomByHighestRole({ + rid: findResult._id, + ...(status && { status: { $in: status as UserStatus[] } }), + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + }); + + return API.v1.success({ + members, + count: members.length, + total, + }); + }, + }, +); + API.v1.addRoute( 'channels.online', { authRequired: true }, diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index 34deb57304fc..5784f8d459bb 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -1,11 +1,12 @@ import { Team, isMeteorError } from '@rocket.chat/core-services'; -import type { IIntegration, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; +import type { IIntegration, IUser, IRoom, RoomType, UserStatus } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { Filter } from 'mongodb'; import { findUsersOfRoom } from '../../../../server/lib/findUsersOfRoom'; +import { findUsersOfRoomByHighestRole } from '../../../../server/lib/findUsersOfRoomByHighestRole'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { removeUserFromRoomMethod } from '../../../../server/methods/removeUserFromRoom'; import { canAccessRoomAsync, roomAccessAttributes } from '../../../authorization/server'; @@ -747,6 +748,41 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'groups.membersByHighestRole', + { authRequired: true }, + { + async get() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + }); + + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult.rid))) { + return API.v1.unauthorized(); + } + + const { count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + const { status, filter } = this.queryParams; + + const { members, total } = await findUsersOfRoomByHighestRole({ + rid: findResult.rid, + ...(status && { status: { $in: status as UserStatus[] } }), + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + }); + + return API.v1.success({ + members, + count: members.length, + total, + }); + }, + }, +); + API.v1.addRoute( 'groups.messages', { authRequired: true }, diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index a640318a9cd0..a14798e5227f 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -339,7 +339,7 @@ API.v1.addRoute( const searchFields = settings.get('Accounts_SearchFields').trim().split(','); - const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter, [], options, searchFields, [extraQuery]); + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter, [], searchFields, options, [extraQuery]); const [members, total] = await Promise.all([cursor.toArray(), totalCount]); diff --git a/apps/meteor/server/lib/findUsersOfRoom.ts b/apps/meteor/server/lib/findUsersOfRoom.ts index 6441d5265d11..de62f7a845e2 100644 --- a/apps/meteor/server/lib/findUsersOfRoom.ts +++ b/apps/meteor/server/lib/findUsersOfRoom.ts @@ -35,7 +35,7 @@ export function findUsersOfRoom({ rid, status, skip = 0, limit = 0, filter = '', const searchFields = settings.get('Accounts_SearchFields').trim().split(','); - return Users.findPaginatedByActiveUsersExcept(filter, undefined, options, searchFields, [ + return Users.findPaginatedByActiveUsersExcept(filter, undefined, searchFields, options, [ { __rooms: rid, ...(status && { status }), diff --git a/apps/meteor/server/lib/findUsersOfRoomByHighestRole.ts b/apps/meteor/server/lib/findUsersOfRoomByHighestRole.ts new file mode 100644 index 000000000000..93c18d33632b --- /dev/null +++ b/apps/meteor/server/lib/findUsersOfRoomByHighestRole.ts @@ -0,0 +1,81 @@ +import type { IUserWithRoleInfo, IUser, IRoom, UserStatus } from '@rocket.chat/core-typings'; +import { Users, Subscriptions } from '@rocket.chat/models'; +import type { Filter, FilterOperators, FindOptions } from 'mongodb'; + +import { settings } from '../../app/settings/server'; + +type FindUsersParam = { + rid: string; + status?: FilterOperators; + skip?: number; + limit?: number; + filter?: string; + sort?: Record; +}; + +export async function findUsersOfRoomByHighestRole({ + rid, + status, + limit = 0, + filter = '', + sort, +}: FindUsersParam): Promise<{ members: IUserWithRoleInfo[]; total: number }> { + const options: FindOptions = { + projection: { + name: 1, + username: 1, + nickname: 1, + status: 1, + avatarETag: 1, + _updatedAt: 1, + federated: 1, + statusConnection: 1, + }, + sort: { + statusConnection: -1 as const, + ...(sort || { ...(settings.get('UI_Use_Real_Name') && { name: 1 }), username: 1 }), + }, + limit, + }; + const extraQuery: Filter = { + __rooms: rid, + ...(status && { status }), + }; + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + + // Find highest roles members (owners and moderators) + const result = await Subscriptions.findPaginatedActiveHighestRoleUsers(filter, rid, searchFields, options, extraQuery); + const { + members: highestRolesMembers = [], + totalCount: totalMembersWithRoles = { total: 0 }, + ids = { allMembersIds: [] }, + } = result[0] || {}; + const { total: totalMembersWithRolesCount } = totalMembersWithRoles; + const { allMembersIds: highestRolesMembersIds } = ids; + + if (limit <= highestRolesMembers.length) { + const totalMembersCount = await Users.countActiveUsersExcept(filter, highestRolesMembersIds, searchFields, [extraQuery]); + return { members: highestRolesMembers, total: totalMembersWithRolesCount + totalMembersCount }; + } + if (options.limit) { + options.limit -= highestRolesMembers.length; + } + + // Find average members + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter, highestRolesMembersIds, searchFields, options, [ + extraQuery, + ]); + const [members, totalMembersCount] = await Promise.all([await cursor.toArray(), totalCount]); + const membersWithHighestRoles = members.map( + (member): IUserWithRoleInfo => ({ + ...member, + highestRole: { + role: 'member', + level: 2, + }, + }), + ); + + const allMembers = highestRolesMembers.concat(membersWithHighestRoles); + return { members: allMembers, total: totalMembersWithRolesCount + totalMembersCount }; +} diff --git a/apps/meteor/server/methods/browseChannels.ts b/apps/meteor/server/methods/browseChannels.ts index 5516b83d184a..d76505da97b1 100644 --- a/apps/meteor/server/methods/browseChannels.ts +++ b/apps/meteor/server/methods/browseChannels.ts @@ -225,7 +225,7 @@ const findUsers = async ({ }; if (workspace === 'all') { - const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(text, [], options, searchFields); + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(text, [], searchFields, options); const [results, total] = await Promise.all([cursor.toArray(), totalCount]); return { total, diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index 6b3c184a0e24..96d82bbb2470 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -4,6 +4,7 @@ import type { IRoom, ISubscription, IUser, + IUserWithRoleInfo, RocketChatRecordDeleted, RoomType, SpotlightUser, @@ -460,6 +461,96 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri .toArray(); } + async findPaginatedActiveHighestRoleUsers( + searchTerm: string, + rid: IRoom['_id'], + searchFields: string[], + options: FindOptions = {}, + extraQuery?: Filter, + { startsWith = false, endsWith = false }: { startsWith?: string | false; endsWith?: string | false } = {}, + ): Promise<{ members: IUserWithRoleInfo[]; totalCount: { total: number }; ids: { allMembersIds: string[] } }[]> { + const termRegex = new RegExp((startsWith ? '^' : '') + escapeRegExp(searchTerm) + (endsWith ? '$' : ''), 'i'); + const orStatement = (searchFields || []).map((el) => ({ [el.trim()]: termRegex })) as { [x: string]: RegExp }[]; + + const limit = + options.limit !== 0 + ? [ + { + $limit: options.limit, + }, + ] + : []; + + return this.col + .aggregate<{ members: IUserWithRoleInfo[]; totalCount: { total: number }; ids: { allMembersIds: string[] } }>( + [ + { + $match: { + rid, + roles: { $in: ['owner', 'moderator'] }, + }, + }, + { + $lookup: { + from: 'users', + as: 'user', + let: { id: '$u._id' }, + pipeline: [ + { + $match: { + $expr: { $eq: ['$_id', '$$id'] }, + username: { $exists: true }, + active: true, + ...(searchTerm && orStatement.length > 0 && { $or: orStatement }), + ...extraQuery, + }, + }, + { $project: options.projection }, + ], + }, + }, + { + $unwind: { + path: '$user', + }, + }, + { + $addFields: { + 'user.highestRole': { + $cond: [{ $in: ['owner', '$roles'] }, { role: 'owner', level: 0 }, { role: 'moderator', level: 1 }], + }, + }, + }, + { + $replaceRoot: { newRoot: '$user' }, + }, + { + $facet: { + members: [ + { + $sort: { + 'highestRole.level': 1, + ...(options.sort as object), + }, + }, + ...limit, + ], + ids: [{ $group: { _id: null, allMembersIds: { $push: '$_id' } } }], + totalCount: [{ $count: 'total' }], + }, + }, + { + $unwind: { path: '$totalCount' }, + }, + { + $unwind: { path: '$ids' }, + }, + ], + { allowDiskUse: true }, + ) + .toArray(); + } + incUnreadForRoomIdExcludingUserIds(roomId: IRoom['_id'], userIds: IUser['_id'][], inc: number): Promise { if (inc == null) { inc = 1; diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index 0d56fc76bccf..e63468907d20 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -266,10 +266,7 @@ export class UsersRaw extends BaseRaw { const termRegex = new RegExp((startsWith ? '^' : '') + escapeRegExp(searchTerm) + (endsWith ? '$' : ''), 'i'); - const orStmt = (searchFields || []).reduce((acc, el) => { - acc.push({ [el.trim()]: termRegex }); - return acc; - }, []); + const orStmt = (searchFields || []).map((el) => ({ [el.trim()]: termRegex })); const query = { $and: [ @@ -292,38 +289,89 @@ export class UsersRaw extends BaseRaw { findPaginatedByActiveUsersExcept( searchTerm, exceptions, - options, searchFields, + options = {}, extraQuery = [], { startsWith = false, endsWith = false } = {}, ) { if (exceptions == null) { exceptions = []; } - if (options == null) { - options = {}; + if (!Array.isArray(exceptions)) { + exceptions = [exceptions]; + } + + const regexString = (startsWith ? '^' : '') + escapeRegExp(searchTerm) + (endsWith ? '$' : ''); + const termRegex = new RegExp(regexString, 'i'); + + const orStmt = (searchFields || []).map((el) => ({ [el.trim()]: termRegex })); + + const query = { + $and: [ + { + active: true, + ...(exceptions.length && { _id: { $nin: exceptions } }), + username: { $exists: true }, + // if the search term is empty, don't need to have the $or statement (because it would be an empty regex) + ...(searchTerm && orStmt.length && { $or: orStmt }), + }, + ...extraQuery, + ], + }; + + return this.findPaginated(query, options); + } + + countActiveUsersExcept(searchTerm, exceptions, searchFields, extraQuery = [], { startsWith = false, endsWith = false } = {}) { + if (exceptions == null) { + exceptions = []; } if (!Array.isArray(exceptions)) { exceptions = [exceptions]; } - const termRegex = new RegExp((startsWith ? '^' : '') + escapeRegExp(searchTerm) + (endsWith ? '$' : ''), 'i'); + const regexString = (startsWith ? '^' : '') + escapeRegExp(searchTerm) + (endsWith ? '$' : ''); + const termRegex = new RegExp(regexString, 'i'); - const orStmt = (searchFields || []).reduce((acc, el) => { - acc.push({ [el.trim()]: termRegex }); - return acc; - }, []); + const orStmt = (searchFields || []).map((el) => ({ [el.trim()]: termRegex })); const query = { $and: [ { active: true, - username: { - $exists: true, - ...(exceptions.length > 0 && { $nin: exceptions }), - }, + ...(exceptions.length && { _id: { $nin: exceptions } }), + username: { $exists: true }, // if the search term is empty, don't need to have the $or statement (because it would be an empty regex) - ...(searchTerm && orStmt.length > 0 && { $or: orStmt }), + ...(searchTerm && orStmt.length && { $or: orStmt }), + }, + ...extraQuery, + ], + }; + + return this.col.countDocuments(query); + } + + findPaginatedActiveUsersByIds( + searchTerm, + searchFields, + ids = [], + options = {}, + extraQuery = [], + { startsWith = false, endsWith = false } = {}, + ) { + const regexString = (startsWith ? '^' : '') + escapeRegExp(searchTerm) + (endsWith ? '$' : ''); + const termRegex = new RegExp(regexString, 'i'); + + const orStmt = (searchFields || []).map((el) => ({ [el.trim()]: termRegex })); + + const query = { + $and: [ + { + active: true, + ...(ids.length && { _id: { $in: ids } }), + username: { $exists: true }, + // if the search term is empty, don't need to have the $or statement (because it would be an empty regex) + ...(searchTerm && orStmt.length && { $or: orStmt }), }, ...extraQuery, ], @@ -338,12 +386,12 @@ export class UsersRaw extends BaseRaw { $or: [{ federation: { $exists: false } }, { 'federation.origin': localDomain }], }, ]; - return this.findPaginatedByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + return this.findPaginatedByActiveUsersExcept(searchTerm, exceptions, forcedSearchFields, options, extraQuery); } findPaginatedByActiveExternalUsersExcept(searchTerm, exceptions, options, forcedSearchFields, localDomain) { const extraQuery = [{ federation: { $exists: true } }, { 'federation.origin': { $ne: localDomain } }]; - return this.findPaginatedByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + return this.findPaginatedByActiveUsersExcept(searchTerm, exceptions, forcedSearchFields, options, extraQuery); } findActive(query, options = {}) { diff --git a/apps/meteor/tests/end-to-end/api/02-channels.js b/apps/meteor/tests/end-to-end/api/02-channels.js index 5291b3621b43..1c018abd72b7 100644 --- a/apps/meteor/tests/end-to-end/api/02-channels.js +++ b/apps/meteor/tests/end-to-end/api/02-channels.js @@ -1347,6 +1347,212 @@ describe('[Channels]', function () { }); }); + describe('/channels.membersByHighestRole', () => { + let testChannel; + let testUser; + before('create a channel', async () => { + testUser = await createUser(); + const result = await createRoom({ + type: 'c', + name: `channel-test-highest-role-${Date.now()}`, + members: [testUser.username, 'rocket.cat'], + }); + testChannel = result.body.channel; + }); + before('assign roles to the users added to the channel', async () => { + await request.post(api('channels.addLeader')).set(credentials).send({ + roomId: testChannel._id, + userId: testUser._id, + }); + await request.post(api('channels.addModerator')).set(credentials).send({ + roomId: testChannel._id, + userId: 'rocket.cat', + }); + }); + after(async () => { + await deleteUser(testUser); + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + + it('should return an array of members by channel when roomId is provided', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }) + .end(done); + }); + + it('should return an array of members by channel when roomName is provided', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomName: testChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }) + .end(done); + }); + + it('should return an array of members by channel even when requested with count param', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 5, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count', 3); + expect(res.body).to.have.property('total'); + }) + .end(done); + }); + + it('should return a filtered array of members by channel', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total'); + + const member = res.body.members[0]; + expect(member).to.have.property('_id'); + expect(member).to.have.property('username'); + expect(member).to.have.property('name'); + expect(member).to.have.property('status'); + expect(member).to.have.property('highestRole'); + }) + .end(done); + }); + + it('should return the correct highest role when searching for a moderator user', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + + const member = res.body.members[0]; + expect(member).to.have.property('highestRole'); + + const { highestRole } = member; + expect(highestRole).to.have.property('role', 'moderator'); + expect(highestRole).to.have.property('level', 1); + }) + .end(done); + }); + + it('should return the correct highest role when searching for an owner user', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: adminUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + + const member = res.body.members[0]; + expect(member).to.have.property('highestRole'); + + const { highestRole } = member; + expect(highestRole).to.have.property('role', 'owner'); + expect(highestRole).to.have.property('level', 0); + }) + .end(done); + }); + + it('should return the correct highest role when searching for a leader', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + + const member = res.body.members[0]; + expect(member).to.have.property('highestRole'); + + const { highestRole } = member; + expect(highestRole).to.have.property('role', 'member'); + expect(highestRole).to.have.property('level', 2); + }) + .end(done); + }); + + it('should return members correctly sorted by highest role', (done) => { + request + .get(api('channels.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body.members).to.have.length(3); + + const highestRoles = ['owner', 'moderator', 'member']; + for (let i = 0; i < 3; i++) { + const member = res.body.members[i]; + expect(member).to.have.property('highestRole'); + expect(member.highestRole).to.have.property('role', highestRoles[i]); + expect(member.highestRole).to.have.property('level', i); + } + }) + .end(done); + }); + }); + describe('/channels.getIntegrations', () => { let integrationCreatedByAnUser; let userCredentials; diff --git a/apps/meteor/tests/end-to-end/api/03-groups.js b/apps/meteor/tests/end-to-end/api/03-groups.js index 07b03494900f..ad705ea0c010 100644 --- a/apps/meteor/tests/end-to-end/api/03-groups.js +++ b/apps/meteor/tests/end-to-end/api/03-groups.js @@ -5,7 +5,7 @@ import { getCredentials, api, request, credentials, group, apiPrivateChannelName import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; import { createIntegration, removeIntegration } from '../../data/integration.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { testFileUploads } from '../../data/uploads.helper'; import { adminUsername, password } from '../../data/user'; import { createUser, login, deleteUser } from '../../data/users.helper'; @@ -1065,6 +1065,204 @@ describe('[Groups]', function () { }); }); + describe('/groups.membersByHighestRole', () => { + let testGroup; + let testUser; + before('create a group', async () => { + testUser = await createUser(); + const result = await createRoom({ + type: 'p', + name: `group-test-highest-role-${Date.now()}`, + members: [testUser.username, 'rocket.cat'], + }); + testGroup = result.body.group; + }); + before('assign roles to the users added to the group', async () => { + await request.post(api('groups.addLeader')).set(credentials).send({ + roomId: testGroup._id, + userId: testUser._id, + }); + await request.post(api('groups.addModerator')).set(credentials).send({ + roomId: testGroup._id, + userId: 'rocket.cat', + }); + }); + after(async () => { + await deleteUser(testUser); + await deleteRoom({ type: 'p', roomId: testGroup._id }); + }); + + it('should return an array of members by channel when roomId is provided', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return an array of members by channel when roomName is provided', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return an array of members by channel even when requested with count param', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + count: 5, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count', 3); + expect(res.body).to.have.property('total'); + }); + }); + + it('should return a filtered array of members by group', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + filter: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body).to.have.property('count', 1); + expect(res.body).to.have.property('total'); + + const member = res.body.members[0]; + expect(member).to.have.property('_id'); + expect(member).to.have.property('username'); + expect(member).to.have.property('name'); + expect(member).to.have.property('status'); + expect(member).to.have.property('highestRole'); + }); + }); + + it('should return the correct highest role when searching for a moderator user', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + filter: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + + const member = res.body.members[0]; + expect(member).to.have.property('highestRole'); + + const { highestRole } = member; + expect(highestRole).to.have.property('role', 'moderator'); + expect(highestRole).to.have.property('level', 1); + }); + }); + + it('should return the correct highest role when searching for an owner user', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + filter: adminUsername, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + + const member = res.body.members[0]; + expect(member).to.have.property('highestRole'); + + const { highestRole } = member; + expect(highestRole).to.have.property('role', 'owner'); + expect(highestRole).to.have.property('level', 0); + }); + }); + + it('should return the correct highest role when searching for a leader', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + filter: testUser.username, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + + const member = res.body.members[0]; + expect(member).to.have.property('highestRole'); + + const { highestRole } = member; + expect(highestRole).to.have.property('role', 'member'); + expect(highestRole).to.have.property('level', 2); + }); + }); + + it('should return members correctly sorted by highest role', async () => { + await request + .get(api('groups.membersByHighestRole')) + .set(credentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body.members).to.have.length(3); + + const highestRoles = ['owner', 'moderator', 'member']; + for (let i = 0; i < 3; i++) { + const member = res.body.members[i]; + expect(member).to.have.property('highestRole'); + expect(member.highestRole).to.have.property('role', highestRoles[i]); + expect(member.highestRole).to.have.property('level', i); + } + }); + }); + }); + describe('/groups.files', async () => { await testFileUploads('groups.files', 'p'); }); diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 29864ae81ed1..91c4c04016a4 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -193,6 +193,13 @@ export interface IRegisterUser extends IUser { export const isRegisterUser = (user: IUser): user is IRegisterUser => user.username !== undefined && user.name !== undefined; export const isUserFederated = (user: Partial) => 'federated' in user && user.federated === true; +export interface IUserWithRoleInfo extends IUser { + highestRole: { + role: string; + level: number; + }; +} + export type IUserDataEvent = { id: unknown; } & ( diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index bb7d4718b7ff..078cd6348d42 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -1,4 +1,4 @@ -import type { ISubscription, IRole, IUser, IRoom, RoomType, SpotlightUser, AtLeast } from '@rocket.chat/core-typings'; +import type { ISubscription, IRole, IUser, IRoom, IUserWithRoleInfo, RoomType, SpotlightUser, AtLeast } from '@rocket.chat/core-typings'; import type { FindOptions, FindCursor, @@ -86,6 +86,15 @@ export interface ISubscriptionsModel extends IBaseModel { options?: AggregateOptions, ): Promise; + findPaginatedActiveHighestRoleUsers( + searchTerm: string, + rid: IRoom['_id'], + searchFields: string[], + options?: FindOptions, + extraQuery?: Filter, + { startsWith, endsWith }?: { startsWith?: string | false; endsWith?: string | false }, + ): Promise<{ members: IUserWithRoleInfo[]; totalCount: { total: number }; ids: { allMembersIds: string[] } }[]>; + incUnreadForRoomIdExcludingUserIds(roomId: IRoom['_id'], userIds: IUser['_id'][], inc: number): Promise; setAlertForRoomIdExcludingUserId(roomId: IRoom['_id'], userId: IUser['_id']): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index f9a2b1c45a2a..ca5ebe06b9dd 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -9,7 +9,17 @@ import type { AtLeast, ILivechatAgentStatus, } from '@rocket.chat/core-typings'; -import type { Document, UpdateResult, FindCursor, FindOptions, Filter, InsertOneResult, DeleteResult, ModifyResult } from 'mongodb'; +import type { + Document, + UpdateResult, + FindCursor, + FindOptions, + Filter, + FilterOperators, + InsertOneResult, + DeleteResult, + ModifyResult, +} from 'mongodb'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -39,11 +49,26 @@ export interface IUsersModel extends IBaseModel { findPaginatedByActiveUsersExcept( searchTerm: any, exceptions: any, - options: any, searchFields: any, + options?: any, extraQuery?: any, params?: { startsWith?: boolean; endsWith?: boolean }, ): FindPaginated>; + countActiveUsersExcept( + searchTerm: string, + exceptions: IUser['_id'][], + searchFields: string[], + extraQuery?: FilterOperators[], + params?: { startsWith?: boolean; endsWith?: boolean }, + ): Promise; + findPaginatedActiveUsersByIds( + searchTerm: string, + searchFields: string[], + ids: IUser['_id'][], + options?: FindOptions, + extraQuery?: FilterOperators[], + params?: { startsWith?: boolean; endsWith?: boolean }, + ): FindPaginated>; findPaginatedByActiveLocalUsersExcept( searchTerm: any, diff --git a/packages/rest-typings/src/v1/channels/ChannelsMembersByHighestRoleProps.ts b/packages/rest-typings/src/v1/channels/ChannelsMembersByHighestRoleProps.ts new file mode 100644 index 000000000000..a8a5fba6afd5 --- /dev/null +++ b/packages/rest-typings/src/v1/channels/ChannelsMembersByHighestRoleProps.ts @@ -0,0 +1,51 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajv } from '../Ajv'; + +type MembersByHighestRoleProps = { + roomId?: IRoom['_id']; + roomName?: IRoom['name']; + status?: string[]; + filter?: string; +}; + +export type ChannelsMembersByHighestRoleProps = PaginatedRequest; + +const membersByHighestRolePropsSchema = { + properties: { + roomId: { + type: 'string', + }, + roomName: { + type: 'string', + }, + status: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + filter: { + type: 'string', + nullable: true, + }, + count: { + type: 'integer', + nullable: true, + }, + offset: { + type: 'integer', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}; + +export const isChannelsMembersByHighestRoleProps = ajv.compile(membersByHighestRolePropsSchema); diff --git a/packages/rest-typings/src/v1/channels/channels.ts b/packages/rest-typings/src/v1/channels/channels.ts index 328588ffb58b..980883a65781 100644 --- a/packages/rest-typings/src/v1/channels/channels.ts +++ b/packages/rest-typings/src/v1/channels/channels.ts @@ -16,6 +16,7 @@ import type { ChannelsJoinProps } from './ChannelsJoinProps'; import type { ChannelsKickProps } from './ChannelsKickProps'; import type { ChannelsLeaveProps } from './ChannelsLeaveProps'; import type { ChannelsListProps } from './ChannelsListProps'; +import type { ChannelsMembersByHighestRoleProps } from './ChannelsMembersByHighestRoleProps'; import type { ChannelsMessagesProps } from './ChannelsMessagesProps'; import type { ChannelsModeratorsProps } from './ChannelsModeratorsProps'; import type { ChannelsOnlineProps } from './ChannelsOnlineProps'; @@ -53,6 +54,11 @@ export type ChannelsEndpoints = { members: IUser[]; }>; }; + '/v1/channels.membersByHighestRole': { + GET: (params: ChannelsMembersByHighestRoleProps) => PaginatedResult<{ + members: IUser[]; + }>; + }; '/v1/channels.history': { GET: (params: ChannelsHistoryProps) => PaginatedResult<{ messages: IMessage[]; diff --git a/packages/rest-typings/src/v1/groups/GroupsMembersByHighestRoleProps.ts b/packages/rest-typings/src/v1/groups/GroupsMembersByHighestRoleProps.ts new file mode 100644 index 000000000000..b20d37dfc3c0 --- /dev/null +++ b/packages/rest-typings/src/v1/groups/GroupsMembersByHighestRoleProps.ts @@ -0,0 +1,51 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajv } from '../Ajv'; + +type MembersByHighestRoleProps = { + roomId?: IRoom['_id']; + roomName?: IRoom['name']; + status?: string[]; + filter?: string; +}; + +export type GroupsMembersByHighestRoleProps = PaginatedRequest; + +const membersByHighestRolePropsSchema = { + properties: { + roomId: { + type: 'string', + }, + roomName: { + type: 'string', + }, + status: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + filter: { + type: 'string', + nullable: true, + }, + count: { + type: 'integer', + nullable: true, + }, + offset: { + type: 'integer', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}; + +export const isGroupsMembersByHighestRoleProps = ajv.compile(membersByHighestRolePropsSchema); diff --git a/packages/rest-typings/src/v1/groups/groups.ts b/packages/rest-typings/src/v1/groups/groups.ts index 529e086a81af..67093bf7c12a 100644 --- a/packages/rest-typings/src/v1/groups/groups.ts +++ b/packages/rest-typings/src/v1/groups/groups.ts @@ -1,4 +1,14 @@ -import type { IMessage, IRoom, ITeam, IGetRoomRoles, IUser, IUploadWithUser, IIntegration, ISubscription } from '@rocket.chat/core-typings'; +import type { + IMessage, + IRoom, + ITeam, + IGetRoomRoles, + IUser, + IUploadWithUser, + IIntegration, + ISubscription, + IUserWithRoleInfo, +} from '@rocket.chat/core-typings'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; import type { GroupsAddAllProps } from './GroupsAddAllProps'; @@ -53,6 +63,14 @@ export type GroupsEndpoints = { total: number; }; }; + '/v1/groups.membersByHighestRole': { + GET: (params: GroupsMembersProps) => { + count: number; + offset: number; + members: IUserWithRoleInfo[]; + total: number; + }; + }; '/v1/groups.history': { GET: (params: GroupsHistoryProps) => PaginatedResult<{ messages: IMessage[];