From 2f946fc5da2280c8bab6967ee1bd7883ae742930 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Tue, 30 Jul 2024 16:58:39 +0200 Subject: [PATCH] feat: platform team event-types (#15928) * fix: dont check OrganizationSettings.orgAutoAcceptEmail uniqueness for platform teams * feat: display organizationId in OAuthClientCard * feat: ApiAuthStrategy handle oauth credentials * refactor: clean up ApiAuthStrategy test * refactor: use ApiAuthGuard in OauthClientsUsersController and OAuthFlowController * fix: copying org id * refactor: more specific error message when api auth * refactor: auto accept team creator membership * feat: useTeamEventType and useTeamEventTypes hooks * refactor: make team event-types public & searchable by eventSlug * feat: include host name in team event-types hosts response * fix: isFixed=true by default for COLLECTIVE event hosts * fix: useTeamEventType eventSlug access * feat: BookerPlatformWrapper enable team events by exposting orgId and teamId props * refactor: provide orgId in atoms context * refactor: use orgId from context in team event-types hooks * refactor: return teams in useMe * chore: examples app teams setup * Revert "refactor: return teams in useMe" This reverts commit de992ddc9af6ee9a2111938069f5b9c34cc2d8ea. * Revert "chore: examples app teams setup" This reverts commit 0766aa21acc25efa2361d38c3f87ddba773a0245. * feat: useTeams hook * chore: setup examples app with team event-type * fix: small fixes * swagger * Revert "refactor: provide orgId in atoms context" This reverts commit f053a498ee6f8fa8ece5ec8d8630c59eda8873e3. * feat: orgId in atoms context * feat: PlatformBilling guard * chore: delete test of the deleted oauth-client-credentials.guard * refactor: org event-types collective events isFixed always true and priority medium * refactor: org event-types COLLECTIVE response ignore isFixed and priority as they are same * fix: organizations event-types e2e * fix: billing guard * refactor: tests cleanup * fix: platform plan guard spec * refactor: e2e test cleanup * seed error if not team * refactor: rename authenticateApiKey to authenticateBearerToken * refactor: transforming response hosts * refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail * refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- apps/api/v2/src/ee/me/outputs/me.output.ts | 3 + apps/api/v2/src/modules/auth/auth.module.ts | 2 + .../billing/platform-plan.decorator.ts | 4 + .../billing/platform-plan.guard.spec.ts | 123 ++++++++ .../guards/billing/platform-plan.guard.ts | 79 +++++ .../api-auth/api-auth.strategy.e2e-spec.ts | 121 +++++-- .../strategies/api-auth/api-auth.strategy.ts | 69 +++- apps/api/v2/src/modules/billing/types.ts | 2 + .../oauth-client-users.controller.e2e-spec.ts | 21 ++ .../oauth-client-users.controller.ts | 4 +- .../oauth-flow.controller.e2e-spec.ts | 10 + .../oauth-flow/oauth-flow.controller.ts | 4 +- ...oauth-client-credentials.guard.e2e-spec.ts | 94 ------ .../oauth-client-credentials.guard.ts | 33 -- .../oauth-clients/oauth-client.module.ts | 11 +- .../organizations-event-types.controller.ts | 36 ++- .../organizations-event-types.e2e-spec.ts | 8 +- .../organizations-membership.controller.ts | 9 +- .../organizations-schedules.controller.ts | 10 +- ...anizations-teams-memberships.controller.ts | 9 +- .../teams/organizations-teams.controller.ts | 12 +- .../users/organizations-users.controller.ts | 8 +- .../outputs/organization-team.output.ts | 117 +------ .../organizations-event-types.repository.ts | 12 + .../services/event-types/input.service.ts | 34 +- .../organizations-event-types.service.ts | 13 + .../services/event-types/output.service.ts | 49 ++- .../src/modules/profiles/profiles.module.ts | 10 + .../modules/profiles/profiles.repository.ts | 20 ++ .../v2/src/modules/users/users.repository.ts | 10 + apps/api/v2/swagger/documentation.json | 296 +++++++++++++++++- .../dashboard/oauth-clients-list/index.tsx | 1 + .../oauth-clients/OAuthClientCard.tsx | 17 + apps/web/pages/api/auth/verify-email.test.ts | 6 +- apps/web/pages/api/auth/verify-email.ts | 2 +- .../repository/__mocks__/organization.ts | 20 +- .../server/repository/organization.test.ts | 12 +- .../lib/server/repository/organization.ts | 3 +- .../atoms/booker/BookerPlatformWrapper.tsx | 49 ++- .../atoms/cal-provider/BaseCalProvider.tsx | 4 + .../transformApiEventTypeForAtom.ts | 87 ++++- .../hooks/event-types/public/useEventType.ts | 6 +- .../event-types/public/useTeamEventType.ts | 41 +++ .../event-types/public/useTeamEventTypes.ts | 27 ++ .../platform/atoms/hooks/teams/useTeams.ts | 32 ++ .../platform/atoms/hooks/useAtomsContext.ts | 2 + packages/platform/atoms/index.ts | 2 + packages/platform/examples/base/.env.example | 1 + .../platform/examples/base/src/pages/_app.tsx | 28 +- .../base/src/pages/api/managed-user.ts | 140 ++++++++- .../examples/base/src/pages/booking.tsx | 85 +++-- .../inputs/get-event-types-query.input.ts | 6 + .../outputs/event-type.output.ts | 11 +- packages/platform/types/index.ts | 1 + packages/platform/types/oauth-clients.ts | 1 + .../platform/types/organizations/index.ts | 1 + .../types/organizations/teams/index.ts | 1 + .../organizations/teams/outputs/index.ts | 1 + .../teams/outputs/team.output.ts | 107 +++++++ 59 files changed, 1515 insertions(+), 412 deletions(-) create mode 100644 apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts create mode 100644 apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts create mode 100644 apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts delete mode 100644 apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.e2e-spec.ts delete mode 100644 apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts create mode 100644 apps/api/v2/src/modules/profiles/profiles.module.ts create mode 100644 apps/api/v2/src/modules/profiles/profiles.repository.ts create mode 100644 packages/platform/atoms/hooks/event-types/public/useTeamEventType.ts create mode 100644 packages/platform/atoms/hooks/event-types/public/useTeamEventTypes.ts create mode 100644 packages/platform/atoms/hooks/teams/useTeams.ts create mode 100644 packages/platform/types/organizations/index.ts create mode 100644 packages/platform/types/organizations/teams/index.ts create mode 100644 packages/platform/types/organizations/teams/outputs/index.ts create mode 100644 packages/platform/types/organizations/teams/outputs/team.output.ts diff --git a/apps/api/v2/src/ee/me/outputs/me.output.ts b/apps/api/v2/src/ee/me/outputs/me.output.ts index 060189d4d9..597b2738e9 100644 --- a/apps/api/v2/src/ee/me/outputs/me.output.ts +++ b/apps/api/v2/src/ee/me/outputs/me.output.ts @@ -22,4 +22,7 @@ export class MeOutput { @IsString() timeZone!: string; + + @IsInt() + organizationId!: number | null; } diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts index b97a5aceb5..8663d429b7 100644 --- a/apps/api/v2/src/modules/auth/auth.module.ts +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth. import { DeploymentsModule } from "@/modules/deployments/deployments.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { ProfilesModule } from "@/modules/profiles/profiles.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; @@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport"; MembershipsModule, TokensModule, DeploymentsModule, + ProfilesModule, ], providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService], exports: [NextAuthGuard, ApiAuthGuard], diff --git a/apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts b/apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts new file mode 100644 index 0000000000..e43da04781 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/billing/platform-plan.decorator.ts @@ -0,0 +1,4 @@ +import type { PlatformPlanType } from "@/modules/billing/types"; +import { Reflector } from "@nestjs/core"; + +export const PlatformPlan = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts new file mode 100644 index 0000000000..e65dbb5815 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts @@ -0,0 +1,123 @@ +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +describe("PlatformPlanGuard", () => { + let guard: PlatformPlanGuard; + let reflector: Reflector; + let organizationsRepository: OrganizationsRepository; + let redisService: RedisService; + + const mockContext = createMockExecutionContext({ + params: { teamId: "1", orgId: "1" }, + user: { id: "1" }, + }); + + beforeEach(async () => { + reflector = new Reflector(); + organizationsRepository = createMock(); + redisService = createMock({ + redis: { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(null), + }, + }); + guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + }); + + it("should return true", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: { + subscriptionId: "sub_123", + plan: "SCALE", + }, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if the organization does not exist", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return true if the organization is not platform", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: false, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: undefined, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if the organization has no subscription", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: { + subscriptionId: null, + plan: "STARTER", + }, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false if the user has a lower plan than required", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ + isPlatform: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + platformBilling: { + subscriptionId: "sub_123", + plan: "STARTER", + }, + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return true if the result is cached in Redis", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true)); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if the result is cached in Redis", async () => { + jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); + jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false)); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + function createMockExecutionContext(context: Record): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + params: context.params, + user: context.user, + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts new file mode 100644 index 0000000000..27bde7942f --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts @@ -0,0 +1,79 @@ +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { PlatformPlanType } from "@/modules/billing/types"; +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; + +@Injectable() +export class PlatformPlanGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly organizationsRepository: OrganizationsRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const teamId = request.params.teamId as string; + const orgId = request.params.orgId as string; + const user = request.user as GetUserReturnType; + const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType; + + const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${ + teamId ?? "none" + }:guard:platformbilling:${minimumPlan}`; + + const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false"); + + if (cachedAccess) { + return cachedAccess; + } + + let canAccess = false; + + if (user && orgId) { + const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId)); + const isPlatform = team?.isPlatform; + const hasSubscription = team?.platformBilling?.subscriptionId; + + if (!team) { + canAccess = false; + } else if (!isPlatform) { + canAccess = true; + } else if (!hasSubscription) { + canAccess = false; + } else { + canAccess = hasMinimumPlan({ + currentPlan: team.platformBilling?.plan as PlatformPlanType, + minimumPlan: minimumPlan, + plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"], + }); + } + } + + await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300); + return canAccess; + } +} + +type HasMinimumPlanProp = { + currentPlan: PlatformPlanType; + minimumPlan: PlatformPlanType; + plans: PlatformPlanType[]; +}; + +export function hasMinimumPlan(props: HasMinimumPlanProp): boolean { + const currentPlanIndex = props.plans.indexOf(props.currentPlan); + const minimumPlanIndex = props.plans.indexOf(props.minimumPlan); + + if (currentPlanIndex === -1 || minimumPlanIndex === -1) { + throw new Error( + `Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}` + ); + } + + return currentPlanIndex >= minimumPlanIndex; +} diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts index 8724db21e0..6351591ff2 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts @@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { ProfilesModule } from "@/modules/profiles/profiles.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersRepository } from "@/modules/users/users.repository"; import { ExecutionContext, HttpException } from "@nestjs/common"; @@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PlatformOAuthClient, Team, User } from "@prisma/client"; import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { MockedRedisService } from "test/mocks/mock-redis-service"; +import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + import { ApiAuthStrategy } from "./api-auth.strategy"; describe("ApiAuthStrategy", () => { @@ -33,11 +37,17 @@ describe("ApiAuthStrategy", () => { let oAuthClient: PlatformOAuthClient; let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + const validApiKeyEmail = "api-key-user-email@example.com"; const validAccessTokenEmail = "access-token-user-email@example.com"; + const validOAuthEmail = "oauth-user@example.com"; + let validApiKeyUser: User; let validAccessTokenUser: User; + let validOAuthUser: User; let module: TestingModule; + beforeAll(async () => { module = await Test.createTestingModule({ imports: [ @@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => { isGlobal: true, load: [appConfig], }), + ProfilesModule, ], providers: [ MockedRedisService, @@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => { apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module); teamRepositoryFixture = new TeamRepositoryFixture(module); oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); + profilesRepositoryFixture = new ProfileRepositoryFixture(module); organization = await teamRepositoryFixture.create({ name: "organization" }); validApiKeyUser = await userRepositoryFixture.create({ email: validApiKeyEmail, @@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => { validAccessTokenUser = await userRepositoryFixture.create({ email: validAccessTokenEmail, }); + + validOAuthUser = await userRepositoryFixture.create({ + email: validOAuthEmail, + }); + + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: validOAuthEmail, + user: { connect: { id: validOAuthUser.id } }, + organization: { connect: { id: organization.id } }, + }); + const data = { logo: "logo-url", name: "name", @@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => { oAuthClient.id ); - const context: ExecutionContext = { - switchToHttp: () => ({ - getRequest: () => ({ - headers: { - authorization: `Bearer ${accessToken}`, - }, - get: (key: string) => - ({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]), - }), - }), - } as ExecutionContext; - const request = context.switchToHttp().getRequest(); - const user = await strategy.accessTokenStrategy(accessToken); - await expect(user).toBeDefined(); - if (user) await expect(user.id).toEqual(validAccessTokenUser.id); + expect(user).toBeDefined(); + expect(user?.id).toEqual(validAccessTokenUser.id); }); it("should return user associated with valid api key", async () => { @@ -117,22 +128,15 @@ describe("ApiAuthStrategy", () => { now.setDate(now.getDate() + 1); const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now); - const context: ExecutionContext = { - switchToHttp: () => ({ - getRequest: () => ({ - headers: { - authorization: `Bearer cal_test_${keyString}`, - }, - get: (key: string) => - ({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]), - }), - }), - } as ExecutionContext; - const request = context.switchToHttp().getRequest(); - const user = await strategy.apiKeyStrategy(keyString); - await expect(user).toBeDefined(); - if (user) expect(user.id).toEqual(validApiKeyUser.id); + expect(user).toBeDefined(); + expect(user?.id).toEqual(validApiKeyUser.id); + }); + + it("should return user associated with valid OAuth client", async () => { + const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret); + expect(user).toBeDefined(); + expect(user.id).toEqual(validOAuthUser.id); }); it("should throw 401 if api key is invalid", async () => { @@ -158,7 +162,55 @@ describe("ApiAuthStrategy", () => { } }); - it("should throw 401 if Authorization header does not contain auth token", async () => { + it("should throw 401 if OAuth ID is invalid", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + [X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`, + [X_CAL_SECRET_KEY]: `secret`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + + it("should throw 401 if OAuth secret is invalid", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + [X_CAL_CLIENT_ID]: `${oAuthClient.id}`, + [X_CAL_SECRET_KEY]: `gibberish`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + + it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => { const context: ExecutionContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => { }); afterAll(async () => { - await userRepositoryFixture.deleteByEmail(validApiKeyEmail); - await userRepositoryFixture.deleteByEmail(validAccessTokenEmail); + await oAuthClientRepositoryFixture.delete(oAuthClient.id); + await userRepositoryFixture.delete(validApiKeyUser.id); + await userRepositoryFixture.delete(validAccessTokenUser.id); + await userRepositoryFixture.delete(validOAuthUser.id); + await teamRepositoryFixture.delete(organization.id); module.close(); }); }); diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts index d05824382c..17df03b9ad 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; import { BaseStrategy } from "@/lib/passport/strategies/types"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { DeploymentsService } from "@/modules/deployments/deployments.service"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { ProfilesRepository } from "@/modules/profiles/profiles.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; @@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; -import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; +import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; @Injectable() export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") { @@ -20,19 +22,74 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") private readonly oauthFlowService: OAuthFlowService, private readonly tokensRepository: TokensRepository, private readonly userRepository: UsersRepository, - private readonly apiKeyRepository: ApiKeyRepository + private readonly apiKeyRepository: ApiKeyRepository, + private readonly oauthRepository: OAuthClientRepository, + private readonly profilesRepository: ProfilesRepository ) { super(); } async authenticate(request: Request) { - const authString = request.get("Authorization")?.replace("Bearer ", ""); - if (!authString) { - return this.error(new UnauthorizedException("No Authorization header provided")); + try { + const { params } = request; + const oAuthClientSecret = request.get(X_CAL_SECRET_KEY); + const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID); + const bearerToken = request.get("Authorization")?.replace("Bearer ", ""); + + if (oAuthClientId && oAuthClientSecret) { + return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret); + } + + if (bearerToken) { + const requestOrigin = request.get("Origin"); + return await this.authenticateBearerToken(bearerToken, requestOrigin); + } + + throw new UnauthorizedException( + "No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers" + ); + } catch (err) { + if (err instanceof Error) { + return this.error(err); + } + return this.error( + new InternalServerErrorException("An error occurred while authenticating the request") + ); + } + } + + async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) { + const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret); + return this.success(user); + } + + async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) { + const client = await this.oauthRepository.getOAuthClient(oAuthClientId); + + if (!client) { + throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`); } - const requestOrigin = request.get("Origin"); + if (client.secret !== oAuthClientSecret) { + throw new UnauthorizedException("Invalid client secret"); + } + const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId); + + if (!platformCreatorId) { + throw new UnauthorizedException("No owner ID found for this OAuth client"); + } + + const user = await this.userRepository.findByIdWithProfile(platformCreatorId); + + if (!user) { + throw new UnauthorizedException("No user associated with the provided OAuth client"); + } + + return user; + } + + async authenticateBearerToken(authString: string, requestOrigin: string | undefined) { try { const user = isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_") ? await this.apiKeyStrategy(authString) diff --git a/apps/api/v2/src/modules/billing/types.ts b/apps/api/v2/src/modules/billing/types.ts index 3cce957fa8..ea19f6cdde 100644 --- a/apps/api/v2/src/modules/billing/types.ts +++ b/apps/api/v2/src/modules/billing/types.ts @@ -4,3 +4,5 @@ export enum PlatformPlan { SCALE = "SCALE", ENTERPRISE = "ENTERPRISE", } + +export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE"; diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index 392a11db99..51443c6259 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -18,6 +18,7 @@ import { PlatformOAuthClient, Team, User } from "@prisma/client"; import * as request from "supertest"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -78,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => { let teamRepositoryFixture: TeamRepositoryFixture; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let schedulesRepositoryFixture: SchedulesRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; let postResponseData: CreateUserResponse; + const platformAdminEmail = "platform-sensei@mail.com"; + let platformAdmin: User; + const userEmail = "oauth-client-user@gmail.com"; const userTimeZone = "Europe/Rome"; @@ -98,9 +103,20 @@ describe("OAuth Client Users Endpoints", () => { teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail }); + organization = await teamRepositoryFixture.create({ name: "organization" }); oAuthClient = await createOAuthClient(organization.id); + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: platformAdmin.id } }, + organization: { connect: { id: organization.id } }, + }); + await app.init(); }); @@ -277,6 +293,11 @@ describe("OAuth Client Users Endpoints", () => { } catch (e) { // User might have been deleted by the test } + try { + await userRepositoryFixture.delete(platformAdmin.id); + } catch (e) { + // User might have been deleted by the test + } await app.close(); }); }); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts index f6968b831a..ab22b43822 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -1,11 +1,11 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { Locales } from "@/lib/enums/locales"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; -import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; @@ -37,7 +37,7 @@ import { Pagination } from "@calcom/platform-types"; path: "/v2/oauth-clients/:clientId/users", version: API_VERSIONS_VALUES, }) -@UseGuards(OAuthClientCredentialsGuard) +@UseGuards(ApiAuthGuard) @DocsTags("Managed users") export class OAuthClientUsersController { private readonly logger = new Logger("UserController"); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts index 128d31b618..511c245a48 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts @@ -15,6 +15,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PlatformOAuthClient, Team, User } from "@prisma/client"; import * as request from "supertest"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { withNextAuth } from "test/utils/withNextAuth"; @@ -58,6 +59,7 @@ describe("OAuthFlow Endpoints", () => { let usersRepositoryFixtures: UserRepositoryFixture; let organizationsRepositoryFixture: TeamRepositoryFixture; let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; let user: User; let organization: Team; @@ -83,11 +85,19 @@ describe("OAuthFlow Endpoints", () => { oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); usersRepositoryFixtures = new UserRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); user = await usersRepositoryFixtures.create({ email: userEmail, }); + organization = await organizationsRepositoryFixture.create({ name: "organization" }); + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: user.id } }, + organization: { connect: { id: organization.id } }, + }); oAuthClient = await createOAuthClient(organization.id); }); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts index 96be69b9a8..f546590a37 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts @@ -1,9 +1,9 @@ import { getEnv } from "@/env"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; -import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input"; @@ -137,7 +137,7 @@ export class OAuthFlowController { @Post("/refresh") @HttpCode(HttpStatus.OK) - @UseGuards(OAuthClientCredentialsGuard) + @UseGuards(ApiAuthGuard) async refreshAccessToken( @Param("clientId") clientId: string, @Headers(X_CAL_SECRET_KEY) secretKey: string, diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.e2e-spec.ts deleted file mode 100644 index 285f866125..0000000000 --- a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.e2e-spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { AppModule } from "@/app.module"; -import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; -import { createMock } from "@golevelup/ts-jest"; -import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; -import { Test, TestingModule } from "@nestjs/testing"; -import { PlatformOAuthClient, Team } from "@prisma/client"; -import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; -import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; - -import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; - -import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard"; - -describe("OAuthClientCredentialsGuard", () => { - let guard: OAuthClientCredentialsGuard; - let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; - let teamRepositoryFixture: TeamRepositoryFixture; - let oauthClient: PlatformOAuthClient; - let organization: Team; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [AppModule, OAuthClientModule], - }).compile(); - - guard = module.get(OAuthClientCredentialsGuard); - teamRepositoryFixture = new TeamRepositoryFixture(module); - oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); - - organization = await teamRepositoryFixture.create({ name: "organization" }); - - const data = { - logo: "logo-url", - name: "name", - redirectUris: ["redirect-uri"], - permissions: 32, - }; - const secret = "secret"; - - oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret); - }); - - it("should be defined", () => { - expect(guard).toBeDefined(); - expect(oauthClient).toBeDefined(); - }); - - it("should return true if client ID and secret are valid", async () => { - const mockContext = createMockExecutionContext( - { [X_CAL_SECRET_KEY]: oauthClient.secret }, - { clientId: oauthClient.id } - ); - - await expect(guard.canActivate(mockContext)).resolves.toBe(true); - }); - - it("should return false if client ID is invalid", async () => { - const mockContext = createMockExecutionContext( - { [X_CAL_SECRET_KEY]: oauthClient.secret }, - { clientId: "invalid id" } - ); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - }); - - it("should return false if secret key is invalid", async () => { - const mockContext = createMockExecutionContext( - { [X_CAL_SECRET_KEY]: "invalid secret" }, - { clientId: oauthClient.id } - ); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - }); - - afterAll(async () => { - await oauthClientRepositoryFixture.delete(oauthClient.id); - await teamRepositoryFixture.delete(organization.id); - }); - - function createMockExecutionContext( - headers: Record, - params: Record - ): ExecutionContext { - return createMock({ - switchToHttp: () => ({ - getRequest: () => ({ - headers, - params, - get: (headerName: string) => headers[headerName], - }), - }), - }); - } -}); diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts deleted file mode 100644 index e58cdcba5c..0000000000 --- a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; -import { Request } from "express"; - -import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; - -@Injectable() -export class OAuthClientCredentialsGuard implements CanActivate { - constructor(private readonly oauthRepository: OAuthClientRepository) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const { params } = request; - - const oauthClientId = params.clientId; - const oauthClientSecret = request.get(X_CAL_SECRET_KEY); - - if (!oauthClientId) { - throw new UnauthorizedException("Missing client ID"); - } - if (!oauthClientSecret) { - throw new UnauthorizedException("Missing client secret"); - } - - const client = await this.oauthRepository.getOAuthClient(oauthClientId); - - if (!client || client.secret !== oauthClientSecret) { - throw new UnauthorizedException("Invalid client credentials"); - } - - return true; - } -} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index 8ec2c656ec..7e286b252e 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -6,7 +6,6 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller"; import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller"; -import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; @@ -34,14 +33,8 @@ import { Global, Module } from "@nestjs/common"; BillingModule, SchedulesModule_2024_04_15, ], - providers: [ - OAuthClientRepository, - OAuthClientCredentialsGuard, - TokensRepository, - OAuthFlowService, - OAuthClientUsersService, - ], + providers: [OAuthClientRepository, TokensRepository, OAuthFlowService, OAuthClientUsersService], controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController], - exports: [OAuthClientRepository, OAuthClientCredentialsGuard], + exports: [OAuthClientRepository], }) export class OAuthClientModule {} diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts index f2008a31a1..b93314fcf1 100644 --- a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts @@ -1,7 +1,9 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.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"; @@ -32,6 +34,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { CreateTeamEventTypeInput_2024_06_14, + GetTeamEventTypesQuery_2024_06_14, SkipTakePagination, UpdateTeamEventTypeInput_2024_06_14, } from "@calcom/platform-types"; @@ -40,13 +43,13 @@ import { path: "/v2/organizations/:orgId", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) @DocsTags("Organizations Event Types") export class OrganizationsEventTypesController { constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {} @Roles("TEAM_ADMIN") - @UseGuards(IsTeamInOrg) + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) @Post("/teams/:teamId/event-types") async createTeamEventType( @GetUser() user: UserWithProfile, @@ -68,7 +71,8 @@ export class OrganizationsEventTypesController { } @Roles("TEAM_ADMIN") - @UseGuards(IsTeamInOrg) + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) @Get("/teams/:teamId/event-types/:eventTypeId") async getTeamEventType( @Param("teamId", ParseIntPipe) teamId: number, @@ -86,10 +90,22 @@ export class OrganizationsEventTypesController { }; } - @Roles("TEAM_ADMIN") - @UseGuards(IsTeamInOrg) + @UseGuards(IsOrgGuard, IsTeamInOrg) @Get("/teams/:teamId/event-types") - async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise { + async getTeamEventTypes( + @Param("teamId", ParseIntPipe) teamId: number, + @Query() queryParams: GetTeamEventTypesQuery_2024_06_14 + ): Promise { + const { eventSlug } = queryParams; + if (eventSlug) { + const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug); + + return { + status: SUCCESS_STATUS, + data: eventType ? [eventType] : [], + }; + } + const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId); return { @@ -99,6 +115,8 @@ export class OrganizationsEventTypesController { } @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) @Get("/teams/event-types") async getTeamsEventTypes( @Param("orgId", ParseIntPipe) orgId: number, @@ -114,7 +132,8 @@ export class OrganizationsEventTypesController { } @Roles("TEAM_ADMIN") - @UseGuards(IsTeamInOrg) + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) @Patch("/teams/:teamId/event-types/:eventTypeId") async updateTeamEventType( @Param("teamId", ParseIntPipe) teamId: number, @@ -136,7 +155,8 @@ export class OrganizationsEventTypesController { } @Roles("TEAM_ADMIN") - @UseGuards(IsTeamInOrg) + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) @Delete("/teams/:teamId/event-types/:eventTypeId") @HttpCode(HttpStatus.OK) async deleteTeamEventType( diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts index 55f7c11a82..7f795a4ccb 100644 --- a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts @@ -252,13 +252,9 @@ describe("Organizations Event Types Endpoints", () => { hosts: [ { userId: teammate1.id, - mandatory: true, - priority: "high", }, { userId: teammate2.id, - mandatory: false, - priority: "low", }, ], }; @@ -431,8 +427,6 @@ describe("Organizations Event Types Endpoints", () => { const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [ { userId: teammate1.id, - mandatory: true, - priority: "medium", }, ]; @@ -603,7 +597,9 @@ describe("Organizations Event Types Endpoints", () => { await userRepositoryFixture.deleteByEmail(teammate2.email); await userRepositoryFixture.deleteByEmail(falseTestUser.email); await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(falseTestTeam.id); await organizationsRepositoryFixture.delete(org.id); + await organizationsRepositoryFixture.delete(falseTestOrg.id); await app.close(); }); }); diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts index fdf6bc0ec2..5de085f4fc 100644 --- a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts @@ -1,7 +1,9 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; @@ -39,12 +41,13 @@ import { Membership } from "@calcom/prisma/client"; path: "/v2/organizations/:orgId/memberships", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) @DocsTags("Organizations Memberships") export class OrganizationsMembershipsController { constructor(private organizationsMembershipService: OrganizationsMembershipService) {} @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @Get("/") @HttpCode(HttpStatus.OK) async getAllMemberships( @@ -66,6 +69,7 @@ export class OrganizationsMembershipsController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @Post("/") @HttpCode(HttpStatus.CREATED) async createMembership( @@ -80,6 +84,7 @@ export class OrganizationsMembershipsController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsMembershipInOrg) @Get("/:membershipId") @HttpCode(HttpStatus.OK) @@ -91,6 +96,7 @@ export class OrganizationsMembershipsController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsMembershipInOrg) @Delete("/:membershipId") @HttpCode(HttpStatus.OK) @@ -107,6 +113,7 @@ export class OrganizationsMembershipsController { @UseGuards(IsMembershipInOrg) @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @Patch("/:membershipId") @HttpCode(HttpStatus.OK) async updateMembership( diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts index 9eb076e8a8..24a4b91268 100644 --- a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts @@ -1,7 +1,9 @@ import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; @@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types"; path: "/v2/organizations/:orgId", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) @DocsTags("Organizations Schedules") export class OrganizationsSchedulesController { constructor( @@ -47,6 +49,7 @@ export class OrganizationsSchedulesController { ) {} @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @Get("/schedules") async getOrganizationSchedules( @Param("orgId", ParseIntPipe) orgId: number, @@ -63,6 +66,7 @@ export class OrganizationsSchedulesController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) @Post("/users/:userId/schedules") async createUserSchedule( @@ -78,6 +82,7 @@ export class OrganizationsSchedulesController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) @Get("/users/:userId/schedules/:scheduleId") async getUserSchedule( @@ -93,6 +98,7 @@ export class OrganizationsSchedulesController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) @Get("/users/:userId/schedules") async getUserSchedules( @@ -107,6 +113,7 @@ export class OrganizationsSchedulesController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) @Patch("/users/:userId/schedules/:scheduleId") async updateUserSchedule( @@ -123,6 +130,7 @@ export class OrganizationsSchedulesController { } @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) @Delete("/users/:userId/schedules/:scheduleId") @HttpCode(HttpStatus.OK) 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 index 7a8a5e90b1..ebc5142b46 100644 --- 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 @@ -1,6 +1,8 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.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"; @@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types"; path: "/v2/organizations/:orgId/teams/:teamId/memberships", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) @DocsTags("Organizations Teams") export class OrganizationsTeamsMembershipsController { constructor( @@ -50,6 +52,7 @@ export class OrganizationsTeamsMembershipsController { @ApiOperation({ summary: "Get all the memberships of a team of an organization." }) @UseGuards() @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") @HttpCode(HttpStatus.OK) async getAllOrgTeamMemberships( @Param("orgId", ParseIntPipe) orgId: number, @@ -75,6 +78,7 @@ export class OrganizationsTeamsMembershipsController { @ApiOperation({ summary: "Get the membership of an organization's team by ID" }) @UseGuards() @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") @HttpCode(HttpStatus.OK) async getOrgTeamMembership( @Param("orgId", ParseIntPipe) orgId: number, @@ -93,6 +97,7 @@ export class OrganizationsTeamsMembershipsController { } @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") @Delete("/:membershipId") @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Delete the membership of an organization's team by ID" }) @@ -113,6 +118,7 @@ export class OrganizationsTeamsMembershipsController { } @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") @Patch("/:membershipId") @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Update the membership of an organization's team by ID" }) @@ -135,6 +141,7 @@ export class OrganizationsTeamsMembershipsController { } @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") @Post("/") @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Create a membership of an organization's team" }) diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts index 47d889edba..e672244694 100644 --- a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts @@ -1,8 +1,10 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.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"; @@ -10,7 +12,6 @@ import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organiza import { OrgMeTeamOutputDto, OrgMeTeamsOutputResponseDto, - OrgTeamOutputDto, OrgTeamOutputResponseDto, OrgTeamsOutputResponseDto, } from "@/modules/organizations/outputs/organization-team.output"; @@ -32,6 +33,7 @@ import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { plainToClass } from "class-transformer"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { OrgTeamOutputDto } from "@calcom/platform-types"; import { SkipTakePagination } from "@calcom/platform-types"; import { Team } from "@calcom/prisma/client"; @@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client"; path: "/v2/organizations/:orgId/teams", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) @DocsTags("Organizations Teams") export class OrganizationsTeamsController { constructor(private organizationsTeamsService: OrganizationsTeamsService) {} @@ -47,6 +49,7 @@ export class OrganizationsTeamsController { @Get() @ApiOperation({ summary: "Get all the teams of an organization." }) @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") async getAllTeams( @Param("orgId", ParseIntPipe) orgId: number, @Query() queryParams: SkipTakePagination @@ -62,6 +65,7 @@ export class OrganizationsTeamsController { @Get("/me") @ApiOperation({ summary: "Get the organization's teams user is a member of" }) @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") async getMyTeams( @Param("orgId", ParseIntPipe) orgId: number, @Query() queryParams: SkipTakePagination, @@ -88,6 +92,7 @@ export class OrganizationsTeamsController { @UseGuards(IsTeamInOrg) @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") @Get("/:teamId") @ApiOperation({ summary: "Get a team of the organization by ID." }) async getTeam(@GetTeam() team: Team): Promise { @@ -99,6 +104,7 @@ export class OrganizationsTeamsController { @UseGuards(IsTeamInOrg) @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @Delete("/:teamId") @ApiOperation({ summary: "Delete a team of the organization by ID." }) async deleteTeam( @@ -114,6 +120,7 @@ export class OrganizationsTeamsController { @UseGuards(IsTeamInOrg) @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @Patch("/:teamId") @ApiOperation({ summary: "Update a team of the organization by ID." }) async updateTeam( @@ -130,6 +137,7 @@ export class OrganizationsTeamsController { @Post() @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @ApiOperation({ summary: "Create a team for an organization." }) async createTeam( @Param("orgId", ParseIntPipe) orgId: number, diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts index d3dcd572f0..0d094507d3 100644 --- a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts @@ -1,8 +1,10 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; @@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client"; version: API_VERSIONS_VALUES, }) @UseInterceptors(ClassSerializerInterceptor) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) @UseGuards(IsOrgGuard) @DocsTags("Organizations Users") export class OrganizationsUsersController { @@ -47,6 +49,7 @@ export class OrganizationsUsersController { @Get() @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") async getOrganizationsUsers( @Param("orgId", ParseIntPipe) orgId: number, @Query() query: GetOrganizationsUsersInput @@ -66,6 +69,7 @@ export class OrganizationsUsersController { @Post() @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") async createOrganizationUser( @Param("orgId", ParseIntPipe) orgId: number, @GetOrg() org: Team, @@ -85,6 +89,7 @@ export class OrganizationsUsersController { @Patch("/:userId") @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) async updateOrganizationUser( @Param("orgId", ParseIntPipe) orgId: number, @@ -101,6 +106,7 @@ export class OrganizationsUsersController { @Delete("/:userId") @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") @UseGuards(IsUserInOrg) async deleteOrganizationUser( @Param("orgId", ParseIntPipe) orgId: number, diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts index e8cb14ac6d..321aefaacf 100644 --- a/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts +++ b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts @@ -1,122 +1,9 @@ import { ApiProperty } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; -import { - IsBoolean, - IsEnum, - IsInt, - IsOptional, - IsString, - IsUrl, - Length, - ValidateNested, -} from "class-validator"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; - -export class OrgTeamOutputDto { - @IsInt() - @Expose() - readonly id!: number; - - @IsInt() - @IsOptional() - @Expose() - readonly parentId?: number; - - @IsString() - @Length(1) - @Expose() - readonly name!: string; - - @IsOptional() - @IsString() - @Expose() - readonly slug?: string; - - @IsOptional() - @IsUrl() - @Expose() - readonly logoUrl?: string; - - @IsOptional() - @IsUrl() - @Expose() - readonly calVideoLogo?: string; - - @IsOptional() - @IsUrl() - @Expose() - readonly appLogo?: string; - - @IsOptional() - @IsUrl() - @Expose() - readonly appIconLogo?: string; - - @IsOptional() - @IsString() - @Expose() - readonly bio?: string; - - @IsOptional() - @IsBoolean() - @Expose() - readonly hideBranding?: boolean; - - @IsBoolean() - @Expose() - readonly isOrganization?: boolean; - - @IsOptional() - @IsBoolean() - @Expose() - readonly isPrivate?: boolean; - - @IsOptional() - @IsBoolean() - @Expose() - readonly hideBookATeamMember?: boolean = false; - - @IsOptional() - @IsString() - @Expose() - readonly metadata?: string; - - @IsOptional() - @IsString() - @Expose() - readonly theme?: string; - - @IsOptional() - @IsString() - @Expose() - readonly brandColor?: string; - - @IsOptional() - @IsString() - @Expose() - readonly darkBrandColor?: string; - - @IsOptional() - @IsUrl() - @Expose() - readonly bannerUrl?: string; - - @IsOptional() - @IsString() - @Expose() - readonly timeFormat?: number; - - @IsOptional() - @IsString() - @Expose() - readonly timeZone?: string = "Europe/London"; - - @IsOptional() - @IsString() - @Expose() - readonly weekStart?: string = "Sunday"; -} +import { OrgTeamOutputDto } from "@calcom/platform-types"; export class OrgMeTeamOutputDto extends OrgTeamOutputDto { @IsString() diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts index b71d77fa21..252877e2c9 100644 --- a/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts @@ -16,6 +16,18 @@ export class OrganizationsEventTypesRepository { }); } + async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + teamId_slug: { + teamId, + slug: eventTypeSlug, + }, + }, + include: { users: true, schedule: true, hosts: true }, + }); + } + async getTeamEventTypes(teamId: number) { return this.dbRead.prisma.eventType.findMany({ where: { diff --git a/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts index 766cf64c42..119351739d 100644 --- a/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts +++ b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts @@ -2,12 +2,13 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_ import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository"; import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; import { UsersRepository } from "@/modules/users/users.repository"; -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { CreateTeamEventTypeInput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14, HostPriority, + SchedulingType, } from "@calcom/platform-types"; @Injectable() @@ -30,7 +31,9 @@ export class InputOrganizationsEventTypesService { const teamEventType = { ...eventType, - hosts: assignAllTeamMembers ? await this.getAllTeamMembers(teamId) : this.transformInputHosts(hosts), + hosts: assignAllTeamMembers + ? await this.getAllTeamMembers(teamId, inputEventType.schedulingType) + : this.transformInputHosts(hosts, inputEventType.schedulingType), assignAllTeamMembers, metadata, }; @@ -46,6 +49,11 @@ export class InputOrganizationsEventTypesService { const { hosts, assignAllTeamMembers, ...rest } = inputEventType; const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest); + const dbEventType = await this.orgEventTypesRepository.getTeamEventType(teamId, eventTypeId); + + if (!dbEventType) { + throw new BadRequestException("Event type to update not found"); + } const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId); const teamEventType = { @@ -53,8 +61,8 @@ export class InputOrganizationsEventTypesService { // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children hosts: !children ? assignAllTeamMembers - ? await this.getAllTeamMembers(teamId) - : this.transformInputHosts(hosts) + ? await this.getAllTeamMembers(teamId, dbEventType.schedulingType) + : this.transformInputHosts(hosts, dbEventType.schedulingType) : undefined, assignAllTeamMembers, children, @@ -118,28 +126,34 @@ export class InputOrganizationsEventTypesService { }); } - async getAllTeamMembers(teamId: number) { + async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) { const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId); + const isFixed = schedulingType === "COLLECTIVE" ? true : false; return membersIds.map((id) => ({ userId: id, - isFixed: false, + isFixed, priority: 2, })); } - transformInputHosts(inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) { + transformInputHosts( + inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined, + schedulingType: SchedulingType | null + ) { if (!inputHosts) { return undefined; } - const defaultMandatory = false; const defaultPriority = "medium"; + const defaultIsFixed = false; return inputHosts.map((host) => ({ userId: host.userId, - isFixed: host.mandatory || defaultMandatory, - priority: getPriorityValue(host.priority || defaultPriority), + isFixed: schedulingType === "COLLECTIVE" ? true : host.mandatory || defaultIsFixed, + priority: getPriorityValue( + schedulingType === "COLLECTIVE" ? "medium" : host.priority || defaultPriority + ), })); } } diff --git a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts index fccf357232..fabcdcdf6c 100644 --- a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts +++ b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts @@ -98,6 +98,19 @@ export class OrganizationsEventTypesService { return this.outputService.getResponseTeamEventType(eventType); } + async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) { + const eventType = await this.organizationEventTypesRepository.getTeamEventTypeBySlug( + teamId, + eventTypeSlug + ); + + if (!eventType) { + return null; + } + + return this.outputService.getResponseTeamEventType(eventType); + } + async getTeamEventTypes(teamId: number) { const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId); diff --git a/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts index df596aef00..ff764eb9a7 100644 --- a/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts +++ b/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts @@ -1,9 +1,11 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service"; import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable } from "@nestjs/common"; import type { EventType, User, Schedule, Host } from "@prisma/client"; -import { HostPriority } from "@calcom/platform-types"; +import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types"; +import { SchedulingType } from "@calcom/prisma/enums"; type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] }; type DatabaseEventType = EventType & EventTypeRelations; @@ -47,7 +49,8 @@ type Input = Pick< export class OutputOrganizationsEventTypesService { constructor( private readonly outputEventTypesService: OutputEventTypesService_2024_06_14, - private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository + private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository, + private readonly usersRepository: UsersRepository ) {} async getResponseTeamEventType(databaseEventType: Input) { @@ -60,7 +63,7 @@ export class OutputOrganizationsEventTypesService { const hosts = databaseEventType.schedulingType === "MANAGED" ? await this.getManagedEventTypeHosts(databaseEventType.id) - : this.transformHosts(databaseEventType.hosts); + : await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType); return { ...rest, @@ -74,25 +77,41 @@ export class OutputOrganizationsEventTypesService { async getManagedEventTypeHosts(eventTypeId: number) { const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId); - const hostsIds: number[] = []; + const transformedHosts: TeamEventTypeResponseHost[] = []; for (const child of children) { if (child.userId) { - hostsIds.push(child.userId); + const user = await this.usersRepository.findById(child.userId); + transformedHosts.push({ userId: child.userId, name: user?.name || "" }); } } - return hostsIds.map((userId) => ({ userId })); + return transformedHosts; } - transformHosts(hosts: Host[]) { - if (!hosts) return []; + async transformHosts( + databaseHosts: Host[], + schedulingType: SchedulingType | null + ): Promise { + if (!schedulingType) return []; - return hosts.map((host) => { - return { - userId: host.userId, - mandatory: host.isFixed, - priority: getPriorityLabel(host.priority || 2), - }; - }); + const transformedHosts: TeamEventTypeResponseHost[] = []; + const databaseUsers = await this.usersRepository.findByIds(databaseHosts.map((host) => host.userId)); + + for (const databaseHost of databaseHosts) { + const databaseUser = databaseUsers.find((u) => u.id === databaseHost.userId); + if (schedulingType === "ROUND_ROBIN") { + // note(Lauris): round robin is the only team event where mandatory (isFixed) and priority are used + transformedHosts.push({ + userId: databaseHost.userId, + name: databaseUser?.name || "", + mandatory: databaseHost.isFixed, + priority: getPriorityLabel(databaseHost.priority || 2), + }); + } else { + transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" }); + } + } + + return transformedHosts; } } diff --git a/apps/api/v2/src/modules/profiles/profiles.module.ts b/apps/api/v2/src/modules/profiles/profiles.module.ts new file mode 100644 index 0000000000..547ad3510e --- /dev/null +++ b/apps/api/v2/src/modules/profiles/profiles.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { ProfilesRepository } from "@/modules/profiles/profiles.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ProfilesRepository], + exports: [ProfilesRepository], +}) +export class ProfilesModule {} diff --git a/apps/api/v2/src/modules/profiles/profiles.repository.ts b/apps/api/v2/src/modules/profiles/profiles.repository.ts new file mode 100644 index 0000000000..c3ba8fc150 --- /dev/null +++ b/apps/api/v2/src/modules/profiles/profiles.repository.ts @@ -0,0 +1,20 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class ProfilesRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async getPlatformOwnerUserId(organizationId: number) { + const profile = await this.dbRead.prisma.profile.findFirst({ + where: { + organizationId, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return profile?.userId; + } +} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index a60c648de4..a4fa88daa4 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -90,6 +90,16 @@ export class UsersRepository { }); } + async findByIds(userIds: number[]) { + return this.dbRead.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + }); + } + async findByIdWithCalendars(userId: number) { return this.dbRead.prisma.user.findUnique({ where: { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 8d73215751..c821991eef 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -991,6 +991,57 @@ ] } }, + "/v2/organizations/{orgId}/teams/me": { + "get": { + "operationId": "OrganizationsTeamsController_getMyTeams", + "summary": "Get the organization's teams user is a member of", + "parameters": [ + { + "name": "orgId", + "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/OrgMeTeamsOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + } + }, "/v2/organizations/{orgId}/teams/{teamId}": { "get": { "operationId": "OrganizationsTeamsController_getTeam", @@ -1160,6 +1211,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" + } + } + } + }, "responses": { "201": { "description": "", @@ -1262,6 +1323,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" + } + } + } + }, "responses": { "200": { "description": "", @@ -2301,6 +2372,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15" + } + } + } + }, "responses": { "200": { "description": "", @@ -5100,6 +5181,29 @@ "data" ] }, + "OrgMeTeamsOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgTeamOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, "OrgTeamOutputResponseDto": { "type": "object", "properties": { @@ -5329,6 +5433,57 @@ "data" ] }, + "CreateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "One-on-one coaching" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome" + }, + "availability": { + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "09:00", + "endTime": "10:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true + }, + "overrides": { + "example": [ + { + "date": "2024-05-20", + "startTime": "12:00", + "endTime": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + }, + "required": [ + "name", + "timeZone", + "isDefault" + ] + }, "CreateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -5377,6 +5532,52 @@ "data" ] }, + "UpdateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "One-on-one coaching" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome" + }, + "availability": { + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "09:00", + "endTime": "10:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true + }, + "overrides": { + "example": [ + { + "date": "2024-05-20", + "startTime": "12:00", + "endTime": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + } + }, "UpdateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -6012,9 +6213,12 @@ "freq" ] }, - "Host": { + "TeamEventTypeResponseHost": { "type": "object", "properties": { + "name": { + "type": "string" + }, "userId": { "type": "number" }, @@ -6028,6 +6232,7 @@ } }, "required": [ + "name", "userId" ] }, @@ -6145,7 +6350,7 @@ "hosts": { "type": "array", "items": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/TeamEventTypeResponseHost" } }, "assignAllTeamMembers": { @@ -6467,6 +6672,26 @@ "userId" ] }, + "GetDefaultScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "required": [ + "status", + "data" + ] + }, "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": { @@ -6764,6 +6989,66 @@ "data" ] }, + "UpdateScheduleInput_2024_04_15": { + "type": "object", + "properties": { + "timeZone": { + "type": "string" + }, + "name": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "schedule": { + "example": [ + [], + [ + { + "start": "2022-01-01T00:00:00.000Z", + "end": "2022-01-02T00:00:00.000Z" + } + ], + [], + [], + [], + [], + [] + ], + "items": { + "type": "array" + }, + "type": "array" + }, + "dateOverrides": { + "example": [ + [], + [ + { + "start": "2022-01-01T00:00:00.000Z", + "end": "2022-01-02T00:00:00.000Z" + } + ], + [], + [], + [], + [], + [] + ], + "items": { + "type": "array" + }, + "type": "array" + } + }, + "required": [ + "timeZone", + "name", + "isDefault", + "schedule" + ] + }, "EventTypeModel_2024_04_15": { "type": "object", "properties": { @@ -7033,6 +7318,10 @@ }, "timeZone": { "type": "string" + }, + "organizationId": { + "type": "number", + "nullable": true } }, "required": [ @@ -7042,7 +7331,8 @@ "timeFormat", "defaultScheduleId", "weekStart", - "timeZone" + "timeZone", + "organizationId" ] }, "GetMeOutput": { diff --git a/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx b/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx index 27c4e0e5bb..e8b5a9bba7 100644 --- a/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx +++ b/apps/web/components/settings/platform/dashboard/oauth-clients-list/index.tsx @@ -46,6 +46,7 @@ export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAu isLoading={isDeleting} onDelete={handleDelete} areEmailsEnabled={client.areEmailsEnabled} + organizationId={client.organizationId} /> ); })} diff --git a/apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx b/apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx index 1c658eb7ac..54a3410cf7 100644 --- a/apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx +++ b/apps/web/components/settings/platform/oauth-clients/OAuthClientCard.tsx @@ -22,6 +22,7 @@ type OAuthClientCardProps = { secret: string; onDelete: (id: string) => Promise; isLoading: boolean; + organizationId: number; }; export const OAuthClientCard = ({ @@ -38,6 +39,7 @@ export const OAuthClientCard = ({ onDelete, isLoading, areEmailsEnabled, + organizationId, }: OAuthClientCardProps) => { const router = useRouter(); @@ -113,6 +115,21 @@ export const OAuthClientCard = ({ /> +
+
+
Organization Id:
+
{organizationId}
+ { + navigator.clipboard.writeText(organizationId.toString()); + showToast("Organization id copied to clipboard.", "success"); + }} + /> +
+
Permissions: {permissions ?
{clientPermissions}
: <> Disabled} diff --git a/apps/web/pages/api/auth/verify-email.test.ts b/apps/web/pages/api/auth/verify-email.test.ts index 12532aff89..b278708744 100644 --- a/apps/web/pages/api/auth/verify-email.test.ts +++ b/apps/web/pages/api/auth/verify-email.test.ts @@ -18,7 +18,7 @@ describe("moveUserToMatchingOrg", () => { }); it("should not proceed if no matching organization is found", async () => { - organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch(); + organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch(); await moveUserToMatchingOrg({ email }); @@ -44,7 +44,7 @@ describe("moveUserToMatchingOrg", () => { requestedSlug: "requested-test-org", }; - organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization( + organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization( org, { email } ); @@ -65,7 +65,7 @@ describe("moveUserToMatchingOrg", () => { requestedSlug: "requested-test-org", }; - organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization( + organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization( org, { email } ); diff --git a/apps/web/pages/api/auth/verify-email.ts b/apps/web/pages/api/auth/verify-email.ts index 0405c3226b..c090e1de54 100644 --- a/apps/web/pages/api/auth/verify-email.ts +++ b/apps/web/pages/api/auth/verify-email.ts @@ -19,7 +19,7 @@ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email"; // TODO: To be unit tested export async function moveUserToMatchingOrg({ email }: { email: string }) { - const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email }); + const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email }); if (!org) { return; diff --git a/packages/lib/server/repository/__mocks__/organization.ts b/packages/lib/server/repository/__mocks__/organization.ts index 16bac666dd..3ac1c60cbd 100644 --- a/packages/lib/server/repository/__mocks__/organization.ts +++ b/packages/lib/server/repository/__mocks__/organization.ts @@ -14,20 +14,22 @@ const OrganizationRepository = organizationMock.OrganizationRepository; export const organizationScenarios = { OrganizationRepository: { - findUniqueByMatchingAutoAcceptEmail: { + findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail: { // eslint-disable-next-line @typescript-eslint/no-explicit-any fakeReturnOrganization: (org: any, forInput: any) => { - OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockImplementation((arg) => { - if (forInput.email === arg.email) { - return org; + OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.mockImplementation( + (arg) => { + if (forInput.email === arg.email) { + return org; + } + const errorMsg = "Mock Error-fakeReturnOrganization: Unhandled input"; + console.log(errorMsg, { arg, forInput }); + throw new Error(errorMsg); } - const errorMsg = "Mock Error-fakeReturnOrganization: Unhandled input"; - console.log(errorMsg, { arg, forInput }); - throw new Error(errorMsg); - }); + ); }, fakeNoMatch: () => { - OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockResolvedValue(null); + OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.mockResolvedValue(null); }, }, } satisfies Partial>, diff --git a/packages/lib/server/repository/organization.test.ts b/packages/lib/server/repository/organization.test.ts index 3038858e14..8f4378b9aa 100644 --- a/packages/lib/server/repository/organization.test.ts +++ b/packages/lib/server/repository/organization.test.ts @@ -8,7 +8,7 @@ vi.mock("./teamUtils", () => ({ getParsedTeam: (org: any) => org, })); -describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { +describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () => { beforeEach(async () => { vi.resetAllMocks(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -17,7 +17,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { }); it("should return null if no organization matches the email domain", async () => { - const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com", }); @@ -29,7 +29,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { await createReviewedOrganization({ name: "Test Org 2", orgAutoAcceptEmail: "example.com" }); await expect( - OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email: "test@example.com" }) + OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com" }) ).rejects.toThrow("Multiple organizations found with the same auto accept email domain"); }); @@ -39,7 +39,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { orgAutoAcceptEmail: "example.com", }); - const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com", }); @@ -49,7 +49,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { it("should not confuse a team with organization", async () => { await createTeam({ name: "Test Team", orgAutoAcceptEmail: "example.com" }); - const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com", }); @@ -59,7 +59,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => { it("should correctly match orgAutoAcceptEmail", async () => { await createReviewedOrganization({ name: "Test Org", orgAutoAcceptEmail: "noexample.com" }); - const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ + const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com", }); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 3bb8ade09f..aba6247cba 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -165,11 +165,12 @@ export class OrganizationRepository { }); } - static async findUniqueByMatchingAutoAcceptEmail({ email }: { email: string }) { + static async findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email }: { email: string }) { const emailDomain = email.split("@").at(-1); const orgs = await prisma.team.findMany({ where: { isOrganization: true, + isPlatform: false, organizationSettings: { orgAutoAcceptEmail: emailDomain, isOrganizationVerified: true, diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 202e313c7c..2df13a5e52 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -22,8 +22,12 @@ import type { } from "@calcom/platform-types"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; -import { transformApiEventTypeForAtom } from "../event-types/atom-api-transformers/transformApiEventTypeForAtom"; +import { + transformApiEventTypeForAtom, + transformApiTeamEventTypeForAtom, +} from "../event-types/atom-api-transformers/transformApiEventTypeForAtom"; import { useEventType } from "../hooks/event-types/public/useEventType"; +import { useTeamEventType } from "../hooks/event-types/public/useTeamEventType"; import { useAtomsContext } from "../hooks/useAtomsContext"; import { useAvailableSlots } from "../hooks/useAvailableSlots"; import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes"; @@ -44,7 +48,7 @@ import { AtomsWrapper } from "../src/components/atoms-wrapper"; export type BookerPlatformWrapperAtomProps = Omit & { rescheduleUid?: string; bookingUid?: string; - username: string | string[]; + username: string | string[] | undefined; entity?: BookerProps["entity"]; // values for the booking form and booking fields defaultFormValues?: { @@ -68,6 +72,7 @@ export type BookerPlatformWrapperAtomProps = Omit void; onDeleteSlotError?: (data: ApiErrorResponse) => void; locationUrl?: string; + teamId?: number; }; export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => { @@ -88,7 +93,10 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => }); const queryClient = useQueryClient(); const username = useMemo(() => { - return formatUsername(props.username); + if (props.username) { + return formatUsername(props.username); + } + return ""; }, [props.username]); setSelectedDuration(props.duration ?? null); @@ -98,16 +106,45 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => return getUsernameList(username ?? "").length > 1; }, [username]); - const { isSuccess, isError, isPending, data } = useEventType(username, props.eventSlug); + const { isSuccess, isError, isPending, data } = useEventType(username, props.eventSlug, props.isTeamEvent); + const { + isSuccess: isTeamSuccess, + isError: isTeamError, + isPending: isTeamPending, + data: teamData, + } = useTeamEventType(props.teamId, props.eventSlug, props.isTeamEvent); const event = useMemo(() => { + if (props.isTeamEvent) { + return { + isSuccess: isTeamSuccess, + isError: isTeamError, + isPending: isTeamPending, + data: + teamData && teamData.length > 0 + ? transformApiTeamEventTypeForAtom(teamData[0], props.entity) + : undefined, + }; + } + return { isSuccess, isError, isPending, data: data && data.length > 0 ? transformApiEventTypeForAtom(data[0], props.entity) : undefined, }; - }, [isSuccess, isError, isPending, data, props.entity]); + }, [ + props.isTeamEvent, + props.entity, + isSuccess, + isError, + isPending, + data, + isTeamSuccess, + isTeamError, + isTeamPending, + teamData, + ]); if (isDynamic && props.duration && event.data) { // note(Lauris): Mandatory - In case of "dynamic" event type default event duration returned by the API is 30, @@ -188,7 +225,7 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => // Should only wait for one or the other, not both. (Boolean(eventSlug) || Boolean(event?.data?.id) || event?.data?.id === 0), orgSlug: props.entity?.orgSlug ?? undefined, - eventTypeSlug: isDynamic ? "dynamic" : undefined, + eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "", }); const bookerForm = useBookingForm({ diff --git a/packages/platform/atoms/cal-provider/BaseCalProvider.tsx b/packages/platform/atoms/cal-provider/BaseCalProvider.tsx index c76ece1178..95e8a58fe9 100644 --- a/packages/platform/atoms/cal-provider/BaseCalProvider.tsx +++ b/packages/platform/atoms/cal-provider/BaseCalProvider.tsx @@ -9,6 +9,7 @@ import frTranslations from "@calcom/web/public/static/locales/fr/common.json"; import ptBrTranslations from "@calcom/web/public/static/locales/pt-BR/common.json"; import { AtomsContext } from "../hooks/useAtomsContext"; +import { useMe } from "../hooks/useMe"; import { useOAuthClient } from "../hooks/useOAuthClient"; import { useOAuthFlow } from "../hooks/useOAuthFlow"; import { useTimezone } from "../hooks/useTimezone"; @@ -38,6 +39,7 @@ export function BaseCalProvider({ onTimezoneChange, }: CalProviderProps) { const [error, setError] = useState(""); + const { data: me } = useMe(); const { mutateAsync } = useUpdateUserTimezone(); @@ -117,6 +119,7 @@ export function BaseCalProvider({ isInit: isInit, isValidClient: Boolean(!error && clientId && isInit), isAuth: Boolean(isInit && !error && clientId && currentAccessToken && http.getAuthorizationHeader()), + organizationId: me?.data.organizationId || 0, ...translations, }}> {children} @@ -134,6 +137,7 @@ export function BaseCalProvider({ isInit: false, isRefreshing: false, ...translations, + organizationId: 0, }}> <> {children} diff --git a/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts b/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts index bd71610c5f..708dcbaa1a 100644 --- a/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts +++ b/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts @@ -7,7 +7,7 @@ import { transformApiEventTypeBookingFields, } from "@calcom/lib/event-types/transformers"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; -import type { EventTypeOutput_2024_06_14 } from "@calcom/platform-types"; +import type { EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; import { bookerLayoutOptions, BookerLayouts, @@ -91,6 +91,91 @@ export function transformApiEventTypeForAtom( }; } +export function transformApiTeamEventTypeForAtom( + eventType: TeamEventTypeOutput_2024_06_14, + entity: BookerProps["entity"] | undefined +) { + const { lengthInMinutes, locations, hosts, bookingFields, ...rest } = eventType; + + const isDefault = isDefaultEvent(rest.title); + + const defaultEventBookerLayouts = { + enabledLayouts: [...bookerLayoutOptions], + defaultLayout: BookerLayouts.MONTH_VIEW, + }; + const firstUsersMetadata = userMetadataSchema.parse({}); + const bookerLayouts = bookerLayoutsSchema.parse( + firstUsersMetadata?.defaultBookerLayouts || defaultEventBookerLayouts + ); + + return { + ...rest, + length: lengthInMinutes, + locations: getLocations(locations), + bookingFields: getBookingFields(bookingFields), + isDefault, + isDynamic: false, + profile: { + username: "team", + name: "team", + weekStart: "Sunday", + image: "", + brandColor: null, + darkBrandColor: null, + theme: null, + bookerLayouts, + }, + entity: entity + ? { + ...entity, + orgSlug: entity.orgSlug || null, + teamSlug: entity.teamSlug || null, + fromRedirectOfNonOrgLink: true, + name: entity.name || null, + logoUrl: entity.logoUrl || undefined, + } + : { + fromRedirectOfNonOrgLink: true, + considerUnpublished: false, + orgSlug: null, + teamSlug: null, + name: null, + logoUrl: undefined, + }, + hosts: hosts.map((host) => ({ + user: { + id: host.userId, + avatarUrl: null, + name: host.name, + username: "", + metadata: {}, + darkBrandColor: null, + brandColor: null, + theme: null, + weekStart: "Sunday", + }, + })), + users: hosts.map((host) => ({ + metadata: undefined, + bookerUrl: getBookerBaseUrlSync(null), + profile: { + username: "", + name: host.name, + weekStart: "Sunday", + image: "", + brandColor: null, + darkBrandColor: null, + theme: null, + organization: null, + id: host.userId, + organizationId: null, + userId: host.userId, + upId: `usr-${host.userId}`, + }, + })), + }; +} + function isDefaultEvent(eventSlug: string) { const foundInDefaults = defaultEvents.find((obj) => { return obj.slug === eventSlug; diff --git a/packages/platform/atoms/hooks/event-types/public/useEventType.ts b/packages/platform/atoms/hooks/event-types/public/useEventType.ts index fe4dcda1db..8cc49b0044 100644 --- a/packages/platform/atoms/hooks/event-types/public/useEventType.ts +++ b/packages/platform/atoms/hooks/event-types/public/useEventType.ts @@ -13,7 +13,7 @@ import http from "../../../lib/http"; export const QUERY_KEY = "use-event-type"; export type UsePublicEventReturnType = ReturnType; -export const useEventType = (username: string, eventSlug: string) => { +export const useEventType = (username: string, eventSlug: string, isTeamEvent: boolean | undefined) => { const [stateUsername, stateEventSlug] = useBookerStore( (state) => [state.username, state.eventSlug], shallow @@ -29,6 +29,10 @@ export const useEventType = (username: string, eventSlug: string) => { const event = useQuery({ queryKey: [QUERY_KEY, stateUsername ?? username, stateEventSlug ?? eventSlug], queryFn: async () => { + if (isTeamEvent) { + return; + } + if (isDynamic) { return http .get>( diff --git a/packages/platform/atoms/hooks/event-types/public/useTeamEventType.ts b/packages/platform/atoms/hooks/event-types/public/useTeamEventType.ts new file mode 100644 index 0000000000..0750b2af44 --- /dev/null +++ b/packages/platform/atoms/hooks/event-types/public/useTeamEventType.ts @@ -0,0 +1,41 @@ +import { useQuery } from "@tanstack/react-query"; +import { shallow } from "zustand/shallow"; + +import { useBookerStore } from "@calcom/features/bookings/Booker/store"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiSuccessResponse, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; +import type { ApiResponse } from "@calcom/platform-types"; + +import http from "../../../lib/http"; +import { useAtomsContext } from "../../useAtomsContext"; + +export const QUERY_KEY = "use-team-event-type"; + +export const useTeamEventType = (teamId: number | undefined, eventSlug: string, isTeamEvent: boolean | undefined) => { + const { organizationId } = useAtomsContext(); + + const [stateEventSlug] = useBookerStore( + (state) => [state.eventSlug], + shallow + ); + + const requestEventSlug = stateEventSlug ?? eventSlug; + + const pathname = `/organizations/${organizationId}/teams/${teamId}/event-types?eventSlug=${requestEventSlug}`; + + const event = useQuery({ + queryKey: [QUERY_KEY, eventSlug, organizationId, teamId], + queryFn: async () => { + if(organizationId && teamId && eventSlug && isTeamEvent) { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + } + }, + }); + + return event; +}; diff --git a/packages/platform/atoms/hooks/event-types/public/useTeamEventTypes.ts b/packages/platform/atoms/hooks/event-types/public/useTeamEventTypes.ts new file mode 100644 index 0000000000..6ba0ec0ccb --- /dev/null +++ b/packages/platform/atoms/hooks/event-types/public/useTeamEventTypes.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types"; +import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +import http from "../../../lib/http"; +import { useAtomsContext } from "../../useAtomsContext"; + +export const QUERY_KEY = "use-team-event-types"; +export const useTeamEventTypes = (teamId: number) => { + const { organizationId } = useAtomsContext(); + const pathname = `/organizations/${organizationId}/teams/${teamId}/event-types`; + + return useQuery({ + queryKey: [QUERY_KEY, organizationId, teamId], + queryFn: () => { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: !!organizationId && !!teamId, + }); +}; diff --git a/packages/platform/atoms/hooks/teams/useTeams.ts b/packages/platform/atoms/hooks/teams/useTeams.ts new file mode 100644 index 0000000000..8815869a85 --- /dev/null +++ b/packages/platform/atoms/hooks/teams/useTeams.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { ApiResponse } from "@calcom/platform-types"; +import type { OrgTeamOutputDto } from "@calcom/platform-types"; + +import http from "../../lib/http"; +import { useAtomsContext } from "../useAtomsContext"; + +export const QUERY_KEY = "use-teams"; + +export const useTeams = () => { + const { organizationId } = useAtomsContext(); + + const pathname = `/organizations/${organizationId}/teams/me`; + + const event = useQuery({ + queryKey: [QUERY_KEY, organizationId], + queryFn: async () => { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: !!organizationId, + }); + + return event; +}; diff --git a/packages/platform/atoms/hooks/useAtomsContext.ts b/packages/platform/atoms/hooks/useAtomsContext.ts index 1cbf0113ec..bfd5d942b8 100644 --- a/packages/platform/atoms/hooks/useAtomsContext.ts +++ b/packages/platform/atoms/hooks/useAtomsContext.ts @@ -26,11 +26,13 @@ export interface IAtomsContext { locales: CalProviderLanguagesType[]; exists: (key: translationKeys | string) => boolean; }; + organizationId: number; } export const AtomsContext = createContext({ clientId: "", accessToken: "", + organizationId: 0, options: { refreshUrl: "", apiUrl: "" }, error: "", getClient: () => { diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 08ac93f2f3..272ff8098c 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -6,6 +6,7 @@ export { useIsPlatform } from "./hooks/useIsPlatform"; export { useAtomsContext } from "./hooks/useAtomsContext"; export { useConnectedCalendars } from "./hooks/useConnectedCalendars"; export { useEventTypes } from "./hooks/event-types/public/useEventTypes"; +export { useTeamEventTypes } from "./hooks/event-types/public/useTeamEventTypes"; export { useEventType as useEvent } from "./hooks/event-types/public/useEventType"; export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById"; export { useCancelBooking } from "./hooks/useCancelBooking"; @@ -18,3 +19,4 @@ export { BookerEmbed } from "./booker-embed"; export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials"; export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar"; export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar"; +export { useTeams } from "./hooks/teams/useTeams"; diff --git a/packages/platform/examples/base/.env.example b/packages/platform/examples/base/.env.example index c076b38537..6af644be70 100644 --- a/packages/platform/examples/base/.env.example +++ b/packages/platform/examples/base/.env.example @@ -1,3 +1,4 @@ NEXT_PUBLIC_X_CAL_ID="" X_CAL_SECRET_KEY="" NEXT_PUBLIC_CALCOM_API_URL="http://localhost:5555/api/v2" +ORGANIZATION_ID= \ No newline at end of file diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx index 813e101f3a..fa67e54067 100644 --- a/packages/platform/examples/base/src/pages/_app.tsx +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -26,6 +26,9 @@ function generateRandomEmail() { return `${randomLocalPart}@${randomDomain}`; } +// note(Lauris): needed because useEffect kicks in twice creating 2 parallel requests +let seeding = false; + export default function App({ Component, pageProps }: AppProps) { const [accessToken, setAccessToken] = useState(""); const [email, setUserEmail] = useState(""); @@ -47,16 +50,21 @@ export default function App({ Component, pageProps }: AppProps) { }, []); useEffect(() => { - const randomEmail = generateRandomEmail(); - fetch("/api/managed-user", { - method: "POST", - body: JSON.stringify({ email: randomEmail }), - }).then(async (res) => { - const data = await res.json(); - setAccessToken(data.accessToken); - setUserEmail(data.email); - setUsername(data.username); - }); + const randomEmailOne = generateRandomEmail(); + const randomEmailTwo = generateRandomEmail(); + if (!seeding) { + seeding = true; + fetch("/api/managed-user", { + method: "POST", + + body: JSON.stringify({ emails: [randomEmailOne, randomEmailTwo] }), + }).then(async (res) => { + const data = await res.json(); + setAccessToken(data.accessToken); + setUserEmail(data.email); + setUsername(data.username); + }); + } }, []); useEffect(() => { if (!!selectedUser) { diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts index 3de7389de1..d28a03fdd2 100644 --- a/packages/platform/examples/base/src/pages/api/managed-user.ts +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -1,7 +1,7 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import type { NextApiRequest, NextApiResponse } from "next"; -import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; import prisma from "../../lib/prismaClient"; @@ -14,7 +14,9 @@ type Data = { // example endpoint to create a managed cal.com user export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { email } = JSON.parse(req.body); + const { emails } = JSON.parse(req.body); + const emailOne = emails[0]; + const emailTwo = emails[1]; const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } }); if (existingUser && existingUser.calcomUserId) { @@ -25,11 +27,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< accessToken: existingUser.accessToken ?? "", }); } - const localUser = await prisma.user.create({ + + const localUserOne = await prisma.user.create({ data: { - email, + email: emailOne, }, }); + + const localUserTwo = await prisma.user.create({ + data: { + email: emailTwo, + }, + }); + const response = await fetch( // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, @@ -42,11 +52,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< origin: "http://localhost:4321", }, body: JSON.stringify({ - email, + email: emailOne, name: "John Jones", }), } ); + const body = await response.json(); await prisma.user.update({ data: { @@ -55,9 +66,55 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< calcomUserId: body.data?.user.id, calcomUsername: (body.data?.user.username as string) ?? "", }, - where: { id: localUser.id }, + where: { id: localUserOne.id }, }); + + const responseTwo = await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + email: emailTwo, + name: "Jane Doe", + }), + } + ); + const bodyTwo = await responseTwo.json(); + await prisma.user.update({ + data: { + refreshToken: (bodyTwo.data?.refreshToken as string) ?? "", + accessToken: (bodyTwo.data?.accessToken as string) ?? "", + calcomUserId: bodyTwo.data?.user.id, + calcomUsername: (bodyTwo.data?.user.username as string) ?? "", + }, + where: { id: localUserTwo.id }, + }); + await createDefaultSchedule(body.data?.accessToken as string); + await createDefaultSchedule(bodyTwo.data?.accessToken as string); + + // eslint-disable-next-line turbo/no-undeclared-env-vars + const organizationId = process.env.ORGANIZATION_ID; + if (!organizationId) { + throw new Error("Organization ID is not set"); + } + + const team = await createTeam(+organizationId, "Team Doe"); + if (!team) { + throw new Error("Failed to create team. Probably your platform team does not have required plan."); + } + + await createMembership(+organizationId, team.id, body.data?.user.id); + await createMembership(+organizationId, team.id, bodyTwo.data?.user.id); + await createCollectiveEventType(+organizationId, team.id, [body.data?.user.id, bodyTwo.data?.user.id]); + return res.status(200).json({ id: body?.data?.user?.id, email: (body.data?.user.email as string) ?? "", @@ -66,6 +123,77 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }); } +async function createTeam(orgId: number, name: string) { + const response = await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + name, + }), + } + ); + + const body = await response.json(); + return body.data; +} + +async function createMembership(orgId: number, teamId: number, userId: number) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + userId, + accepted: true, + }), + } + ); +} + +async function createCollectiveEventType(orgId: number, teamId: number, userIds: number[]) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + lengthInMinutes: 60, + title: "Doe collective", + slug: "doe-collective", + schedulingType: "COLLECTIVE", + hosts: userIds.map((userId) => ({ userId })), + }), + } + ); +} + async function createDefaultSchedule(accessToken: string) { const name = "Default Schedule"; const timeZone = "Europe/London"; diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index 2ba49a5602..0245bb9f67 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -4,7 +4,7 @@ import { Inter } from "next/font/google"; import { useRouter } from "next/router"; import { useState } from "react"; -import { Booker, useEventTypes } from "@calcom/atoms"; +import { Booker, useEventTypes, useTeamEventTypes, useTeams } from "@calcom/atoms"; const inter = Inter({ subsets: ["latin"] }); @@ -12,8 +12,11 @@ export default function Bookings(props: { calUsername: string; calEmail: string const [bookingTitle, setBookingTitle] = useState(null); const [eventTypeSlug, setEventTypeSlug] = useState(null); const [eventTypeDuration, setEventTypeDuration] = useState(null); + const [isTeamEvent, setIsTeamEvent] = useState(false); const router = useRouter(); const { isLoading: isLoadingEvents, data: eventTypes } = useEventTypes(props.calUsername); + const { data: teams } = useTeams(); + const { isLoading: isLoadingTeamEvents, data: teamEventTypes } = useTeamEventTypes(teams?.[0]?.id || 0); const rescheduleUid = (router.query.rescheduleUid as string) ?? ""; const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? ""; @@ -28,6 +31,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string {!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && !rescheduleUid && (
+

User event types

{eventTypes?.map( (event: { id: number; slug: string; title: string; lengthInMinutes: number }) => { const formatEventSlug = event.slug @@ -40,6 +44,36 @@ export default function Bookings(props: { calUsername: string; calEmail: string onClick={() => { setEventTypeSlug(event.slug); setEventTypeDuration(event.lengthInMinutes); + setIsTeamEvent(false); + }} + className="mx-10 w-[80vw] cursor-pointer rounded-md border-[0.8px] border-black px-10 py-4" + key={event.id}> +

{formatEventSlug}

+

{`/${event.slug}`}

+ {event?.lengthInMinutes} +
+ ); + } + )} +
+ )} + + {!isLoadingTeamEvents && !eventTypeSlug && Boolean(teamEventTypes?.length) && !rescheduleUid && ( +
+

Team event types

+ {teamEventTypes?.map( + (event: { id: number; slug: string; title: string; lengthInMinutes: number }) => { + const formatEventSlug = event.slug + .split("-") + .map((item) => `${item[0].toLocaleUpperCase()}${item.slice(1)}`) + .join(" "); + + return ( +
{ + setEventTypeSlug(event.slug); + setEventTypeDuration(event.lengthInMinutes); + setIsTeamEvent(true); }} className="mx-10 w-[80vw] cursor-pointer rounded-md border-[0.8px] border-black px-10 py-4" key={event.id}> @@ -54,28 +88,33 @@ export default function Bookings(props: { calUsername: string; calEmail: string )} {!bookingTitle && eventTypeSlug && !rescheduleUid && ( - { - setBookingTitle(data.data.title ?? ""); - router.push(`/${data.data.uid}`); - }} - duration={eventTypeDuration} - customClassNames={{ - bookerContainer: "!bg-[#F5F2FE] [&_button:!rounded-full] border-subtle border", - datePickerCustomClassNames: { - datePickerDatesActive: "!bg-[#D7CEF5]", - }, - eventMetaCustomClassNames: { - eventMetaTitle: "text-[#7151DC]", - }, - availableTimeSlotsCustomClassNames: { - availableTimeSlotsHeaderContainer: "!bg-[#F5F2FE]", - availableTimes: "!bg-[#D7CEF5]", - }, - }} - /> + <> +

{eventTypeSlug}

+ { + setBookingTitle(data.data.title ?? ""); + router.push(`/${data.data.uid}`); + }} + teamId={teams?.[0]?.id || 0} + isTeamEvent={isTeamEvent} + duration={eventTypeDuration} + customClassNames={{ + bookerContainer: "!bg-[#F5F2FE] [&_button:!rounded-full] border-subtle border", + datePickerCustomClassNames: { + datePickerDatesActive: "!bg-[#D7CEF5]", + }, + eventMetaCustomClassNames: { + eventMetaTitle: "text-[#7151DC]", + }, + availableTimeSlotsCustomClassNames: { + availableTimeSlotsHeaderContainer: "!bg-[#F5F2FE]", + availableTimes: "!bg-[#D7CEF5]", + }, + }} + /> + )} {!bookingTitle && rescheduleUid && eventTypeSlugQueryParam && ( { if (typeof value === "string") { diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts index 345ea9c203..fbf2ae0266 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts @@ -12,7 +12,7 @@ import { } from "class-validator"; import type { Location_2024_06_14, BookingField_2024_06_14 } from "../inputs"; -import { Host } from "../inputs"; +import { Host as TeamEventTypeHostInput } from "../inputs"; import { RecurringEvent_2024_06_14 } from "../inputs"; import { ValidateBookingFields_2024_06_14 } from "../inputs/booking-fields.input"; import { ValidateLocations_2024_06_14 } from "../inputs/locations.input"; @@ -139,6 +139,11 @@ export class EventTypeOutput_2024_06_14 { scheduleId!: number | null; } +export class TeamEventTypeResponseHost extends TeamEventTypeHostInput { + @IsString() + name!: string; +} + export class TeamEventTypeOutput_2024_06_14 { @IsInt() @DocsProperty({ example: 1 }) @@ -235,9 +240,9 @@ export class TeamEventTypeOutput_2024_06_14 { parentEventTypeId?: number | null; @ValidateNested({ each: true }) - @Type(() => Host) + @Type(() => TeamEventTypeResponseHost) @IsArray() - hosts!: Host[]; + hosts!: TeamEventTypeResponseHost[]; @IsBoolean() @IsOptional() diff --git a/packages/platform/types/index.ts b/packages/platform/types/index.ts index e148e7eb29..2628472372 100644 --- a/packages/platform/types/index.ts +++ b/packages/platform/types/index.ts @@ -7,3 +7,4 @@ export * from "./bookings"; export * from "./billing"; export * from "./schedules"; export * from "./event-types"; +export * from "./organizations"; diff --git a/packages/platform/types/oauth-clients.ts b/packages/platform/types/oauth-clients.ts index 1d3aab0663..9666ae8945 100644 --- a/packages/platform/types/oauth-clients.ts +++ b/packages/platform/types/oauth-clients.ts @@ -46,6 +46,7 @@ export const userSchemaResponse = z.object({ weekStart: z.string(), timeZone: z.string().default("Europe/London"), username: z.string(), + organizationId: z.number().nullable(), }); export type UserResponse = z.infer; diff --git a/packages/platform/types/organizations/index.ts b/packages/platform/types/organizations/index.ts new file mode 100644 index 0000000000..9a4630082b --- /dev/null +++ b/packages/platform/types/organizations/index.ts @@ -0,0 +1 @@ +export * from "./teams"; diff --git a/packages/platform/types/organizations/teams/index.ts b/packages/platform/types/organizations/teams/index.ts new file mode 100644 index 0000000000..fb53cfa187 --- /dev/null +++ b/packages/platform/types/organizations/teams/index.ts @@ -0,0 +1 @@ +export * from "./outputs"; diff --git a/packages/platform/types/organizations/teams/outputs/index.ts b/packages/platform/types/organizations/teams/outputs/index.ts new file mode 100644 index 0000000000..919d6058c9 --- /dev/null +++ b/packages/platform/types/organizations/teams/outputs/index.ts @@ -0,0 +1 @@ +export * from "./team.output"; diff --git a/packages/platform/types/organizations/teams/outputs/team.output.ts b/packages/platform/types/organizations/teams/outputs/team.output.ts new file mode 100644 index 0000000000..d3cac09e70 --- /dev/null +++ b/packages/platform/types/organizations/teams/outputs/team.output.ts @@ -0,0 +1,107 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class OrgTeamOutputDto { + @IsInt() + @Expose() + readonly id!: number; + + @IsInt() + @IsOptional() + @Expose() + readonly parentId?: number; + + @IsString() + @Length(1) + @Expose() + readonly name!: string; + + @IsOptional() + @IsString() + @Expose() + readonly slug?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @Expose() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @Expose() + readonly hideBranding?: boolean; + + @IsBoolean() + @Expose() + readonly isOrganization?: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + readonly hideBookATeamMember?: boolean = false; + + @IsOptional() + @IsString() + @Expose() + readonly metadata?: string; + + @IsOptional() + @IsString() + @Expose() + readonly theme?: string; + + @IsOptional() + @IsString() + @Expose() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @Expose() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @Expose() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @Expose() + readonly timeZone?: string = "Europe/London"; + + @IsOptional() + @IsString() + @Expose() + readonly weekStart?: string = "Sunday"; +}