diff --git a/app/api/server/v1/teams.ts b/app/api/server/v1/teams.ts index ca1d12ed4bdb..83a108da25b3 100644 --- a/app/api/server/v1/teams.ts +++ b/app/api/server/v1/teams.ts @@ -2,7 +2,7 @@ import { Promise } from 'meteor/promise'; import { API } from '../api'; import { Team } from '../../../../server/sdk'; -import { hasPermission } from '../../../authorization/server'; +import { hasAtLeastOnePermission, hasPermission } from '../../../authorization/server'; API.v1.addRoute('teams.list', { authRequired: true }, { get() { @@ -40,6 +40,9 @@ API.v1.addRoute('teams.listAll', { authRequired: true }, { API.v1.addRoute('teams.create', { authRequired: true }, { post() { + if (!hasPermission(this.userId, 'create-team')) { + return API.v1.unauthorized(); + } const { name, type, members, room, owner } = this.bodyParams; if (!name) { @@ -62,15 +65,71 @@ API.v1.addRoute('teams.create', { authRequired: true }, { API.v1.addRoute('teams.members', { authRequired: true }, { get() { - const { teamId } = this.queryParams; + const { offset, count } = this.getPaginationItems(); + const { teamId, teamName } = this.queryParams; + + const { records, total } = Promise.await(Team.members(teamId, teamName, { offset, count })); + + return API.v1.success({ + members: records, + total, + count: records.length, + offset, + }); + }, +}); + +API.v1.addRoute('teams.addMembers', { authRequired: true }, { + post() { + if (!hasAtLeastOnePermission(this.userId, ['add-team-member', 'edit-team-member'])) { + return API.v1.unauthorized(); + } + + const { teamId, teamName, members } = this.bodyParams; + + Promise.await(Team.addMembers(this.userId, teamId, teamName, members)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.updateMember', { authRequired: true }, { + post() { + if (!hasAtLeastOnePermission(this.userId, ['edit-team-member'])) { + return API.v1.unauthorized(); + } + + const { teamId, teamName, member } = this.bodyParams; + + Promise.await(Team.updateMember(teamId, teamName, member)); + + return API.v1.success(); + }, +}); - if (!teamId) { - return API.v1.failure('Team ID is required'); +API.v1.addRoute('teams.removeMembers', { authRequired: true }, { + post() { + if (!hasAtLeastOnePermission(this.userId, ['edit-team-member'])) { + return API.v1.unauthorized(); } - const members = Promise.await(Team.members(this.userId, teamId)); + const { teamId, teamName, members } = this.bodyParams; + + Promise.await(Team.removeMembers(teamId, teamName, members)); + + return API.v1.success(); + }, +}); + +API.v1.addRoute('teams.leave', { authRequired: true }, { + post() { + const { teamId, teamName } = this.bodyParams; + + Promise.await(Team.removeMembers(teamId, teamName, [{ + userId: this.userId, + }])); - return API.v1.success({ members }); + return API.v1.success(); }, }); diff --git a/app/models/server/raw/TeamMember.ts b/app/models/server/raw/TeamMember.ts index 8b03c27fc87e..16ffe848d3ec 100644 --- a/app/models/server/raw/TeamMember.ts +++ b/app/models/server/raw/TeamMember.ts @@ -32,6 +32,10 @@ export class TeamMemberRaw extends BaseRaw { return this.col.find({ teamId }, options); } + updateOneByUserIdAndTeamId(userId: string, teamId: string, update: Partial): Promise { + return this.col.updateOne({ userId, teamId }, { $set: update }); + } + createOneByTeamIdAndUserId(teamId: string, userId: string, createdBy: Pick): Promise> { return this.insertOne({ teamId, diff --git a/server/sdk/types/ITeamService.ts b/server/sdk/types/ITeamService.ts index a9427ddfa7d3..8c3532fafa1a 100644 --- a/server/sdk/types/ITeamService.ts +++ b/server/sdk/types/ITeamService.ts @@ -8,11 +8,20 @@ export interface ITeamCreateParams { owner?: string; // the team owner. If not present, owner = requester } +export interface ITeamMemberParams { + userId?: string; + userName?: string; + roles?: Array; +} + export interface ITeamService { create(uid: string, params: ITeamCreateParams): Promise; list(uid: string, options?: IPaginationOptions): Promise>; listAll(options?: IPaginationOptions): Promise>; - members(uid: string, teamId: string): Promise>; + members(teamId: string, teamName: string, options?: IPaginationOptions): Promise>; + addMembers(uid: string, teamId: string, teamName: string, members: Array): Promise; + updateMember(teamId: string, teamName: string, members: ITeamMemberParams): Promise; + removeMembers(teamId: string, teamName: string, members: Array): Promise; getInfoByName(teamName: string): Promise | undefined>; getInfoById(teamId: string): Promise | undefined>; } diff --git a/server/services/team/service.ts b/server/services/team/service.ts index 6decf5203120..d58b24748b8b 100644 --- a/server/services/team/service.ts +++ b/server/services/team/service.ts @@ -2,8 +2,8 @@ import { Db } from 'mongodb'; import { TeamRaw } from '../../../app/models/server/raw/Team'; import { ITeam, ITeamMember, TEAM_TYPE, IRecordsWithTotal, IPaginationOptions } from '../../../definition/ITeam'; -import { Authorization, Room } from '../../sdk'; -import { ITeamCreateParams, ITeamService } from '../../sdk/types/ITeamService'; +import { Room } from '../../sdk'; +import { ITeamCreateParams, ITeamMemberParams, ITeamService } from '../../sdk/types/ITeamService'; import { IUser } from '../../../definition/IUser'; import { ServiceClass } from '../../sdk/types/ServiceClass'; import { UsersRaw } from '../../../app/models/server/raw/Users'; @@ -33,11 +33,6 @@ export class TeamService extends ServiceClass implements ITeamService { } async create(uid: string, { team, room = { name: team.name, extraData: {} }, members, owner }: ITeamCreateParams): Promise { - const hasPermission = await Authorization.hasPermission(uid, 'create-team'); - if (!hasPermission) { - throw new Error('no-permission'); - } - const existingTeam = await this.TeamModel.findOneByName(team.name, { projection: { _id: 1 } }); if (existingTeam) { throw new Error('team-name-already-exists'); @@ -155,18 +150,104 @@ export class TeamService extends ServiceClass implements ITeamService { }; } - async members(userId: string, teamId: string): Promise> { - const isMember = await this.TeamMembersModel.findOneByUserIdAndTeamId(userId, teamId); - const hasPermission = await Authorization.hasAtLeastOnePermission(userId, ['add-team-member', 'edit-team-member', 'view-all-teams']); - if (!hasPermission) { - throw new Error('no-permission'); + async members(teamId: string, teamName: string, { offset, count }: IPaginationOptions = { offset: 0, count: 50 }): Promise> { + if (!teamId) { + const teamIdName = await this.TeamModel.findOneByName(teamName, { projection: { _id: 1 } }); + if (!teamIdName) { + throw new Error('team-does-not-exist'); + } + + teamId = teamIdName._id; + } + + const cursor = this.TeamMembersModel.findByTeamId(teamId, { + limit: count, + skip: offset, + }); + + return { + total: await cursor.count(), + records: await cursor.toArray(), + }; + } + + async addMembers(uid: string, teamId: string, teamName: string, members: Array): Promise { + const createdBy = await this.Users.findOneById(uid, { projection: { username: 1 } }); + if (!createdBy) { + throw new Error('invalid-user'); + } + + if (!teamId) { + const teamIdName = await this.TeamModel.findOneByName(teamName, { projection: { _id: 1 } }); + if (!teamIdName) { + throw new Error('team-does-not-exist'); + } + + teamId = teamIdName._id; + } + + const membersList: Array> = members?.map((member) => ({ + teamId, + userId: member.userId ? member.userId : '', + roles: member.roles ? member.roles : [], + createdAt: new Date(), + createdBy, + _updatedAt: new Date(), // TODO how to avoid having to do this? + })) || []; + + await this.TeamMembersModel.insertMany(membersList); + } + + async updateMember(teamId: string, teamName: string, member: ITeamMemberParams): Promise { + if (!teamId) { + const teamIdName = await this.TeamModel.findOneByName(teamName, { projection: { _id: 1 } }); + if (!teamIdName) { + throw new Error('team-does-not-exist'); + } + + teamId = teamIdName._id; } - if (!isMember && !hasPermission) { - return []; + if (!member.userId) { + member.userId = await this.Users.findOneByUsername(member.userName); + if (!member.userId) { + throw new Error('invalid-user'); + } } - return this.TeamMembersModel.findByTeamId(teamId).toArray(); + const memberUpdate: Partial = { + roles: member.roles ? member.roles : [], + _updatedAt: new Date(), + }; + + await this.TeamMembersModel.updateOneByUserIdAndTeamId(member.userId, teamId, memberUpdate); + } + + async removeMembers(teamId: string, teamName: string, members: Array): Promise { + if (!teamId) { + const teamIdName = await this.TeamModel.findOneByName(teamName, { projection: { _id: 1 } }); + if (!teamIdName) { + throw new Error('team-does-not-exist'); + } + + teamId = teamIdName._id; + } + + for await (const member of members) { + if (!member.userId) { + member.userId = await this.Users.findOneByUsername(member.userName); + if (!member.userId) { + throw new Error('invalid-user'); + } + } + + const existingMember = await this.TeamMembersModel.findOneByUserIdAndTeamId(member.userId, teamId); + if (!existingMember) { + throw new Error('member-does-not-exist'); + } + + this.TeamMembersModel.removeById(existingMember._id); + } } async addMember({ _id, username }: IUser, userId: string, teamId: string): Promise { diff --git a/tests/end-to-end/api/25-teams.js b/tests/end-to-end/api/25-teams.js index 9855cfea7c03..9fd66e2e2709 100644 --- a/tests/end-to-end/api/25-teams.js +++ b/tests/end-to-end/api/25-teams.js @@ -77,4 +77,183 @@ describe('[Teams]', () => { .end(done); }); }); + + describe('/teams.addMembers', () => { + it('should add members to a public team', (done) => { + request.post(api('teams.addMembers')) + .set(credentials) + .send({ + teamName: community, + members: [ + { + userId: 'test-123', + roles: ['member'], + }, + { + userId: 'test-456', + roles: ['member'], + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => + request.get(api('teams.members')) + .set(credentials) + .query({ + teamName: community, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.lengthOf(3); + expect(response.body.members[1].userId).to.eql('test-123'); + expect(response.body.members[1].roles).to.have.lengthOf(1); + expect(response.body.members[1].roles).to.eql(['member']); + expect(response.body.members[2].userId).to.eql('test-456'); + expect(response.body.members[2].roles).to.have.lengthOf(1); + expect(response.body.members[2].roles).to.eql(['member']); + }), + ) + .then(() => done()) + .catch(done); + }); + }); + + describe('/teams.members', () => { + it('should list all the members from a public team', (done) => { + request.get(api('teams.members')) + .set(credentials) + .query({ + teamName: community, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count', 3); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('members'); + expect(res.body.members).to.have.length(3); + expect(res.body.members[0]).to.have.property('_id'); + expect(res.body.members[0]).to.have.property('roles'); + expect(res.body.members[0]).to.have.property('createdAt'); + }) + .end(done); + }); + }); + + describe('/teams.updateMember', () => { + it('should update member\'s data in a public team', (done) => { + request.post(api('teams.updateMember')) + .set(credentials) + .send({ + teamName: community, + member: + { + userId: 'test-123', + roles: ['member', 'owner'], + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => + request.get(api('teams.members')) + .set(credentials) + .query({ + teamName: community, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.length(3); + expect(response.body.members[1].userId).to.eql('test-123'); + expect(response.body.members[1].roles).to.have.lengthOf(2); + expect(response.body.members[1].roles).to.eql(['member', 'owner']); + }), + ) + .then(() => done()) + .catch(done); + }); + }); + + describe('/teams.removeMembers', () => { + it('should remove one member from a public team', (done) => { + request.post(api('teams.removeMembers')) + .set(credentials) + .send({ + teamName: community, + members: [ + { + userId: 'test-456', + }, + ], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => + request.get(api('teams.members')) + .set(credentials) + .query({ + teamName: community, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.lengthOf(2); + expect(response.body.members[1].userId).to.eql('test-123'); + }), + ) + .then(() => done()) + .catch(done); + }); + }); + + describe('/teams.leave', () => { + it('should remove the calling user from the team', (done) => { + request.post(api('teams.leave')) + .set(credentials) + .send({ + teamName: community, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .then(() => + request.get(api('teams.members')) + .set(credentials) + .query({ + teamName: community, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('members'); + expect(response.body.members).to.have.lengthOf(1); + expect(response.body.members[0].userId).to.eql('test-123'); + }), + ) + .then(() => done()) + .catch(done); + }); + }); });