diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts index 2b11d1fdc40e7a..6666e96445b453 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts @@ -119,6 +119,15 @@ export class OAuthClientRepository { }); } + async getByOrgId(organizationId: number) { + return this.dbRead.prisma.platformOAuthClient.findMany({ + where: { + organizationId, + }, + select: { id: true }, + }); + } + async getByEventTypeHosts(eventTypeId: number) { const hostWithUserPlatformClient = await this.dbRead.prisma.host.findFirst({ select: { diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index feda629b039bc0..ab89e115ae3352 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -1,17 +1,23 @@ +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input"; import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { TeamService } from "@calcom/platform-libraries"; import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service"; +export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR = `Can't add user to organization - the user is platform managed user but organization is not because organization probably was not created using OAuth credentials.`; +export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR = `Can't add user to organization - the user is not platform managed user but organization is platform managed. Both have to be created using OAuth credentials.`; +export const MANAGED_USER_AND_MANAGED_ORG_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR = `Can't add user to organization - managed user and organization were created using different OAuth clients.`; + @Injectable() export class OrganizationsMembershipService { constructor( private readonly organizationsMembershipRepository: OrganizationsMembershipRepository, - private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService + private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService, + private readonly oAuthClientsRepository: OAuthClientRepository ) {} async getOrgMembership(organizationId: number, membershipId: number) { @@ -95,7 +101,33 @@ export class OrganizationsMembershipService { } async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) { + await this.canUserBeAddedToOrg(data.userId, organizationId); const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data); return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } + + async canUserBeAddedToOrg(userId: number, orgId: number) { + const [userOAuthClient, orgOAuthClients] = await Promise.all([ + this.oAuthClientsRepository.getByUserId(userId), + this.oAuthClientsRepository.getByOrgId(orgId), + ]); + + if (!userOAuthClient && orgOAuthClients.length === 0) { + return true; + } + + if (userOAuthClient && orgOAuthClients.some((orgClient) => orgClient.id === userOAuthClient.id)) { + return true; + } + + if (!userOAuthClient) { + throw new BadRequestException(REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR); + } + + if (orgOAuthClients.length === 0) { + throw new BadRequestException(PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR); + } + + throw new BadRequestException(MANAGED_USER_AND_MANAGED_ORG_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR); + } } diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index a9dad4f97d9520..db918dba316887 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -13,7 +13,8 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { EmailModule } from "@/modules/email/email.module"; import { EmailService } from "@/modules/email/email.service"; -import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; import { UserOOOService } from "@/modules/ooo/services/ooo.service"; import { OrganizationsAttributesController } from "@/modules/organizations/attributes/index/controllers/organizations-attributes.controller"; @@ -65,6 +66,8 @@ import { RedisModule } from "@/modules/redis/redis.module"; import { RedisService } from "@/modules/redis/redis.service"; import { StripeModule } from "@/modules/stripe/stripe.module"; import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service"; import { TeamsModule } from "@/modules/teams/teams/teams.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; @@ -93,6 +96,7 @@ import { Module } from "@nestjs/common"; OrganizationsOrganizationsModule, OrganizationsStripeModule, OrganizationsTeamsRoutingFormsModule, + MembershipsModule, OrganizationsConferencingModule, EventTypesPrivateLinksModule, ], @@ -101,7 +105,6 @@ import { Module } from "@nestjs/common"; OrganizationsTeamsRepository, OrganizationsService, OrganizationsTeamsService, - MembershipsRepository, OrganizationsSchedulesService, OrganizationsUsersRepository, OrganizationsUsersService, @@ -147,6 +150,9 @@ import { Module } from "@nestjs/common"; TeamsSchedulesService, SchedulesService_2024_06_11, InputSchedulesService_2024_06_11, + TeamsMembershipsService, + TeamsMembershipsRepository, + OAuthClientRepository, ], exports: [ OrganizationsService, diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships-guard.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships-guard.controller.e2e-spec.ts new file mode 100644 index 00000000000000..3fdd3940588ad3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships-guard.controller.e2e-spec.ts @@ -0,0 +1,470 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { EmailService } from "@/modules/email/email.service"; +import { + CreateManagedUserData, + CreateManagedUserOutput, +} from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { + PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR, + REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR, +} from "@/modules/organizations/memberships/services/organizations-membership.service"; +import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { + PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR, + REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR, + PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR, +} from "@/modules/teams/memberships/services/teams-memberships.service"; +import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, User } from "@prisma/client"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +describe("Organizations Teams Memberships Endpoints", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + + let org: Team; + let orgOwner: User; + let orgUser: User; + let orgTeam: Team; + let orgApiKey: string; + + let platformOrg: Team; + let platformOrgOwner: User; + let platformOrgUser: CreateManagedUserData; + let platformOrgTeam: Team; + let platformOAuthClient: PlatformOAuthClient; + let secondPlatformOAuthClient: PlatformOAuthClient; + let secondPlatformOrgUser: CreateManagedUserData; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule, TeamsMembershipsModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + + const orgName = `organizations-teams-memberships-organization-${randomString()}`; + org = await organizationsRepositoryFixture.create({ + name: orgName, + slug: orgName, + isOrganization: true, + }); + + const platformOrgName = `organizations-teams-memberships-platform-organization-${randomString()}`; + platformOrg = await organizationsRepositoryFixture.create({ + isPlatform: true, + name: platformOrgName, + slug: platformOrgName, + isOrganization: true, + platformBilling: { + create: { + customerId: "cus_999", + plan: "ESSENTIALS", + subscriptionId: "sub_999", + }, + }, + }); + platformOAuthClient = await createOAuthClient(platformOrg.id); + secondPlatformOAuthClient = await createOAuthClient(platformOrg.id); + + orgOwner = await userRepositoryFixture.create({ + email: `organizations-teams-memberships-org-owner-${randomString()}@api.com`, + username: `organizations-teams-memberships-org-owner-${randomString()}`, + }); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(orgOwner.id, null); + orgApiKey = `cal_test_${keyString}`; + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: orgOwner.id } }, + team: { connect: { id: org.id } }, + accepted: true, + }); + + platformOrgOwner = await userRepositoryFixture.create({ + email: `platform-org-owner-${randomString()}@api.com`, + username: `platform-org-owner-${randomString()}`, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: platformOrgOwner.id } }, + team: { connect: { id: platformOrg.id } }, + accepted: true, + }); + + await profilesRepositoryFixture.create({ + uid: "asd1qwwqeqw-asddsadasd", + username: `platform-org-owner-${randomString()}`, + organization: { connect: { id: platformOrg.id } }, + user: { + connect: { id: platformOrgOwner.id }, + }, + }); + + await profilesRepositoryFixture.create({ + uid: "asd1qwwqeqw-asddsadasd-2", + username: `org-owner-${randomString()}`, + organization: { connect: { id: org.id } }, + user: { + connect: { id: orgOwner.id }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:4321"], + permissions: 1023, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("should create org users", () => { + it("should create a new org user", async () => { + const newOrgUser: CreateUserInput = { + email: `organization-user-${randomString()}@api.com`, + }; + + jest + .spyOn(EmailService.prototype, "sendSignupToOrganizationEmail") + .mockImplementation(() => Promise.resolve()); + + const { body } = await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users`) + .send(newOrgUser) + .set("Content-Type", "application/json") + .set("Authorization", `Bearer ${orgApiKey}`) + .set("Accept", "application/json"); + + const userData = body.data; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.email).toBe(newOrgUser.email); + orgUser = userData; + }); + + it(`should create a new platform org manager user using first oAuth client`, async () => { + const managedUserEmail = `platform-organization-manager-user-${randomString()}@api.com`; + const requestBody: CreateManagedUserInput = { + email: managedUserEmail, + timeZone: "Europe/Berlin", + weekStart: "Monday", + timeFormat: 24, + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + bio: "I am a bio", + metadata: { + key: "value", + }, + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${platformOAuthClient.id}/users`) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone); + expect(responseBody.data.user.name).toEqual(requestBody.name); + await userConnectedToOAuth(platformOAuthClient.id, responseBody.data.user.email, 1); + platformOrgUser = responseBody.data; + }); + + it(`should create a new platform org manager user using second oAuth client`, async () => { + const managedUserEmail = `platform-organization-manager-user-${randomString()}@api.com`; + const requestBody: CreateManagedUserInput = { + email: managedUserEmail, + timeZone: "Europe/Berlin", + weekStart: "Monday", + timeFormat: 24, + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + bio: "I am a bio", + metadata: { + key: "value", + }, + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${secondPlatformOAuthClient.id}/users`) + .set("x-cal-secret-key", secondPlatformOAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone); + expect(responseBody.data.user.name).toEqual(requestBody.name); + await userConnectedToOAuth(secondPlatformOAuthClient.id, responseBody.data.user.email, 1); + secondPlatformOrgUser = responseBody.data; + }); + }); + + describe("should create org teams", () => { + it("should create the team for org", async () => { + const teamName = `organization team ${randomString()}`; + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .set("Authorization", `Bearer ${orgApiKey}`) + .send({ + name: teamName, + } satisfies CreateOrgTeamDto) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + orgTeam = responseBody.data; + expect(orgTeam.name).toEqual(teamName); + expect(orgTeam.parentId).toEqual(org.id); + }); + }); + + it("should create the team for platform org", async () => { + const teamName = `platform organization team ${randomString()}`; + return request(app.getHttpServer()) + .post(`/v2/organizations/${platformOrg.id}/teams`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + name: teamName, + } satisfies CreateOrgTeamDto) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + platformOrgTeam = responseBody.data; + expect(platformOrgTeam.name).toEqual(teamName); + expect(platformOrgTeam.parentId).toEqual(platformOrg.id); + }); + }); + }); + + describe("organization memberships", () => { + describe("negative tests", () => { + it("should not add managed user to organization", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/memberships`) + .set("Authorization", `Bearer ${orgApiKey}`) + .send({ + userId: platformOrgUser.user.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(400); + expect(response.body.error.message).toEqual(PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR); + }); + + it("should not add managed user to organization team", async () => { + await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .set("Authorization", `Bearer ${orgApiKey}`) + .send({ + userId: platformOrgUser.user.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(422); + }); + + it("should not add managed user to organization team", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/teams/${orgTeam.id}/memberships`) + .set("Authorization", `Bearer ${orgApiKey}`) + .send({ + userId: platformOrgUser.user.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(400); + expect(response.body.error.message).toEqual(PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR); + }); + }); + + describe("positive tests", () => { + it("should add user to organization", async () => { + await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/memberships`) + .set("Authorization", `Bearer ${orgApiKey}`) + .send({ + userId: orgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + }); + + it("should add user to organization team", async () => { + await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) + .set("Authorization", `Bearer ${orgApiKey}`) + .send({ + userId: orgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + }); + }); + }); + + describe("platform organization memberships", () => { + describe("negative tests", () => { + it("should not add user to platform organization", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${platformOrg.id}/memberships`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + userId: orgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(400); + expect(response.body.error.message).toEqual(REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR); + }); + + it("should not add user to platform organization team", async () => { + await request(app.getHttpServer()) + .post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + userId: orgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(422); + }); + it("should not add user to platform organization team", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/teams/${platformOrgTeam.id}/memberships`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + userId: orgUser.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(400); + expect(response.body.error.message).toEqual(REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR); + }); + + it("should not add user to platform organization team because user is created using different oAuth client", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + userId: secondPlatformOrgUser.user.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(400); + expect(response.body.error.message).toEqual( + PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR + ); + }); + }); + + describe("positive tests", () => { + it("should add user to organization", async () => { + await request(app.getHttpServer()) + .post(`/v2/organizations/${platformOrg.id}/memberships`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + userId: platformOrgUser.user.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + }); + + it("should add user to organization team", async () => { + await request(app.getHttpServer()) + .post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`) + .set("x-cal-client-id", platformOAuthClient.id) + .set("x-cal-secret-key", platformOAuthClient.secret) + .send({ + userId: platformOrgUser.user.id, + accepted: true, + role: "MEMBER", + } satisfies CreateOrgTeamMembershipDto) + .expect(201); + }); + }); + }); + + async function userConnectedToOAuth(oAuthClientId: string, userEmail: string, usersCount: number) { + const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClientId); + const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail); + + expect(oAuthUsers?.length).toEqual(usersCount); + expect(newOAuthUser?.email).toEqual(userEmail); + } + + afterAll(async () => { + await organizationsRepositoryFixture.delete(org.id); + await teamsRepositoryFixture.delete(orgTeam.id); + await organizationsRepositoryFixture.delete(platformOrg.id); + await teamsRepositoryFixture.delete(platformOrgTeam.id); + await userRepositoryFixture.deleteByEmail(platformOrgUser.user.email); + await userRepositoryFixture.deleteByEmail(orgUser.email); + await userRepositoryFixture.deleteByEmail(orgOwner.email); + await userRepositoryFixture.deleteByEmail(platformOrgOwner.email); + await userRepositoryFixture.deleteByEmail(secondPlatformOrgUser.user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships.controller.e2e-spec.ts similarity index 100% rename from apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts rename to apps/api/v2/src/modules/organizations/teams/memberships/e2e/organizations-teams-memberships.controller.e2e-spec.ts diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts b/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts index d5e84d5341c749..8a4a474e4f153d 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts @@ -1,6 +1,7 @@ import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input"; import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; import { Injectable, NotFoundException } from "@nestjs/common"; import { TeamService } from "@calcom/platform-libraries"; @@ -8,10 +9,12 @@ import { TeamService } from "@calcom/platform-libraries"; @Injectable() export class OrganizationsTeamsMembershipsService { constructor( - private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository + private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository, + private readonly teamsMembershipsService: TeamsMembershipsService ) {} async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { + await this.teamsMembershipsService.canUserBeAddedToTeam(data.userId, teamId); const teamMembership = await this.organizationsTeamsMembershipsRepository.createOrgTeamMembership( teamId, data 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 index ef96a9180fc04f..dfb952d92eff77 100644 --- 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 @@ -1,15 +1,24 @@ +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; 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"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { TeamService } from "@calcom/platform-libraries"; +export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR = `Can't add user to team - the user is platform managed user but team is not because team probably was not created using OAuth credentials.`; +export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR = `Can't add user to team - the user is not platform managed user but team is platform managed. Both have to be created using OAuth credentials.`; +export const PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR = `Can't add user to team - managed user and team were created using different OAuth clients.`; + @Injectable() export class TeamsMembershipsService { - constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {} + constructor( + private readonly teamsMembershipsRepository: TeamsMembershipsRepository, + private readonly oAuthClientsRepository: OAuthClientRepository + ) {} async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) { + await this.canUserBeAddedToTeam(data.userId, teamId); const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data); return teamMembership; } @@ -54,4 +63,29 @@ export class TeamsMembershipsService { return teamMembership; } + + async canUserBeAddedToTeam(userId: number, teamId: number) { + const [userOAuthClient, teamOAuthClient] = await Promise.all([ + this.oAuthClientsRepository.getByUserId(userId), + this.oAuthClientsRepository.getByTeamId(teamId), + ]); + + if (!userOAuthClient && !teamOAuthClient) { + return true; + } + + if (userOAuthClient && teamOAuthClient && userOAuthClient.id === teamOAuthClient.id) { + return true; + } + + if (!teamOAuthClient) { + throw new BadRequestException(PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR); + } + + if (!userOAuthClient) { + throw new BadRequestException(REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR); + } + + throw new BadRequestException(PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR); + } } 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 index 40e53915a3918f..0b3e7294083a15 100644 --- a/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.module.ts @@ -12,5 +12,6 @@ import { Module } from "@nestjs/common"; imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule], providers: [TeamsMembershipsRepository, TeamsMembershipsService], controllers: [TeamsMembershipsController], + exports: [TeamsMembershipsService], }) export class TeamsMembershipsModule {}