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
This commit is contained in:
Somay Chauhan
2025-04-17 16:36:31 +00:00
committed by GitHub
parent 11a41e0e39
commit 59ab38db98
28 changed files with 999 additions and 494 deletions
+1
View File
@@ -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",
+3 -29
View File
@@ -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 {}
@@ -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,
@@ -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<string, string | undefined> = { 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);
@@ -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);
@@ -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 ?? "",
};
}
}
}
@@ -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 {}
@@ -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<void> {
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
if (!canAccess) {
throw new ForbiddenException("Organization validation failed.");
}
}
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<any> {
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 }) {
@@ -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 {}
@@ -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<StripConnectOutputResponseDto> {
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<StripCredentialsCheckOutputResponseDto> {
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<StripCredentialsSaveOutputResponseDto> {
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 ?? "",
};
}
}
}
@@ -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 {}
@@ -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<string> {
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<ApiResponseWithoutData> {
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
"stripe_payment",
teamId
);
return await this.stripeService.validateStripeCredentials(stripeCredentials);
}
}
@@ -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<StripConnectOutputResponseDto> {
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<StripCredentialsSaveOutputResponseDto> {
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<string, string | undefined> = { 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<StripCredentialsCheckOutputResponseDto> {
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<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
@@ -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 {
@@ -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],
@@ -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<AppConfig>,
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 }> {
@@ -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],
+198 -41
View File
@@ -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 <token>` where `<token>` 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"
}
},
@@ -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";
+190 -39
View File
@@ -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 <token>` where `<token>` 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"
}
},
@@ -139,7 +139,7 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
form.reset();
setIsDialogOpen(false);
toast({
description: "Calendar credentials added successfully",
description: "Calendar credentials added successfully",
});
onSuccess?.();
} else {
@@ -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<Partial<StripeConnectProps>> = ({
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<Partial<StripeConnectProps>> = ({
"",
"md:rounded-md",
className,
isChecking && "animate-pulse",
isLoading && "animate-pulse bg-gray-200",
isDisabled && "cursor-not-allowed",
!isDisabled && "cursor-pointer"
)}
@@ -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 <div>Checking...</div>;
if (!checked) return <div>Checking...</div>;
return (
<div>
@@ -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<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/stripe/check`)
?.get<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(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<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/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,
};
};
@@ -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<ApiResponse<{ authUrl: string }>>(
`/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<ApiResponse<{ authUrl: string }>>(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();
@@ -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`}>
<Navbar username={props.calUsername} />
<div className="my-8">
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} teamId={35} />
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} teamId={20} />
</div>
</main>
);
-2
View File
@@ -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";
+12
View File
@@ -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"