diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts index a0762f94ef..f11c54f112 100644 --- a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts @@ -1,6 +1,7 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; +import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; @@ -18,8 +19,6 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { ApiSuccessResponse } from "@calcom/platform-types"; import { Membership, Team } from "@calcom/prisma/client"; -import { UpdateOrgMembershipDto } from "../../inputs/update-organization-membership.input"; - describe("Organizations Memberships Endpoints", () => { describe("User Authentication - User is Org Admin", () => { let app: INestApplication; @@ -198,6 +197,7 @@ describe("Organizations Memberships Endpoints", () => { afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(user2.email); await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); await organizationsRepositoryFixture.delete(org.id); await app.close(); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts new file mode 100644 index 0000000000..01c774fb6a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts @@ -0,0 +1,342 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { Membership, Team } from "@calcom/prisma/client"; + +describe("Organizations Teams Memberships Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let orgTeam: Team; + let nonOrgTeam: Team; + let membership: Membership; + let membership2: Membership; + let membershipCreatedViaApi: Membership; + + const userEmail = "org-admin-membership-teams-controller-e2e@api.com"; + const userEmail2 = "org-member-membership-teams-controller-e2e@api.com"; + const nonOrgUserEmail = "non-org-member-membership-teams-controller-e2e@api.com"; + + const invitedUserEmail = "org-member-invited-membership-teams-controller-e2e@api.com"; + + let user: User; + let user2: User; + let nonOrgUser: User; + + let userToInviteViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + user2 = await userRepositoryFixture.create({ + email: userEmail2, + username: userEmail2, + }); + + nonOrgUser = await userRepositoryFixture.create({ + email: nonOrgUserEmail, + username: nonOrgUserEmail, + }); + + userToInviteViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: "Test Organization", + isOrganization: true, + }); + + orgTeam = await teamsRepositoryFixture.create({ + name: "Org Team", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + nonOrgTeam = await teamsRepositoryFixture.create({ + name: "Non Org Team", + isOrganization: false, + }); + + membership = await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + membership2 = await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: userToInviteViaApi.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: nonOrgUser.id } }, + team: { connect: { id: nonOrgTeam.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user2.id}`, + username: userEmail2, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user2.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userToInviteViaApi.id}`, + username: invitedUserEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userToInviteViaApi.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all the memberships of the org's team", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(membership.id); + expect(responseBody.data[1].id).toEqual(membership2.id); + expect(responseBody.data.length).toEqual(2); + }); + }); + + it("should fail to get all the memberships of team which is not in the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`) + .expect(404); + }); + + it("should get all the memberships of the org's team paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(membership2.id); + expect(responseBody.data[0].userId).toEqual(user2.id); + expect(responseBody.data.length).toEqual(1); + }); + }); + + it("should fail if org does not exist", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/120494059/teams/${orgTeam.id}/memberships`) + .expect(403); + }); + + it("should get the membership of the org's team", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membership.id); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + it("should fail to get the membership of a team not in the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships/${membership.id}`) + .expect(404); + }); + + it("should fail to create the membership of a team not in the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`) + .send({ + userId: userToInviteViaApi.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(404); + }); + + it("should create the membership of the org's team", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .send({ + userId: userToInviteViaApi.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id); + expect(membershipCreatedViaApi.role).toEqual("MEMBER"); + expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id); + }); + }); + + it("should fail to create the membership of the org's team for a non org user", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .send({ + userId: nonOrgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(422); + }); + + it("should update the membership of the org's team", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) + .send({ + role: "OWNER", + } satisfies UpdateOrgTeamMembershipDto) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.role).toEqual("OWNER"); + }); + }); + + it("should delete the membership of the org's team we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); + }); + }); + + it("should fail to get the membership of the org's team we just deleted", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(404); + }); + + it("should fail if the membership does not exist", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/123132145`) + .expect(404); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser.email); + await userRepositoryFixture.deleteByEmail(user2.email); + await organizationsRepositoryFixture.delete(org.id); + await organizationsRepositoryFixture.delete(nonOrgTeam.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts new file mode 100644 index 0000000000..7a8a5e90b1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts @@ -0,0 +1,158 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { + OrgTeamMembershipOutputDto, + OrgTeamMembershipsOutputResponseDto, + OrgTeamMembershipOutputResponseDto, +} from "@/modules/organizations/outputs/organization-teams-memberships.output"; +import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpStatus, + HttpCode, + UnprocessableEntityException, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/memberships", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg) +@DocsTags("Organizations Teams") +export class OrganizationsTeamsMembershipsController { + constructor( + private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService, + private readonly organizationsRepository: OrganizationsRepository + ) {} + + @Get("/") + @ApiOperation({ summary: "Get all the memberships of a team of an organization." }) + @UseGuards() + @Roles("TEAM_ADMIN") + @HttpCode(HttpStatus.OK) + async getAllOrgTeamMemberships( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const orgTeamMemberships = await this.organizationsTeamsMembershipsService.getPaginatedOrgTeamMemberships( + orgId, + teamId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: orgTeamMemberships.map((membership) => + plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }) + ), + }; + } + + @Get("/:membershipId") + @ApiOperation({ summary: "Get the membership of an organization's team by ID" }) + @UseGuards() + @Roles("TEAM_ADMIN") + @HttpCode(HttpStatus.OK) + async getOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const orgTeamMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership( + orgId, + teamId, + membershipId + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @Delete("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete the membership of an organization's team by ID" }) + async deleteOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.organizationsTeamsMembershipsService.deleteOrgTeamMembership( + orgId, + teamId, + membershipId + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @Patch("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Update the membership of an organization's team by ID" }) + async updateOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number, + @Body() data: UpdateOrgTeamMembershipDto + ): Promise { + const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership( + orgId, + teamId, + membershipId, + data + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a membership of an organization's team" }) + async createOrgTeamMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number, + @Body() data: CreateOrgTeamMembershipDto + ): Promise { + const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(data.userId)); + + if (!user) { + throw new UnprocessableEntityException("User is not part of the Organization"); + } + + const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts new file mode 100644 index 0000000000..f7f1e81dfb --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator"; + +export class CreateOrgTeamMembershipDto { + @IsInt() + readonly userId!: number; + + @IsOptional() + @IsBoolean() + readonly accepted?: boolean = false; + + @IsEnum(MembershipRole) + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @IsBoolean() + readonly disableImpersonation?: boolean = false; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts new file mode 100644 index 0000000000..2784ca48b2 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { IsBoolean, IsOptional, IsEnum } from "class-validator"; + +export class UpdateOrgTeamMembershipDto { + @IsOptional() + @IsBoolean() + readonly accepted?: boolean = false; + + @IsEnum(MembershipRole) + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + readonly role?: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @IsBoolean() + readonly disableImpersonation?: boolean = false; +} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 2a83014ece..d842f174c2 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -4,15 +4,18 @@ import { EmailService } from "@/modules/email/email.service"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller"; import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller"; +import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller"; import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller"; import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository"; import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository"; +import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository"; import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository"; import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service"; import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service"; +import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service"; import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service"; import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; @@ -37,6 +40,8 @@ import { Module } from "@nestjs/common"; EmailService, OrganizationsMembershipRepository, OrganizationsMembershipService, + OrganizationsTeamsMembershipsRepository, + OrganizationsTeamsMembershipsService, ], exports: [ OrganizationsService, @@ -46,12 +51,15 @@ import { Module } from "@nestjs/common"; OrganizationsUsersService, OrganizationsMembershipRepository, OrganizationsMembershipService, + OrganizationsTeamsMembershipsRepository, + OrganizationsTeamsMembershipsService, ], controllers: [ OrganizationsTeamsController, OrganizationsSchedulesController, OrganizationsUsersController, OrganizationsMembershipsController, + OrganizationsTeamsMembershipsController, ], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts new file mode 100644 index 0000000000..ceff7066cc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class OrgTeamMembershipOutputDto { + @IsInt() + @Expose() + readonly id!: number; + + @IsInt() + @Expose() + readonly userId!: number; + + @IsInt() + @Expose() + readonly teamId!: number; + + @IsBoolean() + @Expose() + readonly accepted!: boolean; + + @IsString() + @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + @Expose() + readonly role!: MembershipRole; + + @IsOptional() + @IsBoolean() + @Expose() + readonly disableImpersonation?: boolean; +} + +export class OrgTeamMembershipsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamMembershipOutputDto) + @IsArray() + data!: OrgTeamMembershipOutputDto[]; +} + +export class OrgTeamMembershipOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => OrgTeamMembershipOutputDto) + data!: OrgTeamMembershipOutputDto; +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts new file mode 100644 index 0000000000..470bab239f --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts @@ -0,0 +1,70 @@ +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsMembershipsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findOrgTeamMembershipsPaginated(organizationId: number, teamId: number, skip: number, take: number) { + return this.dbRead.prisma.membership.findMany({ + where: { + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + skip, + take, + }); + } + + async findOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { + return this.dbRead.prisma.membership.findUnique({ + where: { + id: membershipId, + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + }); + } + async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) { + return this.dbWrite.prisma.membership.delete({ + where: { + id: membershipId, + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + }); + } + + async updateOrgTeamMembershipById( + organizationId: number, + teamId: number, + membershipId: number, + data: UpdateOrgTeamMembershipDto + ) { + return this.dbWrite.prisma.membership.update({ + data: { ...data }, + where: { + id: membershipId, + teamId: teamId, + team: { + parentId: organizationId, + }, + }, + }); + } + + async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { + return this.dbWrite.prisma.membership.create({ + data: { ...data, teamId: teamId }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts index 8d06878b11..c1a393dbda 100644 --- a/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts @@ -1,16 +1,12 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository"; -import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; import { Injectable } from "@nestjs/common"; import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; @Injectable() export class OrganizationsMembershipService { - constructor( - private readonly organizationsMembershipRepository: OrganizationsMembershipRepository, - private readonly organizationsTeamsRepository: OrganizationsTeamsRepository - ) {} + constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {} async getOrgMembership(organizationId: number, membershipId: number) { const membership = await this.organizationsMembershipRepository.findOrgMembership( @@ -30,11 +26,11 @@ export class OrganizationsMembershipService { } async deleteOrgMembership(organizationId: number, membershipId: number) { - const memberships = await this.organizationsMembershipRepository.deleteOrgMembership( + const membership = await this.organizationsMembershipRepository.deleteOrgMembership( organizationId, membershipId ); - return memberships; + return membership; } async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) { diff --git a/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts new file mode 100644 index 0000000000..8eb65f30cc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts @@ -0,0 +1,68 @@ +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; +import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsMembershipsService { + constructor( + private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository + ) {} + + async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { + const teamMembership = await this.organizationsTeamsMembershipsRepository.createOrgTeamMembership( + teamId, + data + ); + return teamMembership; + } + + async getPaginatedOrgTeamMemberships(organizationId: number, teamId: number, skip = 0, take = 250) { + const teamMemberships = + await this.organizationsTeamsMembershipsRepository.findOrgTeamMembershipsPaginated( + organizationId, + teamId, + skip, + take + ); + return teamMemberships; + } + + async getOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { + const teamMemberships = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership( + organizationId, + teamId, + membershipId + ); + + if (!teamMemberships) { + throw new NotFoundException("Organization's Team membership not found"); + } + + return teamMemberships; + } + + async updateOrgTeamMembership( + organizationId: number, + teamId: number, + membershipId: number, + data: UpdateOrgTeamMembershipDto + ) { + const teamMembership = await this.organizationsTeamsMembershipsRepository.updateOrgTeamMembershipById( + organizationId, + teamId, + membershipId, + data + ); + return teamMembership; + } + + async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { + const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById( + organizationId, + teamId, + membershipId + ); + return teamMembership; + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 82f5aacdd1..21810f9284 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1615,6 +1615,259 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/memberships": { + "get": { + "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships", + "summary": "Get all the memberships of a team of an organization.", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + }, + "post": { + "operationId": "OrganizationsTeamsMembershipsController_createOrgTeamMembership", + "summary": "Create a membership of an organization's team", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgTeamMembershipDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/memberships/{membershipId}": { + "get": { + "operationId": "OrganizationsTeamsMembershipsController_getOrgTeamMembership", + "summary": "Get the membership of an organization's team by ID", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + }, + "delete": { + "operationId": "OrganizationsTeamsMembershipsController_deleteOrgTeamMembership", + "summary": "Delete the membership of an organization's team by ID", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + }, + "patch": { + "operationId": "OrganizationsTeamsMembershipsController_updateOrgTeamMembership", + "summary": "Update the membership of an organization's team by ID", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgTeamMembershipDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + } + }, "/v2/schedules": { "post": { "operationId": "SchedulesController_2024_04_15_createSchedule", @@ -5195,6 +5448,135 @@ "data" ] }, + "OrgTeamMembershipOutputDto": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "id": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "teamId": { + "type": "number" + }, + "accepted": { + "type": "boolean" + }, + "disableImpersonation": { + "type": "boolean" + } + }, + "required": [ + "role", + "id", + "userId", + "teamId", + "accepted" + ] + }, + "OrgTeamMembershipsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "OrgTeamMembershipOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOrgTeamMembershipDto": { + "type": "object", + "properties": { + "role": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "accepted": { + "type": "boolean", + "default": false + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + } + }, + "CreateOrgTeamMembershipDto": { + "type": "object", + "properties": { + "role": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "userId": { + "type": "number" + }, + "accepted": { + "type": "boolean", + "default": false + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + }, + "required": [ + "role", + "userId" + ] + }, "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": {