From 7ac83e0422cf80763add15862aa7d7a663ee84e2 Mon Sep 17 00:00:00 2001 From: juliajforesti Date: Thu, 29 Aug 2024 17:57:46 -0300 Subject: [PATCH] feat: New endpoint for listing rooms & discussions from teams #33177 --- apps/meteor/app/api/server/v1/teams.ts | 24 ++ apps/meteor/server/models/raw/Rooms.ts | 20 ++ apps/meteor/server/services/team/service.ts | 46 ++++ apps/meteor/tests/data/teams.helper.ts | 19 +- apps/meteor/tests/end-to-end/api/teams.ts | 234 ++++++++++++++++++ .../core-services/src/types/ITeamService.ts | 8 + .../model-typings/src/models/IRoomsModel.ts | 3 + .../v1/teams/TeamsListRoomsAndDiscussions.ts | 27 ++ packages/rest-typings/src/v1/teams/index.ts | 10 +- 9 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 packages/rest-typings/src/v1/teams/TeamsListRoomsAndDiscussions.ts diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 35d61f9e6491..88cd6ae6afc5 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -11,6 +11,7 @@ import { isTeamsDeleteProps, isTeamsLeaveProps, isTeamsUpdateProps, + isTeamsListRoomsAndDiscussionsProps, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; @@ -382,6 +383,29 @@ API.v1.addRoute( }, ); +// This should accept a teamId, filter (search by name on rooms collection) and sort/pagination +// should return a list of rooms/discussions from the team. the discussions will only be returned from the main room +API.v1.addRoute( + 'teams.listRoomsAndDiscussions', + { authRequired: true, validateParams: isTeamsListRoomsAndDiscussionsProps }, + { + async get() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter } = this.queryParams; + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.notFound(); + } + + const data = await Team.listRoomsAndDiscussions(this.userId, team, filter, sort, offset, count); + + return API.v1.success({ ...data, offset, count }); + }, + }, +); + API.v1.addRoute( 'teams.members', { authRequired: true }, diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 96cd5a3a3acf..784bd9f4e360 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -1462,6 +1462,22 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find({ uids: { $size: 2, $in: [uids] }, t: 'd' }); } + findPaginatedByNameOrFnameInIds( + ids: IRoom['_id'][], + filter?: string, + options: FindOptions = {}, + ): FindPaginated> { + const regxp = filter && new RegExp(escapeRegExp(filter), 'i'); + const query: Filter = { + _id: { + $in: ids, + }, + ...(regxp && { $or: [{ name: regxp }, { fname: regxp }] }), + }; + + return this.findPaginated(query, options); + } + // UPDATE addImportIds(_id: IRoom['_id'], importIds: string[]): Promise { const query: Filter = { _id }; @@ -2059,4 +2075,8 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateMany(query, update); } + + findDiscussionsByPrid(prid: string, options?: FindOptions): FindCursor { + return this.find({ prid }, options); + } } diff --git a/apps/meteor/server/services/team/service.ts b/apps/meteor/server/services/team/service.ts index 190464f48da4..c2904dc61310 100644 --- a/apps/meteor/server/services/team/service.ts +++ b/apps/meteor/server/services/team/service.ts @@ -1053,4 +1053,50 @@ export class TeamService extends ServiceClassInternal implements ITeamService { return rooms; } + + // Returns the list of rooms and discussions a user has access to inside a team + // Rooms returned are a composition of the rooms the user is in + public rooms + discussions from the main room (if any) + async listRoomsAndDiscussions( + userId: string, + team: ITeam, + filter?: string, + sort?: Record, + skip = 0, + limit = 10, + ): Promise<{ total: number; data: IRoom[] }> { + const mainRoom = await Rooms.findOneById(team.roomId, { projection: { _id: 1 } }); + if (!mainRoom) { + throw new Error('error-invalid-team-no-main-room'); + } + + const [discussionIds, teamRooms] = await Promise.all([ + Rooms.findDiscussionsByPrid(mainRoom._id, { projection: { _id: 1 } }) + .map(({ _id }) => _id) + .toArray(), + Rooms.findByTeamId(team._id, { projection: { _id: 1, t: 1 } }).toArray(), + ]); + + const teamPublicIds = teamRooms.filter(({ t }) => t === 'c').map(({ _id }) => _id); + const teamRoomIds = teamRooms.map(({ _id }) => _id); + const roomIds = await Subscriptions.findByUserIdAndRoomIds(userId, teamRoomIds, { projection: { rid: 1 } }) + .map(({ rid }) => rid) + .toArray(); + + const { cursor, totalCount } = Rooms.findPaginatedByNameOrFnameInIds( + [...new Set([mainRoom._id, ...roomIds, ...discussionIds, ...teamPublicIds])], + filter, + { + skip, + limit, + sort, + }, + ); + + const [data, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + total, + data, + }; + } } diff --git a/apps/meteor/tests/data/teams.helper.ts b/apps/meteor/tests/data/teams.helper.ts index 8fc60bd19fd4..f6cba25f86c9 100644 --- a/apps/meteor/tests/data/teams.helper.ts +++ b/apps/meteor/tests/data/teams.helper.ts @@ -2,11 +2,20 @@ import type { ITeam, TEAM_TYPE } from '@rocket.chat/core-typings'; import { api, request } from './api-data'; -export const createTeam = async (credentials: Record, teamName: string, type: TEAM_TYPE): Promise => { - const response = await request.post(api('teams.create')).set(credentials).send({ - name: teamName, - type, - }); +export const createTeam = async ( + credentials: Record, + teamName: string, + type: TEAM_TYPE, + members?: string[], +): Promise => { + const response = await request + .post(api('teams.create')) + .set(credentials) + .send({ + name: teamName, + type, + ...(members && { members }), + }); return response.body.team; }; diff --git a/apps/meteor/tests/end-to-end/api/teams.ts b/apps/meteor/tests/end-to-end/api/teams.ts index ca07d3e32679..69f76a4292e8 100644 --- a/apps/meteor/tests/end-to-end/api/teams.ts +++ b/apps/meteor/tests/end-to-end/api/teams.ts @@ -2217,4 +2217,238 @@ describe('[Teams]', () => { }); }); }); + + describe('[teams.listRoomsAndDiscussions]', () => { + const teamName = `team-${Date.now()}`; + let testTeam: ITeam; + let testUser: IUser; + let testUserCredentials: Credentials; + + let privateRoom: IRoom; + let privateRoom2: IRoom; + let publicRoom: IRoom; + let publicRoom2: IRoom; + + let discussionOnPrivateRoom: IRoom; + let discussionOnPublicRoom: IRoom; + let discussionOnMainRoom: IRoom; + + before('Create test team', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + + const members = testUser.username ? [testUser.username] : []; + testTeam = await createTeam(credentials, teamName, 0, members); + }); + + before('make user owner', async () => { + await request + .post(api('teams.updateMember')) + .set(credentials) + .send({ + teamName: testTeam.name, + member: { + userId: testUser._id, + roles: ['member', 'owner'], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + before('create rooms', async () => { + privateRoom = (await createRoom({ type: 'p', name: `test-p-${Date.now()}` })).body.group; + privateRoom2 = (await createRoom({ type: 'p', name: `test-p2-${Date.now()}`, credentials: testUserCredentials })).body.group; + publicRoom = (await createRoom({ type: 'c', name: `test-c-${Date.now()}` })).body.channel; + publicRoom2 = (await createRoom({ type: 'c', name: `test-c2-${Date.now()}` })).body.channel; + + await Promise.all([ + request + .post(api('teams.addRooms')) + .set(credentials) + .send({ + rooms: [privateRoom._id, publicRoom._id, publicRoom2._id], + teamId: testTeam._id, + }) + .expect(200), + request + .post(api('teams.addRooms')) + .set(testUserCredentials) + .send({ + rooms: [privateRoom2._id], + teamId: testTeam._id, + }) + .expect(200), + ]); + }); + + before('Create discussions', async () => { + discussionOnPrivateRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: privateRoom._id, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + discussionOnPublicRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: publicRoom._id, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + discussionOnMainRoom = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ + prid: testTeam.roomId, + t_name: `test-d-${Date.now()}`, + }) + ).body.discussion; + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: privateRoom._id }), + deleteRoom({ type: 'p', roomId: privateRoom2._id }), + deleteRoom({ type: 'c', roomId: publicRoom._id }), + deleteRoom({ type: 'c', roomId: publicRoom2._id }), + deleteRoom({ type: 'p', roomId: discussionOnPrivateRoom._id }), + deleteRoom({ type: 'c', roomId: discussionOnPublicRoom._id }), + deleteRoom({ type: 'c', roomId: discussionOnMainRoom._id }), + deleteTeam(credentials, teamName), + deleteUser({ _id: testUser._id }), + ]); + }); + + it('should fail if user is not logged in', async () => { + await request.get(api('teams.listRoomsAndDiscussions')).expect(401); + }); + + it('should fail if teamId is not passed as queryparam', async () => { + await request.get(api('teams.listRoomsAndDiscussions')).set(credentials).expect(400); + }); + + it('should fail if teamId is not valid', async () => { + await request.get(api('teams.listRoomsAndDiscussions')).set(credentials).query({ teamId: 'invalid' }).expect(404); + }); + + it('should fail if teamId is empty', async () => { + await request.get(api('teams.listRoomsAndDiscussions')).set(credentials).query({ teamId: '' }).expect(404); + }); + + it('should fail if both properties are passed', async () => { + await request + .get(api('teams.listRoomsAndDiscussions')) + .set(credentials) + .query({ teamId: testTeam._id, teamName: testTeam.name }) + .expect(400); + }); + + it('should fail if teamName is empty', async () => { + await request.get(api('teams.listRoomsAndDiscussions')).set(credentials).query({ teamName: '' }).expect(404); + }); + + it('should fail if teamName is invalid', async () => { + await request.get(api('teams.listRoomsAndDiscussions')).set(credentials).query({ teamName: 'invalid' }).expect(404); + }); + + it('should return a list of valid rooms for user', async () => { + const res = await request.get(api('teams.listRoomsAndDiscussions')).query({ teamId: testTeam._id }).set(credentials).expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.an('object'); + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.undefined; + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a valid list of rooms for non admin member too', async () => { + const res = await request + .get(api('teams.listRoomsAndDiscussions')) + .query({ teamName: testTeam.name }) + .set(testUserCredentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(5); + + const mainRoom = res.body.data.find((room: IRoom) => room._id === testTeam.roomId); + expect(mainRoom).to.be.an('object'); + + const publicChannel1 = res.body.data.find((room: IRoom) => room._id === publicRoom._id); + expect(publicChannel1).to.be.an('object'); + + const publicChannel2 = res.body.data.find((room: IRoom) => room._id === publicRoom2._id); + expect(publicChannel2).to.be.an('object'); + + const privateChannel1 = res.body.data.find((room: IRoom) => room._id === privateRoom._id); + expect(privateChannel1).to.be.undefined; + + const privateChannel2 = res.body.data.find((room: IRoom) => room._id === privateRoom2._id); + expect(privateChannel2).to.be.an('object'); + + const discussionOnP = res.body.data.find((room: IRoom) => room._id === discussionOnPrivateRoom._id); + expect(discussionOnP).to.be.undefined; + + const discussionOnC = res.body.data.find((room: IRoom) => room._id === discussionOnPublicRoom._id); + expect(discussionOnC).to.be.undefined; + + const mainDiscussion = res.body.data.find((room: IRoom) => room._id === discussionOnMainRoom._id); + expect(mainDiscussion).to.be.an('object'); + }); + + it('should return a list of rooms filtered by name using the filter parameter', async () => { + const res = await request + .get(api('teams.listRoomsAndDiscussions')) + .query({ teamId: testTeam._id, filter: 'test-p' }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(1); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data[0]._id).to.be.equal(privateRoom._id); + expect(res.body.data.find((room: IRoom) => room._id === privateRoom2._id)).to.be.undefined; + }); + + it('should paginate results', async () => { + const res = await request + .get(api('teams.listRoomsAndDiscussions')) + .query({ teamId: testTeam._id, offset: 1, count: 2 }) + .set(credentials) + .expect(200); + + expect(res.body).to.have.property('total').to.be.equal(5); + expect(res.body).to.have.property('data').to.be.an('array'); + expect(res.body.data).to.have.lengthOf(2); + }); + }); }); diff --git a/packages/core-services/src/types/ITeamService.ts b/packages/core-services/src/types/ITeamService.ts index 2d67bf515fca..1ec8c01e64e9 100644 --- a/packages/core-services/src/types/ITeamService.ts +++ b/packages/core-services/src/types/ITeamService.ts @@ -125,4 +125,12 @@ export interface ITeamService { getStatistics(): Promise; findBySubscribedUserIds(userId: string, callerId?: string): Promise; addRolesToMember(teamId: string, userId: string, roles: Array): Promise; + listRoomsAndDiscussions( + userId: string, + team: ITeam, + filter?: string, + sort?: Record, + skip?: number, + limit?: number, + ): Promise<{ total: number; data: IRoom[] }>; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 498a3c6b4bbc..4d116919c908 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -159,6 +159,8 @@ export interface IRoomsModel extends IBaseModel { findSmallestFederatedRoomInNumberOfUsers(options?: FindOptions): Promise; + findPaginatedByNameOrFnameInIds(ids: IRoom['_id'][], filter?: string, options?: FindOptions): FindPaginated>; + countFederatedRooms(): Promise; incMsgCountById(rid: string, inc: number): Promise; getIncMsgCountUpdateQuery(inc: number, roomUpdater: Updater): Updater; @@ -281,4 +283,5 @@ export interface IRoomsModel extends IBaseModel { getSubscribedRoomIdsWithoutE2EKeys(uid: IUser['_id']): Promise; removeUsersFromE2EEQueueByRoomId(roomId: IRoom['_id'], uids: IUser['_id'][]): Promise; removeUserFromE2EEQueue(uid: IUser['_id']): Promise; + findDiscussionsByPrid(prid: string, options?: FindOptions): FindCursor; } diff --git a/packages/rest-typings/src/v1/teams/TeamsListRoomsAndDiscussions.ts b/packages/rest-typings/src/v1/teams/TeamsListRoomsAndDiscussions.ts new file mode 100644 index 000000000000..756ee466c4e5 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsListRoomsAndDiscussions.ts @@ -0,0 +1,27 @@ +import type { ITeam } from '@rocket.chat/core-typings'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajv } from '../Ajv'; + +export type TeamsListRoomsAndDiscussionsProps = + | PaginatedRequest<{ + teamId: ITeam['_id']; + filter?: string; + }> + | PaginatedRequest<{ teamName: ITeam['name']; filter?: string }>; + +const TeamsListRoomsAndDiscussionsPropsSchema = { + type: 'object', + properties: { + teamId: { type: 'string' }, + teamName: { type: 'string' }, + filter: { type: 'string' }, + offset: { type: 'number' }, + count: { type: 'number' }, + sort: { type: 'string' }, + }, + additionalProperties: false, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], +}; + +export const isTeamsListRoomsAndDiscussionsProps = ajv.compile(TeamsListRoomsAndDiscussionsPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/index.ts b/packages/rest-typings/src/v1/teams/index.ts index 5c93fcefc86b..9365c5961d32 100644 --- a/packages/rest-typings/src/v1/teams/index.ts +++ b/packages/rest-typings/src/v1/teams/index.ts @@ -6,6 +6,7 @@ import type { TeamsAddMembersProps } from './TeamsAddMembersProps'; import type { TeamsConvertToChannelProps } from './TeamsConvertToChannelProps'; import type { TeamsDeleteProps } from './TeamsDeleteProps'; import type { TeamsLeaveProps } from './TeamsLeaveProps'; +import type { TeamsListRoomsAndDiscussionsProps } from './TeamsListRoomsAndDiscussions'; import type { TeamsRemoveMemberProps } from './TeamsRemoveMemberProps'; import type { TeamsRemoveRoomProps } from './TeamsRemoveRoomProps'; import type { TeamsUpdateMemberProps } from './TeamsUpdateMemberProps'; @@ -19,6 +20,7 @@ export * from './TeamsRemoveMemberProps'; export * from './TeamsRemoveRoomProps'; export * from './TeamsUpdateMemberProps'; export * from './TeamsUpdateProps'; +export * from './TeamsListRoomsAndDiscussions'; type ITeamAutocompleteResult = Pick; @@ -135,7 +137,7 @@ export type TeamsEndpoints = { '/v1/teams.info': { GET: (params: ({ teamId: string } | { teamName: string }) & Record) => { - teamInfo: ITeam; + teamInfo: Partial; }; }; @@ -171,7 +173,7 @@ export type TeamsEndpoints = { '/v1/teams.listRooms': { GET: ( params: PaginatedRequest< - ({ teamId: string } | { teamName: string } | { roomId: string }) & { + ({ teamId: string } | { teamName: string }) & { filter?: string; type?: string; } @@ -184,4 +186,8 @@ export type TeamsEndpoints = { room: IRoom; }; }; + + '/v1/teams.listRoomsAndDiscussions': { + GET: (params: TeamsListRoomsAndDiscussionsProps) => PaginatedResult<{ data: IRoom[] }>; + }; };