feat: New endpoint for listing rooms & discussions from teams (#33177)

pull/33305/head^2
Kevin Aleman 1 year ago committed by GitHub
parent 636d32d783
commit 3a161c4310
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/soft-mirrors-remember.md
  2. 39
      apps/meteor/app/api/server/v1/teams.ts
  3. 81
      apps/meteor/server/models/raw/Rooms.ts
  4. 39
      apps/meteor/server/services/team/service.ts
  5. 19
      apps/meteor/tests/data/teams.helper.ts
  6. 321
      apps/meteor/tests/end-to-end/api/teams.ts
  7. 11
      packages/core-services/src/types/ITeamService.ts
  8. 8
      packages/model-typings/src/models/IRoomsModel.ts
  9. 36
      packages/rest-typings/src/v1/teams/TeamsListChildren.ts
  10. 6
      packages/rest-typings/src/v1/teams/index.ts

@ -0,0 +1,8 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-services": minor
"@rocket.chat/model-typings": minor
"@rocket.chat/rest-typings": minor
---
New `teams.listChildren` endpoint that allows users listing rooms & discussions from teams. Only the discussions from the team's main room are returned.

@ -11,6 +11,7 @@ import {
isTeamsDeleteProps,
isTeamsLeaveProps,
isTeamsUpdateProps,
isTeamsListChildrenProps,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { Match, check } from 'meteor/check';
@ -375,6 +376,44 @@ API.v1.addRoute(
},
);
const getTeamByIdOrNameOrParentRoom = async (
params: { teamId: string } | { teamName: string } | { roomId: string },
): Promise<Pick<ITeam, 'type' | 'roomId' | '_id'> | null> => {
if ('teamId' in params && params.teamId) {
return Team.getOneById<ITeam>(params.teamId, { projection: { type: 1, roomId: 1 } });
}
if ('teamName' in params && params.teamName) {
return Team.getOneByName(params.teamName, { projection: { type: 1, roomId: 1 } });
}
if ('roomId' in params && params.roomId) {
return Team.getOneByRoomId(params.roomId, { projection: { type: 1, roomId: 1 } });
}
return null;
};
// 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.listChildren',
{ authRequired: true, validateParams: isTeamsListChildrenProps },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { filter, type } = this.queryParams;
const team = await getTeamByIdOrNameOrParentRoom(this.queryParams);
if (!team) {
return API.v1.notFound();
}
const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count);
return API.v1.success({ ...data, offset, count });
},
},
);
API.v1.addRoute(
'teams.members',
{ authRequired: true },

@ -2063,4 +2063,85 @@ export class RoomsRaw extends BaseRaw<IRoom> implements IRoomsModel {
return this.updateMany(query, update);
}
findChildrenOfTeam(
teamId: string,
teamRoomId: string,
userId: string,
filter?: string,
type?: 'channels' | 'discussions',
options?: FindOptions<IRoom>,
): AggregationCursor<{ totalCount: { count: number }[]; paginatedResults: IRoom[] }> {
const nameFilter = filter ? new RegExp(escapeRegExp(filter), 'i') : undefined;
return this.col.aggregate<{ totalCount: { count: number }[]; paginatedResults: IRoom[] }>([
{
$match: {
$and: [
{
$or: [
...(!type || type === 'channels' ? [{ teamId }] : []),
...(!type || type === 'discussions' ? [{ prid: teamRoomId }] : []),
],
},
...(nameFilter ? [{ $or: [{ fname: nameFilter }, { name: nameFilter }] }] : []),
],
},
},
{
$lookup: {
from: 'rocketchat_subscription',
let: {
roomId: '$_id',
},
pipeline: [
{
$match: {
$and: [
{
$expr: {
$eq: ['$rid', '$$roomId'],
},
},
{
$expr: {
$eq: ['$u._id', userId],
},
},
{
$expr: {
$ne: ['$t', 'c'],
},
},
],
},
},
{
$project: { _id: 1 },
},
],
as: 'subscription',
},
},
{
$match: {
$or: [
{ t: 'c' },
{
$expr: {
$ne: [{ $size: '$subscription' }, 0],
},
},
],
},
},
{ $project: { subscription: 0 } },
{ $sort: options?.sort || { ts: 1 } },
{
$facet: {
totalCount: [{ $count: 'count' }],
paginatedResults: [{ $skip: options?.skip || 0 }, { $limit: options?.limit || 50 }],
},
},
]);
}
}

@ -913,8 +913,8 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
});
}
async getOneByRoomId(roomId: string): Promise<ITeam | null> {
const room = await Rooms.findOneById(roomId);
async getOneByRoomId(roomId: string, options?: FindOptions<ITeam>): Promise<ITeam | null> {
const room = await Rooms.findOneById(roomId, { projection: { teamId: 1 } });
if (!room) {
throw new Error('invalid-room');
@ -924,7 +924,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
throw new Error('room-not-on-team');
}
return Team.findOneById(room.teamId);
return Team.findOneById(room.teamId, options);
}
async addRolesToMember(teamId: string, userId: string, roles: Array<string>): Promise<boolean> {
@ -1078,4 +1078,37 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
const parentRoom = await this.getParentRoom(team);
return { team, ...(parentRoom && { parentRoom }) };
}
// 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 listChildren(
userId: string,
team: AtLeast<ITeam, '_id' | 'roomId' | 'type'>,
filter?: string,
type?: 'channels' | 'discussions',
sort?: Record<string, 1 | -1>,
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 isMember = await TeamMember.findOneByUserIdAndTeamId(userId, team._id, {
projection: { _id: 1 },
});
if (!isMember) {
throw new Error('error-invalid-team-not-a-member');
}
const [{ totalCount: [{ count: total }] = [], paginatedResults: data = [] }] =
(await Rooms.findChildrenOfTeam(team._id, mainRoom._id, userId, filter, type, { skip, limit, sort }).toArray()) || [];
return {
total,
data,
};
}
}

@ -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<string, any>, teamName: string, type: TEAM_TYPE): Promise<ITeam> => {
const response = await request.post(api('teams.create')).set(credentials).send({
name: teamName,
type,
});
export const createTeam = async (
credentials: Record<string, any>,
teamName: string,
type: TEAM_TYPE,
members?: string[],
): Promise<ITeam> => {
const response = await request
.post(api('teams.create'))
.set(credentials)
.send({
name: teamName,
type,
...(members && { members }),
});
return response.body.team;
};

@ -2217,4 +2217,325 @@ describe('[Teams]', () => {
});
});
});
describe('[teams.listChildren]', () => {
const teamName = `team-${Date.now()}`;
let testTeam: ITeam;
let testPrivateTeam: 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);
testPrivateTeam = await createTeam(testUserCredentials, `${teamName}private`, 1, []);
});
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),
deleteTeam(credentials, testPrivateTeam.name),
deleteUser({ _id: testUser._id }),
]);
});
it('should fail if user is not logged in', async () => {
await request.get(api('teams.listChildren')).expect(401);
});
it('should fail if teamId is not passed as queryparam', async () => {
await request.get(api('teams.listChildren')).set(credentials).expect(400);
});
it('should fail if teamId is not valid', async () => {
await request.get(api('teams.listChildren')).set(credentials).query({ teamId: 'invalid' }).expect(404);
});
it('should fail if teamId is empty', async () => {
await request.get(api('teams.listChildren')).set(credentials).query({ teamId: '' }).expect(404);
});
it('should fail if both properties are passed', async () => {
await request.get(api('teams.listChildren')).set(credentials).query({ teamId: testTeam._id, teamName: testTeam.name }).expect(400);
});
it('should fail if teamName is empty', async () => {
await request.get(api('teams.listChildren')).set(credentials).query({ teamName: '' }).expect(404);
});
it('should fail if teamName is invalid', async () => {
await request.get(api('teams.listChildren')).set(credentials).query({ teamName: 'invalid' }).expect(404);
});
it('should fail if roomId is empty', async () => {
await request.get(api('teams.listChildren')).set(credentials).query({ roomId: '' }).expect(404);
});
it('should fail if roomId is invalid', async () => {
await request.get(api('teams.listChildren')).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.listChildren')).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.listChildren')).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 valid list of rooms for non admin member too when filtering by teams main room id', async () => {
const res = await request.get(api('teams.listChildren')).query({ roomId: testTeam.roomId }).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.listChildren'))
.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.listChildren'))
.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);
});
it('should return only items of type channel', async () => {
const res = await request
.get(api('teams.listChildren'))
.query({ teamId: testTeam._id, type: 'channels' })
.set(credentials)
.expect(200);
expect(res.body).to.have.property('total').to.be.equal(4);
expect(res.body).to.have.property('data').to.be.an('array');
expect(res.body.data).to.have.lengthOf(4);
expect(res.body.data.some((room: IRoom) => !!room.prid)).to.be.false;
});
it('should return only items of type discussion', async () => {
const res = await request
.get(api('teams.listChildren'))
.query({ teamId: testTeam._id, type: 'discussions' })
.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).to.have.lengthOf(1);
expect(res.body.data.every((room: IRoom) => !!room.prid)).to.be.true;
});
it('should return both when type is not passed', async () => {
const res = await request.get(api('teams.listChildren')).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);
expect(res.body.data.some((room: IRoom) => !!room.prid)).to.be.true;
expect(res.body.data.some((room: IRoom) => !room.prid)).to.be.true;
});
it('should fail if type is other than channel or discussion', async () => {
await request.get(api('teams.listChildren')).query({ teamId: testTeam._id, type: 'other' }).set(credentials).expect(400);
});
it('should properly list children of a private team', async () => {
const res = await request.get(api('teams.listChildren')).query({ teamId: testPrivateTeam._id }).set(testUserCredentials).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).to.have.lengthOf(1);
});
it('should throw an error when a non member user tries to fetch info for team', async () => {
await request.get(api('teams.listChildren')).query({ teamId: testPrivateTeam._id }).set(credentials).expect(400);
});
});
});

@ -112,7 +112,7 @@ export interface ITeamService {
getOneById<P extends Document>(teamId: string, options?: FindOptions<P extends ITeam ? ITeam : P>): Promise<ITeam | P | null>;
getOneByName(teamName: string | RegExp, options?: FindOptions<ITeam>): Promise<ITeam | null>;
getOneByMainRoomId(teamId: string): Promise<Pick<ITeam, '_id'> | null>;
getOneByRoomId(teamId: string): Promise<ITeam | null>;
getOneByRoomId(teamId: string, options?: FindOptions<ITeam>): Promise<ITeam | null>;
getMatchingTeamRooms(teamId: string, rids: Array<string>): Promise<Array<string>>;
autocomplete(uid: string, name: string): Promise<ITeamAutocompleteResult[]>;
getAllPublicTeams(options?: FindOptions<ITeam>): Promise<Array<ITeam>>;
@ -129,4 +129,13 @@ export interface ITeamService {
getRoomInfo(
room: AtLeast<IRoom, 'teamId' | 'teamMain' | '_id'>,
): Promise<{ team?: Pick<ITeam, 'name' | 'roomId' | 'type'>; parentRoom?: Pick<IRoom, 'name' | 'fname' | 't' | '_id'> }>;
listChildren(
userId: string,
team: AtLeast<ITeam, '_id' | 'roomId' | 'type'>,
filter?: string,
type?: 'channels' | 'discussions',
sort?: Record<string, 1 | -1>,
skip?: number,
limit?: number,
): Promise<{ total: number; data: IRoom[] }>;
}

@ -282,4 +282,12 @@ export interface IRoomsModel extends IBaseModel<IRoom> {
getSubscribedRoomIdsWithoutE2EKeys(uid: IUser['_id']): Promise<IRoom['_id'][]>;
removeUsersFromE2EEQueueByRoomId(roomId: IRoom['_id'], uids: IUser['_id'][]): Promise<Document | UpdateResult>;
removeUserFromE2EEQueue(uid: IUser['_id']): Promise<Document | UpdateResult>;
findChildrenOfTeam(
teamId: string,
teamRoomId: string,
userId: string,
filter?: string,
type?: 'channels' | 'discussions',
options?: FindOptions<IRoom>,
): AggregationCursor<{ totalCount: { count: number }[]; paginatedResults: IRoom[] }>;
}

@ -0,0 +1,36 @@
import type { ITeam } from '@rocket.chat/core-typings';
import type { PaginatedRequest } from '../../helpers/PaginatedRequest';
import { ajv } from '../Ajv';
type GeneralProps = {
filter?: string;
type?: 'channels' | 'discussions';
};
export type TeamsListChildrenProps =
| PaginatedRequest<
{
teamId: ITeam['_id'];
} & GeneralProps
>
| PaginatedRequest<{ teamName: ITeam['name'] } & GeneralProps>
| PaginatedRequest<{ roomId: ITeam['roomId'] } & GeneralProps>;
const TeamsListChildrenPropsSchema = {
type: 'object',
properties: {
teamId: { type: 'string' },
teamName: { type: 'string' },
type: { type: 'string', enum: ['channels', 'discussions'] },
roomId: { type: 'string' },
filter: { type: 'string' },
offset: { type: 'number' },
count: { type: 'number' },
sort: { type: 'string' },
},
additionalProperties: false,
oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }, { required: ['roomId'] }],
};
export const isTeamsListChildrenProps = ajv.compile<TeamsListChildrenProps>(TeamsListChildrenPropsSchema);

@ -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 { TeamsListChildrenProps } from './TeamsListChildren';
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 './TeamsListChildren';
type ITeamAutocompleteResult = Pick<IRoom, '_id' | 'fname' | 'teamId' | 'name' | 't' | 'avatarETag'>;
@ -184,4 +186,8 @@ export type TeamsEndpoints = {
room: IRoom;
};
};
'/v1/teams.listChildren': {
GET: (params: TeamsListChildrenProps) => PaginatedResult<{ data: IRoom[] }>;
};
};

Loading…
Cancel
Save