diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts index b02b611399..3153cd63ac 100644 --- a/apps/api/v2/src/ee/platform-endpoints-module.ts +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -9,6 +9,7 @@ import { ProviderModule } from "@/ee/provider/provider.module"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; import { SlotsModule } from "@/modules/slots/slots.module"; +import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; @@ -24,6 +25,7 @@ import { Module } from "@nestjs/common"; CalendarsModule, BookingsModule_2024_04_15, BookingsModule_2024_08_13, + TeamsMembershipsModule, SlotsModule, ], }) diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts new file mode 100644 index 0000000000..a61dc12850 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts @@ -0,0 +1,301 @@ +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 { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output"; +import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output"; +import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output"; +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 { EventType, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.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 { randomNumber } from "test/utils/randomNumber"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { api } from "@calcom/app-store/alby"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { Membership, Team } from "@calcom/prisma/client"; + +describe("Teams Memberships Endpoints", () => { + describe("User Authentication - User is Team Admin", () => { + let app: INestApplication; + + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let team: Team; + let teamEventType: EventType; + let managedEventType: EventType; + let teamAdminMembership: Membership; + let teamMemberMembership: Membership; + let membershipCreatedViaApi: TeamMembershipOutput; + + const teamAdminEmail = `alice-admin-${randomNumber()}@api.com`; + const teamMemberEmail = `bob-member-${randomNumber()}@api.com`; + const nonTeamUserEmail = `charlie-outsider-${randomNumber()}@api`; + + const invitedUserEmail = `david-invited-${randomNumber()}@api.com`; + + let teamAdmin: User; + let teamMember: User; + let nonTeamUser: User; + + let teammateInvitedViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamAdminEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + teamAdmin = await userRepositoryFixture.create({ + email: teamAdminEmail, + username: teamAdminEmail, + }); + teamMember = await userRepositoryFixture.create({ + email: teamMemberEmail, + username: teamMemberEmail, + }); + nonTeamUser = await userRepositoryFixture.create({ + email: nonTeamUserEmail, + username: nonTeamUserEmail, + }); + teammateInvitedViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + team = await teamsRepositoryFixture.create({ + name: `Team-${randomNumber()}`, + isOrganization: false, + }); + + teamEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 30, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + managedEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "MANAGED", + team: { + connect: { id: team.id }, + }, + title: "Managed Event Type", + slug: "managed-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + teamAdminMembership = await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: teamAdmin.id } }, + team: { connect: { id: team.id } }, + }); + + teamMemberMembership = await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamMember.id } }, + team: { connect: { id: team.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamAdmin.id}`, + username: teamAdminEmail, + organization: { + connect: { + id: team.id, + }, + }, + user: { + connect: { + id: teamAdmin.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamMember.id}`, + username: teamMemberEmail, + organization: { + connect: { + id: team.id, + }, + }, + user: { + connect: { + id: teamMember.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should get all the memberships of the team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(2); + expect(responseBody.data[0].id).toEqual(teamAdminMembership.id); + expect(responseBody.data[1].id).toEqual(teamMemberMembership.id); + }); + }); + + it("should not be able to access memberships if not part of the team", async () => { + return request(app.getHttpServer()).get(`/v2/teams/9999/memberships`).expect(403); + }); + + it("should get all the memberships of the org's team paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(teamMemberMembership.id); + expect(responseBody.data[0].userId).toEqual(teamMember.id); + expect(responseBody.data.length).toEqual(1); + }); + }); + + it("should get membership of the team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships/${teamAdminMembership.id}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(teamAdminMembership.id); + expect(responseBody.data.userId).toEqual(teamAdmin.id); + }); + }); + + it("should have created the membership of the org's team and assigned team wide events", async () => { + const createTeamMembershipBody: CreateTeamMembershipInput = { + userId: teammateInvitedViaApi.id, + accepted: true, + role: "MEMBER", + }; + + return request(app.getHttpServer()) + .post(`/v2/teams/${team.id}/memberships`) + .send(createTeamMembershipBody) + .expect(201) + .then((response) => { + const responseBody: CreateTeamMembershipOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + membershipCreatedViaApi = responseBody.data; + expect(membershipCreatedViaApi.teamId).toEqual(team.id); + expect(membershipCreatedViaApi.role).toEqual("MEMBER"); + expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id); + userHasCorrectEventTypes(membershipCreatedViaApi.userId); + }); + }); + + async function userHasCorrectEventTypes(userId: number) { + const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); + expect(managedEventTypes?.length).toEqual(1); + expect(teamEventTypes?.length).toEqual(2); + const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug); + expect(collectiveEvenType).toBeTruthy(); + const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId); + expect(userHost).toBeTruthy(); + expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy(); + } + + it("should update the membership of the org's team", async () => { + const updateTeamMembershipBody: UpdateTeamMembershipInput = { + role: "OWNER", + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`) + .send(updateTeamMembershipBody) + .expect(200) + .then((response) => { + const responseBody: UpdateTeamMembershipOutput = 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/teams/${team.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/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`) + .expect(404); + }); + + it("should fail if the membership does not exist", async () => { + return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(teamAdmin.email); + await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email); + await userRepositoryFixture.deleteByEmail(nonTeamUser.email); + await userRepositoryFixture.deleteByEmail(teamMember.email); + await teamsRepositoryFixture.delete(team.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts new file mode 100644 index 0000000000..5a2fdbbe30 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts @@ -0,0 +1,159 @@ +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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service"; +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output"; +import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output"; +import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output"; +import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, + Logger, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/teams/:teamId/memberships", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, RolesGuard) +@DocsTags("Teams / Memberships") +export class TeamsMembershipsController { + private logger = new Logger("TeamsMembershipsController"); + + constructor( + private teamsMembershipsService: TeamsMembershipsService, + private oganizationsEventTypesService: OrganizationsEventTypesService + ) {} + + @Roles("TEAM_ADMIN") + @Post("/") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a membership" }) + async createTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: CreateTeamMembershipInput + ): Promise { + const membership = await this.teamsMembershipsService.createTeamMembership(teamId, body); + if (membership.accepted) { + try { + await updateNewTeamMemberEventTypes(body.userId, teamId); + } catch (err) { + this.logger.error("Could not update new team member eventTypes", err); + } + } + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), + }; + } + + @Get("/:membershipId") + @ApiOperation({ summary: "Get a membership" }) + @Roles("TEAM_ADMIN") + @HttpCode(HttpStatus.OK) + async getTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const orgTeamMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId); + + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get all memberships" }) + @Roles("TEAM_ADMIN") + @HttpCode(HttpStatus.OK) + async getTeamMemberships( + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships( + teamId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: orgTeamMemberships.map((membership) => + plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }) + ), + }; + } + + @Roles("TEAM_ADMIN") + @Patch("/:membershipId") + @ApiOperation({ summary: "Create a membership" }) + async updateTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number, + @Body() body: UpdateTeamMembershipInput + ): Promise { + const membership = await this.teamsMembershipsService.updateTeamMembership(teamId, membershipId, body); + + const currentMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId); + + const updatedMembership = await this.teamsMembershipsService.updateTeamMembership( + teamId, + membershipId, + body + ); + + if (!currentMembership.accepted && updatedMembership.accepted) { + try { + await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId); + } catch (err) { + this.logger.error("Could not update new team member eventTypes", err); + } + } + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @Delete("/:membershipId") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Delete a membership" }) + async deleteTeamMembership( + @Param("teamId", ParseIntPipe) teamId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId); + + await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId); + + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/teams/memberships/inputs/create-team-membership.input.ts b/apps/api/v2/src/modules/teams/memberships/inputs/create-team-membership.input.ts new file mode 100644 index 0000000000..bd6a52d4d8 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/inputs/create-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 CreateTeamMembershipInput { + @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/teams/memberships/inputs/update-team-membership.input.ts b/apps/api/v2/src/modules/teams/memberships/inputs/update-team-membership.input.ts new file mode 100644 index 0000000000..79c87b92ab --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/inputs/update-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 UpdateTeamMembershipInput { + @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/teams/memberships/outputs/create-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/create-team-membership.output.ts new file mode 100644 index 0000000000..41bc5611ba --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/create-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/delete-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/delete-team-membership.output.ts new file mode 100644 index 0000000000..40cde0710b --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/delete-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/get-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-membership.output.ts new file mode 100644 index 0000000000..4cfc4c4479 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/get-team-memberships.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-memberships.output.ts new file mode 100644 index 0000000000..14dc8a8f98 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/get-team-memberships.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetTeamMembershipsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput[]; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts new file mode 100644 index 0000000000..da32c2cf36 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; + +export class TeamMembershipOutput { + @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; +} diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/update-team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/update-team-membership.output.ts new file mode 100644 index 0000000000..a839d593bf --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/outputs/update-team-membership.output.ts @@ -0,0 +1,20 @@ +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateTeamMembershipOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: TeamMembershipOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; +} diff --git a/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts new file mode 100644 index 0000000000..328ac61517 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts @@ -0,0 +1,50 @@ +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class TeamsMembershipsService { + constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {} + + async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) { + const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data); + return teamMembership; + } + + async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) { + const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated( + teamId, + skip, + take + ); + return teamMemberships; + } + + async getTeamMembership(teamId: number, membershipId: number) { + const teamMemberships = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId); + + if (!teamMemberships) { + throw new NotFoundException("Organization's Team membership not found"); + } + + return teamMemberships; + } + + async updateTeamMembership(teamId: number, membershipId: number, data: UpdateTeamMembershipInput) { + const teamMembership = await this.teamsMembershipsRepository.updateTeamMembershipById( + teamId, + membershipId, + data + ); + return teamMembership; + } + + async deleteTeamMembership(teamId: number, membershipId: number) { + const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById( + teamId, + membershipId + ); + return teamMembership; + } +} diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts new file mode 100644 index 0000000000..b0cbd078d0 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts @@ -0,0 +1,15 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TeamsMembershipsController } from "@/modules/teams/memberships/controllers/teams-memberships.controller"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule], + providers: [TeamsMembershipsRepository, TeamsMembershipsService], + controllers: [TeamsMembershipsController], +}) +export class TeamsMembershipsModule {} diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts new file mode 100644 index 0000000000..d165c74d6c --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts @@ -0,0 +1,57 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class TeamsMembershipsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) { + return this.dbWrite.prisma.membership.create({ + data: { ...data, teamId: teamId }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } + + async findTeamMembershipsPaginated(teamId: number, skip: number, take: number) { + return await this.dbRead.prisma.membership.findMany({ + where: { + teamId: teamId, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + skip, + take, + }); + } + + async findTeamMembership(teamId: number, membershipId: number) { + return this.dbRead.prisma.membership.findUnique({ + where: { + id: membershipId, + teamId: teamId, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } + async deleteTeamMembershipById(teamId: number, membershipId: number) { + return this.dbWrite.prisma.membership.delete({ + where: { + id: membershipId, + teamId: teamId, + }, + }); + } + + async updateTeamMembershipById(teamId: number, membershipId: number, data: UpdateTeamMembershipInput) { + return this.dbWrite.prisma.membership.update({ + data: { ...data }, + where: { + id: membershipId, + teamId: teamId, + }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + }); + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 5e4d600c5d..47d88cf0f4 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -5366,6 +5366,219 @@ ] } }, + "/v2/teams/{teamId}/memberships": { + "post": { + "operationId": "TeamsMembershipsController_createTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamMembershipInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + }, + "get": { + "operationId": "TeamsMembershipsController_getTeamMemberships", + "summary": "Get all memberships", + "parameters": [ + { + "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/GetTeamMembershipsOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + } + }, + "/v2/teams/{teamId}/memberships/{membershipId}": { + "get": { + "operationId": "TeamsMembershipsController_getTeamMembership", + "summary": "Get a membership", + "parameters": [ + { + "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/GetTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + }, + "patch": { + "operationId": "TeamsMembershipsController_updateTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "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/UpdateTeamMembershipInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + }, + "delete": { + "operationId": "TeamsMembershipsController_deleteTeamMembership", + "summary": "Delete a membership", + "parameters": [ + { + "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/DeleteTeamMembershipOutput" + } + } + } + } + }, + "tags": [ + "Teams / Memberships" + ] + } + }, "/v2/timezones": { "get": { "operationId": "TimezonesController_getTimeZones", @@ -14453,6 +14666,192 @@ } } }, + "CreateTeamMembershipInput": { + "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" + ] + }, + "TeamMembershipOutput": { + "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" + ] + }, + "CreateTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamMembershipsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateTeamMembershipInput": { + "type": "object", + "properties": { + "role": { + "type": "string", + "default": "MEMBER", + "enum": [ + "MEMBER", + "OWNER", + "ADMIN" + ] + }, + "accepted": { + "type": "boolean", + "default": false + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + } + }, + "UpdateTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": [ + "status", + "data" + ] + }, "ReserveSlotInput": { "type": "object", "properties": { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 4af87be068..8223bb2ca7 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -5093,6 +5093,209 @@ "tags": ["Stripe"] } }, + "/v2/teams/{teamId}/memberships": { + "post": { + "operationId": "TeamsMembershipsController_createTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamMembershipInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamMembershipOutput" + } + } + } + } + }, + "tags": ["Teams / Memberships"] + }, + "get": { + "operationId": "TeamsMembershipsController_getTeamMemberships", + "summary": "Get all memberships", + "parameters": [ + { + "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/GetTeamMembershipsOutput" + } + } + } + } + }, + "tags": ["Teams / Memberships"] + } + }, + "/v2/teams/{teamId}/memberships/{membershipId}": { + "get": { + "operationId": "TeamsMembershipsController_getTeamMembership", + "summary": "Get a membership", + "parameters": [ + { + "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/GetTeamMembershipOutput" + } + } + } + } + }, + "tags": ["Teams / Memberships"] + }, + "patch": { + "operationId": "TeamsMembershipsController_updateTeamMembership", + "summary": "Create a membership", + "parameters": [ + { + "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/UpdateTeamMembershipInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamMembershipOutput" + } + } + } + } + }, + "tags": ["Teams / Memberships"] + }, + "delete": { + "operationId": "TeamsMembershipsController_deleteTeamMembership", + "summary": "Delete a membership", + "parameters": [ + { + "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/DeleteTeamMembershipOutput" + } + } + } + } + }, + "tags": ["Teams / Memberships"] + } + }, "/v2/timezones": { "get": { "operationId": "TimezonesController_getTimeZones", @@ -13021,6 +13224,141 @@ } } }, + "CreateTeamMembershipInput": { + "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"] + }, + "TeamMembershipOutput": { + "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"] + }, + "CreateTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": ["status", "data"] + }, + "GetTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": ["status", "data"] + }, + "GetTeamMembershipsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": ["status", "data"] + }, + "UpdateTeamMembershipInput": { + "type": "object", + "properties": { + "role": { + "type": "string", + "default": "MEMBER", + "enum": ["MEMBER", "OWNER", "ADMIN"] + }, + "accepted": { + "type": "boolean", + "default": false + }, + "disableImpersonation": { + "type": "boolean", + "default": false + } + } + }, + "UpdateTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": ["status", "data"] + }, + "DeleteTeamMembershipOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamMembershipOutput" + } + }, + "required": ["status", "data"] + }, "ReserveSlotInput": { "type": "object", "properties": {