From 59ab38db9862bd0da2ecc1f050fad9d87b506484 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 17 Apr 2025 22:06:31 +0530 Subject: [PATCH] feat: add guards to stripe teams controller (#20540) * feat: add guards to stripe teams controller * remove logs and comments * fix return type status * refactor: move PlatformSubscription to a dedicated module * reroute to `organizations/stripe/save` for teams * fix: type errors * feat: fixed it for conferencing apps * feat: Add error handling and fallback URL support in Stripe callback * Refactor OAuth callback handling and move token validation to service layer * Add documentation for OAuth callback proxying in conferencing and stripe controllers * Move OAuthCallbackState type from organizations to stripe service module --- apps/api/v2/package.json | 1 + apps/api/v2/src/modules/auth/auth.module.ts | 32 +-- .../conferencing/conferencing.module.ts | 6 +- .../controllers/conferencing.controller.ts | 48 ++-- .../services/conferencing.service.ts | 14 +- .../organizations-conferencing.controller.ts | 44 +++- .../organizations-conferencing.module.ts | 46 ++++ .../organizations-conferencing.service.ts | 168 +----------- .../organizations/organizations.module.ts | 32 ++- .../stripe/organizations-stripe.controller.ts | 160 ++++++++++++ .../stripe/organizations-stripe.module.ts | 29 +++ .../services/organizations-stripe.service.ts | 83 ++++++ .../stripe/controllers/stripe.controller.ts | 107 +++++--- .../modules/stripe/outputs/stripe.output.ts | 2 +- .../v2/src/modules/stripe/stripe.module.ts | 3 +- .../v2/src/modules/stripe/stripe.service.ts | 64 ++--- .../event-types/teams-event-types.module.ts | 10 +- apps/api/v2/swagger/documentation.json | 239 +++++++++++++++--- .../bookings/views/bookings-single-view.tsx | 6 +- docs/api-reference/v2/openapi.json | 229 ++++++++++++++--- .../atoms/connect/apple/AppleConnect.tsx | 2 +- .../atoms/connect/stripe/StripeConnect.tsx | 18 +- .../EventPaymentsTabPlatformWrapper.tsx | 12 +- .../platform/atoms/hooks/stripe/useCheck.ts | 73 ++---- .../platform/atoms/hooks/stripe/useConnect.ts | 49 ++-- .../base/src/pages/conferencing-apps.tsx | 2 +- packages/prisma/zod-utils.ts | 2 - yarn.lock | 12 + 28 files changed, 999 insertions(+), 494 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts create mode 100644 apps/api/v2/src/modules/organizations/stripe/organizations-stripe.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/stripe/organizations-stripe.module.ts create mode 100644 apps/api/v2/src/modules/organizations/stripe/services/organizations-stripe.service.ts diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 4e8f0fbb26..b052979d35 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -46,6 +46,7 @@ "@golevelup/ts-jest": "^0.4.0", "@microsoft/microsoft-graph-types-beta": "^0.42.0-preview", "@nest-lab/throttler-storage-redis": "1.0.0", + "@nestjs/axios": "^4.0.0", "@nestjs/bull": "^10.1.1", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts index d78b41404b..eea576c84e 100644 --- a/apps/api/v2/src/modules/auth/auth.module.ts +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -1,22 +1,16 @@ import { ApiKeysModule } from "@/modules/api-keys/api-keys.module"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; -import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; -import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; -import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; -import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; -import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; 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 { OrganizationsModule } from "@/modules/organizations/organizations.module"; 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"; -import { forwardRef, Module } from "@nestjs/common"; +import { Module } from "@nestjs/common"; import { PassportModule } from "@nestjs/passport"; @Module({ @@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport"; TokensModule, DeploymentsModule, ProfilesModule, - forwardRef(() => OrganizationsModule), - ], - providers: [ - NextAuthGuard, - NextAuthStrategy, - ApiAuthGuard, - ApiAuthStrategy, - OAuthFlowService, - IsTeamInOrg, - RolesGuard, - IsOrgGuard, - IsAdminAPIEnabledGuard, - PlatformPlanGuard, - ], - exports: [ - NextAuthGuard, - ApiAuthGuard, - IsTeamInOrg, - RolesGuard, - IsOrgGuard, - IsAdminAPIEnabledGuard, - PlatformPlanGuard, ], + providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService], + exports: [NextAuthGuard, ApiAuthGuard], }) export class AuthModule {} diff --git a/apps/api/v2/src/modules/conferencing/conferencing.module.ts b/apps/api/v2/src/modules/conferencing/conferencing.module.ts index 982572c630..166c012c7a 100644 --- a/apps/api/v2/src/modules/conferencing/conferencing.module.ts +++ b/apps/api/v2/src/modules/conferencing/conferencing.module.ts @@ -6,15 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; -import { OrganizationsModule } from "@/modules/organizations/organizations.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersRepository } from "@/modules/users/users.repository"; -import { forwardRef, Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; @Module({ - imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)], + imports: [PrismaModule, ConfigModule, HttpModule], providers: [ ConferencingService, ConferencingRepository, diff --git a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts index faec243dd7..479a3cba8b 100644 --- a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts @@ -15,9 +15,8 @@ import { import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output"; import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output"; import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; -import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; +import { HttpService } from "@nestjs/axios"; import { Logger } from "@nestjs/common"; import { Controller, @@ -32,10 +31,10 @@ import { Delete, Headers, Redirect, - UnauthorizedException, Req, HttpException, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger"; import { plainToInstance } from "class-transformer"; import { Request } from "express"; @@ -60,9 +59,9 @@ export class ConferencingController { private readonly logger = new Logger("ConferencingController"); constructor( - private readonly tokensRepository: TokensRepository, private readonly conferencingService: ConferencingService, - private readonly organizationsConferencingService: OrganizationsConferencingService + private readonly config: ConfigService, + private readonly httpService: HttpService ) {} @Post("/:app/connect") @@ -120,6 +119,14 @@ export class ConferencingController { }; } + /** + * Handles saving conferencing app credentials. + * If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint; + * otherwise, credentials are saved at the user level. + * + * Proxying ensures that permission checks—such as whether the user is allowed to install conferencing app for a team or organization— + * are enforced via controller route guards, avoiding duplication of this logic within the service layer. + */ @Get("/:app/oauth/callback") @UseGuards() @Redirect(undefined, 301) @@ -143,23 +150,28 @@ export class ConferencingController { const decodedCallbackState: OAuthCallbackState = JSON.parse(state); try { - const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken); if (error) { throw new BadRequestException(error_description); } - if (!userId) { - throw new UnauthorizedException("Invalid Access token."); - } - if (decodedCallbackState.orgId) { - return this.organizationsConferencingService.connectTeamOauthApps({ - app, - code, - userId, - decodedCallbackState, - }); - } else { - return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState); + + if (decodedCallbackState.teamId && decodedCallbackState.orgId) { + const apiUrl = this.config.get("api.url"); + const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`; + const params: Record = { state, code, error, error_description }; + const headers = { + Authorization: `Bearer ${decodedCallbackState.accessToken}`, + }; + try { + const response = await this.httpService.axiosRef.get(url, { params, headers }); + const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || ""; + return { url: redirectUrl }; + } catch (err) { + const fallbackUrl = decodedCallbackState.onErrorReturnTo || ""; + return { url: fallbackUrl }; + } } + + return this.conferencingService.connectOauthApps(app, code, decodedCallbackState); } catch (error) { if (error instanceof HttpException || error instanceof Error) { this.logger.error(error.message); diff --git a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts index 5e4f18cd09..df5093d90b 100644 --- a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts @@ -3,9 +3,15 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conf import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { UsersRepository } from "@/modules/users/users.repository"; -import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common"; +import { + BadRequestException, + InternalServerErrorException, + Logger, + UnauthorizedException, +} from "@nestjs/common"; import { Injectable } from "@nestjs/common"; import { @@ -25,6 +31,7 @@ export class ConferencingService { constructor( private readonly conferencingRepository: ConferencingRepository, private readonly usersRepository: UsersRepository, + private readonly tokensRepository: TokensRepository, private readonly googleMeetService: GoogleMeetService, private readonly zoomVideoService: ZoomVideoService, private readonly office365VideoService: Office365VideoService @@ -47,10 +54,13 @@ export class ConferencingService { async connectOauthApps( app: string, code: string, - userId: number, decodedCallbackState: OAuthCallbackState, teamId?: number ) { + const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken); + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } switch (app) { case ZOOM: return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId); diff --git a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts index 936ed57415..fa67b70569 100644 --- a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts +++ b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts @@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output"; import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Controller, @@ -36,10 +37,13 @@ import { Headers, Req, ParseIntPipe, + BadRequestException, + Redirect, } from "@nestjs/common"; import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger"; import { plainToInstance } from "class-transformer"; import { Request } from "express"; +import { stringify } from "querystring"; import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants"; @@ -60,7 +64,8 @@ export type OAuthCallbackState = { export class OrganizationsConferencingController { constructor( private readonly conferencingService: ConferencingService, - private readonly organizationsConferencingService: OrganizationsConferencingService + private readonly organizationsConferencingService: OrganizationsConferencingService, + private readonly tokensRepository: TokensRepository ) {} @Roles("TEAM_ADMIN") @@ -221,4 +226,41 @@ export class OrganizationsConferencingController { return { status: SUCCESS_STATUS }; } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/teams/:teamId/conferencing/:app/oauth/callback") + @Redirect(undefined, 301) + @ApiOperation({ summary: "Save conferencing app OAuth credentials" }) + async saveTeamOauthCredentials( + @Query("state") state: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("orgId", ParseIntPipe) orgId: number, + @Param("app") app: string + ): Promise<{ url: string }> { + if (!state) { + throw new BadRequestException("Missing `state` query param"); + } + + const decodedCallbackState: OAuthCallbackState = JSON.parse(state); + try { + return await this.organizationsConferencingService.connectTeamOauthApps({ + decodedCallbackState, + code, + app, + teamId, + }); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + return { + url: decodedCallbackState.onErrorReturnTo ?? "", + }; + } + } } diff --git a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts new file mode 100644 index 0000000000..0d2b76df47 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.module.ts @@ -0,0 +1,46 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { ConferencingModule } from "@/modules/conferencing/conferencing.module"; +import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository"; +import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; +import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; +import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller"; +import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisService } from "@/modules/redis/redis.service"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, ConfigModule, ConferencingModule], + providers: [ + ConferencingService, + ConferencingRepository, + GoogleMeetService, + UsersRepository, + TeamsRepository, + OrganizationsConferencingService, + ZoomVideoService, + Office365VideoService, + CredentialsRepository, + TokensRepository, + AppsRepository, + OrganizationsRepository, + StripeService, + MembershipsRepository, + RedisService, + OrganizationsTeamsRepository, + ], + exports: [OrganizationsConferencingService], + controllers: [OrganizationsConferencingController], +}) +export class OrganizationsConferencingModule {} diff --git a/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts b/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts index 1a24a20124..80fc4e2893 100644 --- a/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts +++ b/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts @@ -1,10 +1,3 @@ -import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; -import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; -import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; -import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; -import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; -import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; -import { PlatformPlanType } from "@/modules/billing/types"; import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller"; import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output"; import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository"; @@ -13,12 +6,7 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { UsersRepository } from "@/modules/users/users.repository"; -import { - BadRequestException, - ForbiddenException, - InternalServerErrorException, - Logger, -} from "@nestjs/common"; +import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common"; import { Injectable } from "@nestjs/common"; import { GOOGLE_MEET } from "@calcom/platform-constants"; @@ -35,129 +23,9 @@ export class OrganizationsConferencingService { private teamsRepository: TeamsRepository, private usersRepository: UsersRepository, private readonly googleMeetService: GoogleMeetService, - private readonly conferencingService: ConferencingService, - private readonly platformPlanGuard: PlatformPlanGuard, - private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard, - private readonly isOrgGuard: IsOrgGuard, - private readonly isTeamInOrg: IsTeamInOrg, - private readonly rolesGuard: RolesGuard + private readonly conferencingService: ConferencingService ) {} - async verifyAccess({ - user, - orgId, - teamId, - requiredRole, - minimumPlan, - }: { - user: UserWithProfile; - orgId: string; - teamId?: string; - requiredRole?: string; - minimumPlan?: PlatformPlanType; - }): Promise<{ orgId: number; teamId: number | null }> { - const userWithAdminFlag = { - ...user, - isSystemAdmin: user.role === "ADMIN", - }; - - if (!orgId) { - throw new BadRequestException("Organization ID is required"); - } - - await this.ensureOrganizationAccess(orgId); - await this.ensureAdminAPIEnabled(orgId); - - // Only validate team if a teamId is provided - if (teamId && teamId.trim() !== "") { - await this.ensureTeamBelongsToOrg(orgId, teamId); - } - - await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole); - await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan }); - - const parsedOrgId = parseInt(orgId, 10); - // For organization-level operations, teamId might be empty or null - const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null; - - return { - orgId: parsedOrgId, - teamId: parsedTeamId, - }; - } - - private async ensureOrganizationAccess(orgId: string): Promise { - const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId); - if (!canAccess) { - throw new ForbiddenException("Organization validation failed."); - } - } - - private async ensureAdminAPIEnabled(orgId: string): Promise { - const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId); - if (!canAccess) { - throw new ForbiddenException("Admin API is not enabled for this organization."); - } - } - - private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise { - const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId); - if (!canAccess) { - throw new ForbiddenException("Team is not part of the organization."); - } - } - - private async ensureUserRoleAccess( - user: ApiAuthGuardUser, - orgId: string, - teamId?: string, - requiredRole?: string - ): Promise { - if (!requiredRole) return; - - // For team-level roles, we need both orgId and teamId - if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) { - throw new ForbiddenException("Team ID is required for team-level role access"); - } - - // Use empty string for teamId if it's an org-level operation - const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : ""; - - const { canAccess } = await this.rolesGuard.checkUserRoleAccess( - user, - orgId, - effectiveTeamId, - requiredRole - ); - if (!canAccess) { - throw new ForbiddenException("User does not have the required role."); - } - } - - private async ensurePlatformPlanAccess({ - teamId, - orgId, - user, - minimumPlan, - }: { - teamId?: string; - orgId?: string; - user: ApiAuthGuardUser; - minimumPlan?: PlatformPlanType; - }): Promise { - if (!minimumPlan) return; - - const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({ - teamId, - orgId, - user, - minimumPlan, - }); - if (!canAccess) { - throw new ForbiddenException("User's organization does not meet the required subscription plan."); - } - } - async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise { switch (app) { case GOOGLE_MEET: @@ -171,40 +39,14 @@ export class OrganizationsConferencingService { decodedCallbackState, app, code, - userId, + teamId, }: { - userId: number; app: string; decodedCallbackState: OAuthCallbackState; code: string; + teamId: number; }) { - const user = await this.usersRepository.findByIdWithProfile(userId); - const { orgId, teamId } = decodedCallbackState; - - if (!orgId) { - throw new BadRequestException("orgId required"); - } - - if (!user) { - throw new BadRequestException("user not found"); - } - - // Determine if this is a team-level or organization-level operation - const isTeamLevel = !!teamId; - const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN"; - - // Verify access with appropriate parameters - const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({ - user, - orgId, - teamId, - requiredRole, - minimumPlan: "ESSENTIALS", - }); - - const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId; - - return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId); + return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId); } async getConferencingApps({ teamId }: { teamId: number }) { diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 6db22addbd..b830c642bf 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -1,7 +1,12 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { ConferencingModule } from "@/modules/conferencing/conferencing.module"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository"; +import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; +import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service"; +import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { EmailModule } from "@/modules/email/email.module"; import { EmailService } from "@/modules/email/email.service"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; @@ -14,6 +19,7 @@ import { OrganizationAttributeOptionRepository } from "@/modules/organizations/a import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller"; import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service"; import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller"; +import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module"; import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module"; import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller"; @@ -31,6 +37,8 @@ import { OrganizationsOrganizationsModule } from "@/modules/organizations/organi import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller"; import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository"; import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service"; +import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module"; +import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service"; import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller"; import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service"; @@ -50,13 +58,15 @@ import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhook import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; +import { RedisService } from "@/modules/redis/redis.service"; import { StripeModule } from "@/modules/stripe/stripe.module"; import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; import { TeamsModule } from "@/modules/teams/teams/teams.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersModule } from "@/modules/users/users.module"; import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; -import { forwardRef, Module } from "@nestjs/common"; +import { Module } from "@nestjs/common"; @Module({ imports: [ @@ -69,11 +79,11 @@ import { forwardRef, Module } from "@nestjs/common"; EventTypesModule_2024_06_14, TeamsEventTypesModule, TeamsModule, - forwardRef(() => ConferencingModule), - forwardRef(() => AuthModule), OrganizationsDelegationCredentialModule, OrganizationsOrganizationsModule, + OrganizationsStripeModule, OrganizationsTeamsRoutingFormsModule, + OrganizationsConferencingModule, ], providers: [ OrganizationsRepository, @@ -108,6 +118,16 @@ import { forwardRef, Module } from "@nestjs/common"; OrgUsersOOOService, OrgUsersOOORepository, OrganizationsConferencingService, + OrganizationsStripeService, + CredentialsRepository, + AppsRepository, + RedisService, + ConferencingRepository, + GoogleMeetService, + ConferencingService, + ZoomVideoService, + Office365VideoService, + TokensRepository, ], exports: [ OrganizationsService, @@ -129,6 +149,7 @@ import { forwardRef, Module } from "@nestjs/common"; WebhooksService, OrganizationsEventTypesService, OrganizationsConferencingService, + OrganizationsStripeService, ], controllers: [ OrganizationsTeamsController, @@ -142,7 +163,6 @@ import { forwardRef, Module } from "@nestjs/common"; OrganizationsWebhooksController, OrganizationsTeamsSchedulesController, OrganizationsUsersOOOController, - OrganizationsConferencingController, ], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/stripe/organizations-stripe.controller.ts b/apps/api/v2/src/modules/organizations/stripe/organizations-stripe.controller.ts new file mode 100644 index 0000000000..48541942a8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/stripe/organizations-stripe.controller.ts @@ -0,0 +1,160 @@ +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 { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service"; +import { + StripConnectOutputDto, + StripConnectOutputResponseDto, + StripCredentialsCheckOutputResponseDto, + StripCredentialsSaveOutputResponseDto, +} from "@/modules/stripe/outputs/stripe.output"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Get, + Query, + HttpCode, + HttpStatus, + UseGuards, + Param, + Headers, + Req, + ParseIntPipe, + Redirect, + BadRequestException, +} from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; +import { Request } from "express"; +import { stringify } from "querystring"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +export type OAuthCallbackState = { + accessToken: string; + teamId?: string; + orgId?: string; + fromApp?: boolean; + returnTo?: string; + onErrorReturnTo?: string; +}; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId/stripe", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Organizations/Teams Stripe") +export class OrganizationsStripeController { + constructor( + private readonly stripeService: StripeService, + private readonly organizationsStripeService: OrganizationsStripeService, + private readonly tokensRepository: TokensRepository + ) {} + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/connect") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Get stripe connect URL for a team" }) + async getTeamStripeConnectUrl( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @GetUser() user: UserWithProfile, + @Param("teamId") teamId: string, + @Param("orgId") orgId: string, + @Query("returnTo") returnTo?: string, + @Query("onErrorReturnTo") onErrorReturnTo?: string + ): Promise { + const origin = req.headers.origin; + const accessToken = authorization.replace("Bearer ", ""); + + const state: OAuthCallbackState = { + onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin, + fromApp: false, + returnTo: !!returnTo ? returnTo : origin, + accessToken, + teamId, + orgId, + }; + + const stripeRedirectUrl = await this.organizationsStripeService.getStripeTeamRedirectUrl({ + state, + userEmail: user.email, + userName: user.name, + }); + + return { + status: SUCCESS_STATUS, + data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }), + }; + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/check") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Check team stripe connection" }) + async checkTeamStripeConnection( + @Param("teamId", ParseIntPipe) teamId: number + ): Promise { + return await this.organizationsStripeService.checkIfTeamStripeAccountConnected(teamId); + } + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) + @Get("/save") + @Redirect(undefined, 301) + @ApiOperation({ summary: "Save stripe credentials" }) + async save( + @Query("state") state: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined, + @Param("teamId", ParseIntPipe) teamId: number + ): Promise { + if (!state) { + throw new BadRequestException("Missing `state` query param"); + } + + const decodedCallbackState: OAuthCallbackState = JSON.parse(state); + try { + const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken); + + // user cancels flow + if (error === "access_denied") { + return { url: getOnErrorReturnToValueFromQueryState(state) }; + } + + if (error) { + throw new BadRequestException(stringify({ error, error_description })); + } + + return await this.organizationsStripeService.saveStripeAccount( + decodedCallbackState, + code, + teamId, + userId + ); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + return { + url: decodedCallbackState.onErrorReturnTo ?? "", + }; + } + } +} diff --git a/apps/api/v2/src/modules/organizations/stripe/organizations-stripe.module.ts b/apps/api/v2/src/modules/organizations/stripe/organizations-stripe.module.ts new file mode 100644 index 0000000000..7aaafc42e7 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/stripe/organizations-stripe.module.ts @@ -0,0 +1,29 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { OrganizationsStripeController } from "@/modules/organizations/stripe/organizations-stripe.controller"; +import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisService } from "@/modules/redis/redis.service"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [StripeModule, PrismaModule], + exports: [OrganizationsStripeService], + providers: [ + OrganizationsStripeService, + CredentialsRepository, + AppsRepository, + RedisService, + OrganizationsRepository, + MembershipsRepository, + OrganizationsTeamsRepository, + TokensRepository, + ], + controllers: [OrganizationsStripeController], +}) +export class OrganizationsStripeModule {} diff --git a/apps/api/v2/src/modules/organizations/stripe/services/organizations-stripe.service.ts b/apps/api/v2/src/modules/organizations/stripe/services/organizations-stripe.service.ts new file mode 100644 index 0000000000..a24f75188a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/stripe/services/organizations-stripe.service.ts @@ -0,0 +1,83 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service"; +import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance"; +import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas"; +import { Logger, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import type { Prisma } from "@prisma/client"; + +import { ApiResponseWithoutData } from "@calcom/platform-types"; + +@Injectable() +export class OrganizationsStripeService { + private logger = new Logger("OrganizationsStripeService"); + + constructor( + private readonly stripeService: StripeService, + private readonly credentialRepository: CredentialsRepository, + private readonly appsRepository: AppsRepository + ) {} + + async getStripeTeamRedirectUrl({ + state, + userEmail, + userName, + }: { + state: OAuthCallbackState; + userEmail?: string; + userName?: string | null; + }): Promise { + return await this.stripeService.getStripeRedirectUrl(state, userEmail, userName); + } + + async saveStripeAccount( + state: OAuthCallbackState, + code: string, + teamId: number, + userId?: number + ): Promise<{ url: string }> { + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const response = await stripeInstance.oauth.token({ + grant_type: "authorization_code", + code: code?.toString(), + }); + + const data: StripeData = { ...response, default_currency: "" }; + if (response["stripe_user_id"]) { + const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]); + data["default_currency"] = account.default_currency; + } + + const existingCredentials = await this.credentialRepository.findAllCredentialsByTypeAndTeamId( + "stripe_payment", + teamId + ); + + const credentialIdsToDelete = existingCredentials.map((item) => item.id); + if (credentialIdsToDelete.length > 0) { + await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId); + } + + await this.appsRepository.createTeamAppCredential( + "stripe_payment", + data as unknown as Prisma.InputJsonObject, + teamId, + "stripe" + ); + + return { url: state.returnTo ?? "" }; + } + + async checkIfTeamStripeAccountConnected(teamId: number): Promise { + const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId( + "stripe_payment", + teamId + ); + + return await this.stripeService.validateStripeCredentials(stripeCredentials); + } +} diff --git a/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts index 4bdb2672e4..d2cf5217bf 100644 --- a/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts +++ b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts @@ -8,9 +8,11 @@ import { StripCredentialsCheckOutputResponseDto, StripCredentialsSaveOutputResponseDto, } from "@/modules/stripe/outputs/stripe.output"; -import { StripeService } from "@/modules/stripe/stripe.service"; +import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service"; import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; +import { HttpService } from "@nestjs/axios"; import { Controller, Query, @@ -22,8 +24,8 @@ import { Req, BadRequestException, Headers, - Param, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { ApiTags as DocsTags, ApiOperation, ApiHeader } from "@nestjs/swagger"; import { plainToClass } from "class-transformer"; import { Request } from "express"; @@ -37,7 +39,12 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants"; }) @DocsTags("Stripe") export class StripeController { - constructor(private readonly stripeService: StripeService) {} + constructor( + private readonly stripeService: StripeService, + private readonly tokensRepository: TokensRepository, + private readonly httpService: HttpService, + private readonly config: ConfigService + ) {} @Get("/connect") @UseGuards(ApiAuthGuard) @@ -48,26 +55,20 @@ export class StripeController { @Req() req: Request, @Headers("Authorization") authorization: string, @GetUser() user: UserWithProfile, - @Query("redir") redir?: string | null, - @Query("errorRedir") errorRedir?: string | null, - @Query("teamId") teamId?: string | null + @Query("returnTo") returnTo?: string | null, + @Query("onErrorReturnTo") onErrorReturnTo?: string | null ): Promise { const origin = req.headers.origin; const accessToken = authorization.replace("Bearer ", ""); - const state = { - onErrorReturnTo: !!errorRedir ? errorRedir : origin, + const state: OAuthCallbackState = { + onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin, fromApp: false, - returnTo: !!redir ? redir : origin, + returnTo: !!returnTo ? returnTo : origin, accessToken, - teamId: Number(teamId) ?? null, }; - const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl( - JSON.stringify(state), - user.email, - user.name - ); + const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(state, user.email, user.name); return { status: SUCCESS_STATUS, @@ -79,24 +80,71 @@ export class StripeController { @UseGuards() @Redirect(undefined, 301) @ApiOperation({ summary: "Save stripe credentials" }) + /** + * Handles saving Stripe credentials. + * If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint; + * otherwise, credentials are saved at the user level. + * + * Proxying ensures that permission checks—such as whether the user is allowed to install Stripe for a team or organization— + * are enforced via controller route guards, avoiding duplication of this logic within the service layer. + */ async save( @Query("state") state: string, @Query("code") code: string, @Query("error") error: string | undefined, @Query("error_description") error_description: string | undefined ): Promise { - const accessToken = JSON.parse(state).accessToken; - - // user cancels flow - if (error === "access_denied") { - return { url: getOnErrorReturnToValueFromQueryState(state) }; + if (!state) { + throw new BadRequestException("Missing `state` query param"); } - if (error) { - throw new BadRequestException(stringify({ error, error_description })); - } + const decodedCallbackState: OAuthCallbackState = JSON.parse(state); + try { + // If teamId is present, proxy to team endpoint + if (decodedCallbackState.teamId && decodedCallbackState.orgId) { + let url = ""; + const apiUrl = this.config.get("api.url"); + url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/stripe/save`; - return await this.stripeService.saveStripeAccount(state, code, accessToken); + const params: Record = { state, code, error, error_description }; + const headers = { + Authorization: `Bearer ${decodedCallbackState.accessToken}`, + }; + try { + const response = await this.httpService.axiosRef.get(url, { params, headers }); + const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || ""; + return { url: redirectUrl }; + } catch (err) { + const fallbackUrl = decodedCallbackState.onErrorReturnTo || ""; + return { url: fallbackUrl }; + } + } + + // user-level fallback + const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken); + + // user cancels flow + if (error === "access_denied") { + return { url: getOnErrorReturnToValueFromQueryState(state) }; + } + + if (error) { + throw new BadRequestException(stringify({ error, error_description })); + } + + if (!userId) { + throw new BadRequestException("Invalid Access token."); + } + + return await this.stripeService.saveStripeAccount(decodedCallbackState, code, userId); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + return { + url: decodedCallbackState.onErrorReturnTo ?? "", + }; + } } @Get("/check") @@ -107,15 +155,4 @@ export class StripeController { async check(@GetUser() user: UserWithProfile): Promise { return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id); } - - @Get("/check/:teamId") - @UseGuards(ApiAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) - @ApiOperation({ summary: "Check team stripe connection" }) - async checkTeamStripeConnection( - @Param("teamId") teamId: string - ): Promise { - return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId)); - } } diff --git a/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts b/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts index ca9cb0a263..a852dff41d 100644 --- a/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts +++ b/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts @@ -23,7 +23,7 @@ export class StripConnectOutputResponseDto { export class StripCredentialsCheckOutputResponseDto { @ApiProperty({ example: SUCCESS_STATUS }) - status!: typeof SUCCESS_STATUS; + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; } export class StripCredentialsSaveOutputResponseDto { diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts index e504cde031..a995c3991f 100644 --- a/apps/api/v2/src/modules/stripe/stripe.module.ts +++ b/apps/api/v2/src/modules/stripe/stripe.module.ts @@ -6,11 +6,12 @@ import { StripeController } from "@/modules/stripe/controllers/stripe.controller import { StripeService } from "@/modules/stripe/stripe.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersModule } from "@/modules/users/users.module"; +import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; @Module({ - imports: [ConfigModule, PrismaModule, UsersModule], + imports: [ConfigModule, PrismaModule, UsersModule, HttpModule], exports: [StripeService], providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository], controllers: [StripeController], diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts index b87145621f..c1f95ab3a9 100644 --- a/apps/api/v2/src/modules/stripe/stripe.service.ts +++ b/apps/api/v2/src/modules/stripe/stripe.service.ts @@ -2,10 +2,8 @@ import { AppConfig } from "@/config/type"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; -import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance"; import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable, @@ -25,12 +23,13 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas"; import stringify = require("qs-stringify"); -type IntegrationOAuthCallbackState = { +export type OAuthCallbackState = { accessToken: string; - returnTo: string; - onErrorReturnTo: string; - fromApp: boolean; - teamId?: number | null; + teamId?: string; + orgId?: string; + fromApp?: boolean; + returnTo?: string; + onErrorReturnTo?: string; }; @Injectable() @@ -45,8 +44,7 @@ export class StripeService { configService: ConfigService, private readonly config: ConfigService, private readonly appsRepository: AppsRepository, - private readonly credentialRepository: CredentialsRepository, - private readonly tokensRepository: TokensRepository, + private readonly credentialsRepository: CredentialsRepository, private readonly membershipRepository: MembershipsRepository, private readonly usersRepository: UsersRepository ) { @@ -59,7 +57,7 @@ export class StripeService { return this.stripe; } - async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) { + async getStripeRedirectUrl(state: OAuthCallbackState, userEmail?: string, userName?: string | null) { const { client_id } = await this.getStripeAppKeys(); const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = { @@ -73,7 +71,7 @@ export class StripeService { country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined, }, redirect_uri: this.redirectUri, - state: state, + state: JSON.stringify(state), }; const params = z.record(z.any()).parse(stripeConnectParams); @@ -99,10 +97,7 @@ export class StripeService { return { client_id, client_secret }; } - async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> { - const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); - const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state); - + async saveStripeAccount(state: OAuthCallbackState, code: string, userId: number): Promise<{ url: string }> { if (!userId) { throw new UnauthorizedException("Invalid Access token."); } @@ -118,17 +113,14 @@ export class StripeService { data["default_currency"] = account.default_currency; } - if (oAuthCallbackState.teamId) { - await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId); + const existingCredentials = await this.credentialsRepository.findAllCredentialsByTypeAndUserId( + "stripe_payment", + userId + ); - await this.appsRepository.createTeamAppCredential( - "stripe_payment", - data as unknown as Prisma.InputJsonObject, - oAuthCallbackState.teamId, - "stripe" - ); - - return { url: getReturnToValueFromQueryState(state) }; + const credentialIdsToDelete = existingCredentials.map((item) => item.id); + if (credentialIdsToDelete.length > 0) { + await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId); } await this.appsRepository.createAppCredential( @@ -138,11 +130,11 @@ export class StripeService { "stripe" ); - return { url: getReturnToValueFromQueryState(state) }; + return { url: state.returnTo ?? "" }; } async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { - const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId( + const stripeCredentials = await this.credentialsRepository.findCredentialByTypeAndUserId( "stripe_payment", userId ); @@ -150,24 +142,6 @@ export class StripeService { return await this.validateStripeCredentials(stripeCredentials); } - async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> { - const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId( - "stripe_payment", - teamId - ); - - return await this.validateStripeCredentials(stripeCredentials); - } - - async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) { - const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId); - const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER"; - - if (!hasAdminAccessToTeam) { - throw new BadRequestException("You must be team owner or admin to do this"); - } - } - async validateStripeCredentials( credentials?: Credential | null ): Promise<{ status: typeof SUCCESS_STATUS }> { diff --git a/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts b/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts index 06c8e33758..ac7419ad5e 100644 --- a/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts +++ b/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts @@ -1,8 +1,6 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; -import { AuthModule } from "@/modules/auth/auth.module"; -import { ConferencingModule } from "@/modules/conferencing/conferencing.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; -import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; +import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module"; import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer"; import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service"; import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service"; @@ -14,7 +12,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; import { TeamsModule } from "@/modules/teams/teams/teams.module"; import { UsersModule } from "@/modules/users/users.module"; -import { forwardRef, Module } from "@nestjs/common"; +import { Module } from "@nestjs/common"; @Module({ imports: [ @@ -24,8 +22,7 @@ import { forwardRef, Module } from "@nestjs/common"; EventTypesModule_2024_06_14, UsersModule, TeamsModule, - forwardRef(() => ConferencingModule), - forwardRef(() => AuthModule), + OrganizationsConferencingModule, ], providers: [ TeamsEventTypesRepository, @@ -34,7 +31,6 @@ import { forwardRef, Module } from "@nestjs/common"; OrganizationsTeamsRepository, OutputTeamEventTypesResponsePipe, OutputOrganizationsEventTypesService, - OrganizationsConferencingService, ], exports: [TeamsEventTypesRepository, TeamsEventTypesService], controllers: [TeamsEventTypesController], diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index d5308b71b2..2d59fdd640 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3577,6 +3577,62 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/callback": { + "get": { + "operationId": "OrganizationsConferencingController_saveTeamOauthCredentials", + "summary": "Save conferencing app OAuth credentials", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Teams / Conferencing" + ] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/event-types": { "post": { "operationId": "OrganizationsEventTypesController_createTeamEventType", @@ -9249,6 +9305,147 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": { + "get": { + "operationId": "OrganizationsStripeController_getTeamStripeConnectUrl", + "summary": "Get stripe connect URL for a team", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "returnTo", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "onErrorReturnTo", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripConnectOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations/Teams Stripe" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/stripe/check": { + "get": { + "operationId": "OrganizationsStripeController_checkTeamStripeConnection", + "summary": "Check team stripe connection", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations/Teams Stripe" + ] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/stripe/save": { + "get": { + "operationId": "OrganizationsStripeController_save", + "summary": "Save stripe credentials", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations/Teams Stripe" + ] + } + }, "/v2/routing-forms/{routingFormId}/calculate-slots": { "post": { "operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse", @@ -10234,46 +10431,6 @@ ] } }, - "/v2/stripe/check/{teamId}": { - "get": { - "operationId": "StripeController_checkTeamStripeConnection", - "summary": "Check team stripe connection", - "parameters": [ - { - "name": "teamId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "Authorization", - "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto" - } - } - } - } - }, - "tags": [ - "Stripe" - ] - } - }, "/v2/teams": { "post": { "operationId": "TeamsController_createTeam", @@ -19156,7 +19313,7 @@ "type": "object", "properties": { "status": { - "type": "string", + "type": "object", "example": "success" } }, diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 2c953bdc00..ffd6b55fae 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -28,11 +28,7 @@ import { } from "@calcom/features/bookings/lib/SystemField"; import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks"; import { APP_NAME } from "@calcom/lib/constants"; -import { - formatToLocalizedDate, - formatToLocalizedTime, - formatToLocalizedTimezone, -} from "@calcom/lib/dayjs"; +import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs"; import type { nameObjectSchema } from "@calcom/lib/event"; import { getEventName } from "@calcom/lib/event"; import useGetBrandingColours from "@calcom/lib/getBrandColours"; diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 36965f7af0..d4321e13bf 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -3409,6 +3409,60 @@ "tags": ["Orgs / Teams / Conferencing"] } }, + "/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/callback": { + "get": { + "operationId": "OrganizationsConferencingController_saveTeamOauthCredentials", + "summary": "Save conferencing app OAuth credentials", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["Orgs / Teams / Conferencing"] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/event-types": { "post": { "operationId": "OrganizationsEventTypesController_createTeamEventType", @@ -8808,6 +8862,141 @@ "tags": ["OAuth Clients"] } }, + "/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": { + "get": { + "operationId": "OrganizationsStripeController_getTeamStripeConnectUrl", + "summary": "Get stripe connect URL for a team", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "returnTo", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "onErrorReturnTo", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripConnectOutputResponseDto" + } + } + } + } + }, + "tags": ["Organizations/Teams Stripe"] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/stripe/check": { + "get": { + "operationId": "OrganizationsStripeController_checkTeamStripeConnection", + "summary": "Check team stripe connection", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto" + } + } + } + } + }, + "tags": ["Organizations/Teams Stripe"] + } + }, + "/v2/organizations/{orgId}/teams/{teamId}/stripe/save": { + "get": { + "operationId": "OrganizationsStripeController_save", + "summary": "Save stripe credentials", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto" + } + } + } + } + }, + "tags": ["Organizations/Teams Stripe"] + } + }, "/v2/routing-forms/{routingFormId}/calculate-slots": { "post": { "operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse", @@ -9756,44 +9945,6 @@ "tags": ["Stripe"] } }, - "/v2/stripe/check/{teamId}": { - "get": { - "operationId": "StripeController_checkTeamStripeConnection", - "summary": "Check team stripe connection", - "parameters": [ - { - "name": "teamId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "Authorization", - "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto" - } - } - } - } - }, - "tags": ["Stripe"] - } - }, "/v2/teams": { "post": { "operationId": "TeamsController_createTeam", @@ -17567,7 +17718,7 @@ "type": "object", "properties": { "status": { - "type": "string", + "type": "object", "example": "success" } }, diff --git a/packages/platform/atoms/connect/apple/AppleConnect.tsx b/packages/platform/atoms/connect/apple/AppleConnect.tsx index c543d74744..2384f92148 100644 --- a/packages/platform/atoms/connect/apple/AppleConnect.tsx +++ b/packages/platform/atoms/connect/apple/AppleConnect.tsx @@ -139,7 +139,7 @@ export const AppleConnect: FC>> = ({ form.reset(); setIsDialogOpen(false); toast({ - description: "Calendar credentials added successfully", + description: "Calendar credentials added successfully", }); onSuccess?.(); } else { diff --git a/packages/platform/atoms/connect/stripe/StripeConnect.tsx b/packages/platform/atoms/connect/stripe/StripeConnect.tsx index 497c3910f3..ab0d6dbba9 100644 --- a/packages/platform/atoms/connect/stripe/StripeConnect.tsx +++ b/packages/platform/atoms/connect/stripe/StripeConnect.tsx @@ -6,7 +6,7 @@ import type { ButtonColor } from "@calcom/ui/components/button"; import type { IconName } from "@calcom/ui/components/icon"; import type { OnCheckErrorType, UseCheckProps } from "../../hooks/connect/useCheck"; -import { useTeamCheck, useCheck } from "../../hooks/stripe/useCheck"; +import { useCheck } from "../../hooks/stripe/useCheck"; import { useConnect } from "../../hooks/stripe/useConnect"; import { AtomsWrapper } from "../../src/components/atoms-wrapper"; import { cn } from "../../src/lib/utils"; @@ -46,24 +46,16 @@ export const StripeConnect: FC> = ({ let displayedLabel = label || t("stripe_connect_atom_label"); const { connect } = useConnect(redir, errorRedir, teamId); - const { allowConnect, checked } = useCheck({ + const { allowConnect, checked, isLoading } = useCheck({ onCheckError, onCheckSuccess, initialData, - }); - const { allowConnect: allowConnectTeam, checked: checkedTeam } = useTeamCheck({ teamId, - onCheckError, - onCheckSuccess, - initialData, }); - const isChecking = teamId ? !checkedTeam : !checked; - const isAllowConnect = teamId ? allowConnectTeam : allowConnect; + const isDisabled = !checked || !allowConnect; - const isDisabled = isChecking || !isAllowConnect; - - if (isChecking) { + if (!checked) { displayedLabel = loadingLabel || t("stripe_connect_atom_loading_label"); } else if (!allowConnect) { displayedLabel = alreadyConnectedLabel || t("stripe_connect_atom_already_connected_label"); @@ -79,7 +71,7 @@ export const StripeConnect: FC> = ({ "", "md:rounded-md", className, - isChecking && "animate-pulse", + isLoading && "animate-pulse bg-gray-200", isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer" )} diff --git a/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx index 365de32511..c7e98cc0ec 100644 --- a/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx @@ -9,19 +9,15 @@ import useAppsData from "@calcom/lib/hooks/useAppsData"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { StripeConnect } from "../../connect/stripe/StripeConnect"; -import { useCheck, useTeamCheck } from "../../hooks/stripe/useCheck"; +import { useCheck } from "../../hooks/stripe/useCheck"; import { useAtomsEventTypeById } from "../hooks/useAtomEventTypeAppIntegration"; const EventPaymentsTabPlatformWrapper = ({ eventType }: { eventType: EventTypeSetupProps["eventType"] }) => { - const { allowConnect, checked } = useCheck({}); - const { allowConnect: allowConnectTeam, checked: checkedTeam } = useTeamCheck({ teamId: eventType.teamId }); + const { allowConnect, checked } = useCheck({ teamId: eventType.teamId }); - const isAllowConnect = eventType.teamId ? allowConnectTeam : allowConnect; - const isChecking = eventType.teamId ? !checkedTeam : !checked; + const isStripeConnected = !checked || !allowConnect; - const isStripeConnected = isChecking || !isAllowConnect; - - if (isChecking) return
Checking...
; + if (!checked) return
Checking...
; return (
diff --git a/packages/platform/atoms/hooks/stripe/useCheck.ts b/packages/platform/atoms/hooks/stripe/useCheck.ts index 0fb06d3038..a711cff117 100644 --- a/packages/platform/atoms/hooks/stripe/useCheck.ts +++ b/packages/platform/atoms/hooks/stripe/useCheck.ts @@ -1,6 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { CALENDARS } from "@calcom/platform-constants"; import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; @@ -17,23 +16,33 @@ export interface UseCheckProps { checked: boolean; }; }; + teamId?: number | null; } -const stripeQueryKey = ["get-stripe-check"]; -const stripeTeamQueryKey = ["get-stripe-team-check"]; +const stripeTeamQueryKey = "get-stripe-check"; export type OnCheckErrorType = (err: ApiErrorResponse) => void; -export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`]; -export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheckProps) => { - const { isInit, accessToken } = useAtomsContext(); +export const useCheck = ({ teamId, onCheckError, initialData, onCheckSuccess }: UseCheckProps) => { + const { isInit, accessToken, organizationId } = useAtomsContext(); const queryClient = useQueryClient(); - const { data: check, refetch } = useQuery({ - queryKey: stripeQueryKey, + // Determine the appropriate endpoint based on whether teamId is provided + let pathname = "/stripe/check"; + + if (teamId && organizationId) { + pathname = `/organizations/${organizationId}/teams/${teamId}/stripe/check`; + } + + const { + data: check, + refetch, + isLoading, + } = useQuery({ + queryKey: [stripeTeamQueryKey, teamId, organizationId], enabled: isInit && !!accessToken, queryFn: () => { return http - ?.get>(`/stripe/check`) + ?.get>(pathname) .then(({ data: responseBody }) => { if (responseBody.status === SUCCESS_STATUS) { onCheckSuccess?.(); @@ -53,54 +62,12 @@ export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheck allowConnect: check?.data?.allowConnect ?? false, checked: check?.data?.checked ?? false, refetch: () => { - queryClient.setQueryData(stripeQueryKey, { - status: SUCCESS_STATUS, - data: { allowConnect: false, checked: false }, - }); - refetch(); - }, - }; -}; - -export const useTeamCheck = ({ - teamId, - onCheckError, - initialData, - onCheckSuccess, -}: UseCheckProps & { teamId?: number | null }) => { - const { isInit, accessToken } = useAtomsContext(); - const queryClient = useQueryClient(); - - const { data: check, refetch } = useQuery({ - queryKey: stripeTeamQueryKey, - enabled: isInit && !!accessToken && !!teamId, - queryFn: () => { - return http - ?.get>(`/stripe/check/${teamId}`) - .then(({ data: responseBody }) => { - if (responseBody.status === SUCCESS_STATUS) { - onCheckSuccess?.(); - return { status: SUCCESS_STATUS, data: { allowConnect: false, checked: true } }; - } - onCheckError?.(responseBody); - return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } }; - }) - .catch((err) => { - onCheckError?.(err); - return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } }; - }); - }, - initialData, - }); - return { - allowConnect: check?.data?.allowConnect ?? false, - checked: check?.data?.checked ?? false, - refetch: () => { - queryClient.setQueryData(stripeQueryKey, { + queryClient.setQueryData([stripeTeamQueryKey, teamId, organizationId], { status: SUCCESS_STATUS, data: { allowConnect: false, checked: false }, }); refetch(); }, + isLoading, }; }; diff --git a/packages/platform/atoms/hooks/stripe/useConnect.ts b/packages/platform/atoms/hooks/stripe/useConnect.ts index 5b0066be49..fdc9b9c8e6 100644 --- a/packages/platform/atoms/hooks/stripe/useConnect.ts +++ b/packages/platform/atoms/hooks/stripe/useConnect.ts @@ -4,34 +4,43 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; import type { ApiResponse } from "@calcom/platform-types"; import http from "../../lib/http"; +import { useAtomsContext } from "../useAtomsContext"; -export const useGetRedirectUrl = (redir?: string, errorRedir?: string, teamId?: number | null) => { - const authUrl = useQuery({ - queryKey: ["get-stripe-connect-redirect-uri"], +export const useGetRedirectUrl = (returnTo?: string, onErrorReturnTo?: string, teamId?: number | null) => { + const { organizationId } = useAtomsContext(); + + // Determine the appropriate endpoint based on whether teamId is provided + let pathname = "/stripe/connect"; + + if (teamId && organizationId) { + pathname = `/organizations/${organizationId}/teams/${teamId}/stripe/connect`; + } + + // Add query parameters + const queryParams = new URLSearchParams(); + if (returnTo) queryParams.append("returnTo", returnTo); + if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo); + + const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname; + + return useQuery({ + queryKey: ["get-stripe-connect-redirect-uri", teamId, organizationId], staleTime: Infinity, enabled: false, queryFn: () => { - return http - ?.get>( - `/stripe/connect${redir ? `?redir=${encodeURIComponent(redir)}` : "?redir="}${ - errorRedir ? `&errorRedir=${encodeURIComponent(errorRedir)}` : "" - }${teamId ? `&teamId=${teamId}` : ""}` - ) - .then(({ data: responseBody }) => { - if (responseBody.status === SUCCESS_STATUS) { - return responseBody.data.authUrl; - } - if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message); - return ""; - }); + return http?.get>(fullPath).then(({ data: responseBody }) => { + if (responseBody.status === SUCCESS_STATUS) { + return responseBody.data.authUrl; + } + if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message); + return ""; + }); }, }); - - return authUrl; }; -export const useConnect = (redir?: string, errorRedir?: string, teamId?: number | null) => { - const { refetch } = useGetRedirectUrl(redir, errorRedir, teamId); +export const useConnect = (returnTo?: string, onErrorReturnTo?: string, teamId?: number | null) => { + const { refetch } = useGetRedirectUrl(returnTo, onErrorReturnTo, teamId); const connect = async () => { const redirectUri = await refetch(); diff --git a/packages/platform/examples/base/src/pages/conferencing-apps.tsx b/packages/platform/examples/base/src/pages/conferencing-apps.tsx index a0fb7b0680..e476cf03bf 100644 --- a/packages/platform/examples/base/src/pages/conferencing-apps.tsx +++ b/packages/platform/examples/base/src/pages/conferencing-apps.tsx @@ -15,7 +15,7 @@ export default function ConferencingApps(props: { calUsername: string; calEmail: className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
- +
); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index ac933c8dbb..cc98dc0404 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -13,11 +13,9 @@ import type { } from "zod"; import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated"; -import dayjs from "@calcom/dayjs"; import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid"; import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema"; import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema"; -import { isSupportedTimeZone } from "@calcom/lib/dayjs"; import { emailSchema as emailRegexSchema, emailRegex } from "@calcom/lib/emailSchema"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod"; diff --git a/yarn.lock b/yarn.lock index 41eb3ae589..f01a9031b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2305,6 +2305,7 @@ __metadata: "@golevelup/ts-jest": ^0.4.0 "@microsoft/microsoft-graph-types-beta": ^0.42.0-preview "@nest-lab/throttler-storage-redis": 1.0.0 + "@nestjs/axios": ^4.0.0 "@nestjs/bull": ^10.1.1 "@nestjs/cli": ^10.0.0 "@nestjs/common": ^10.0.0 @@ -7681,6 +7682,17 @@ __metadata: languageName: node linkType: hard +"@nestjs/axios@npm:^4.0.0": + version: 4.0.0 + resolution: "@nestjs/axios@npm:4.0.0" + peerDependencies: + "@nestjs/common": ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + checksum: ab05bc772a7ad45e3ea79ea3acd04e6971d17495387d543b3e17355d0f4164982c20ed830eab48e8c7373546259b50ff770708c176832716f4fd126142d18ca7 + languageName: node + linkType: hard + "@nestjs/bull-shared@npm:^10.1.1": version: 10.1.1 resolution: "@nestjs/bull-shared@npm:10.1.1"