From 063400fcbcdc809114ce8d227918262a7c470301 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Fri, 6 Dec 2024 19:03:04 +0530 Subject: [PATCH] feat: conferencing atoms (#17988) * added api/v2 for zoom connect * Update conferencing.controller.ts * fix: added api for ConferencingAppsViewPlatformWrapper * type fixes * added getBulkEventTypes api for bulk-update default location * added bulk-update-to-default-location * fix:bulk-update-to-default-location * invalidated * fixed returnTo and onErrorReturnTo * make logos work for conferencing atoms * smalll improvements * fixup * share common types * type fix, and fixed a typo * fix: type-error * fix: json.parse(json.parse()) * fix: invalidate query * fix: removing app throws error in the main app * undo platform-libraries-1.2.3 * update platform-libraries version --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- apps/api/v2/package.json | 2 +- .../v2/src/modules/apps/apps.repository.ts | 33 +++ apps/api/v2/src/modules/atoms/atoms.module.ts | 2 + .../atoms/controllers/atoms.controller.ts | 47 +++- .../atoms/inputs/event-types-app.input.ts | 11 +- .../services/conferencing-atom.service.ts | 25 ++ .../services/event-types-atom.service.ts | 18 +- .../conferencing/conferencing.module.ts | 9 +- .../controllers/conferencing.controller.ts | 145 ++++++++-- .../get-conferencing-apps-oauth-url.ts | 22 ++ .../outputs/get-conferencing-apps.output.ts | 6 + .../get-default-conferencing-app.output.ts | 29 ++ .../repositories/conferencing.respository.ts | 6 + .../services/conferencing.service.ts | 51 +++- .../services/google-meet.service.ts | 25 -- .../services/zoom-video.service.ts | 113 ++++++++ apps/api/v2/swagger/documentation.json | 183 +++++++++++- .../[category]/installed-category-view.tsx | 34 ++- .../availability/[schedule]/schedule-view.tsx | 10 + .../availability/availability-view.tsx | 10 + .../settings/developer/api-keys-view.tsx | 10 +- apps/web/public/static/locales/en/common.json | 2 + docs/api-reference/v2/openapi.json | 162 ++++++++++- packages/features/apps/components/AppList.tsx | 55 ++-- .../features/apps/components/AppListCard.tsx | 3 +- .../components/AppSetDefaultLinkDialog.tsx | 27 +- .../components/DisconnectIntegrationModal.tsx | 16 +- .../credentials/handleDeleteCredential.ts | 2 +- .../ee/components/CommonSkeletonLoaders.tsx | 2 +- .../attributes/attributes-list-view.tsx | 1 - .../ee/teams/pages/team-appearance-view.tsx | 2 +- .../ee/teams/pages/team-listing-view.tsx | 1 - .../ee/teams/pages/team-members-view.tsx | 1 - .../ee/teams/pages/team-profile-view.tsx | 1 - .../BulkEditDefaultForEventsModal.tsx | 28 +- .../lib/bulkUpdateEventsToDefaultLocation.ts | 59 ++++ packages/lib/getConnectedApps.ts | 232 +++++++++++++++ .../availability/AvailabilitySettings.tsx | 11 +- .../ConferencingAppsViewPlatformWrapper.tsx | 264 ++++++++++++++++++ .../ConferencingAppsViewWebWrapper.tsx | 39 ++- ...omBulkUpdateEventTypesToDefaultLocation.ts | 43 +++ .../hooks/useAtomGetEventTypes.ts | 29 ++ .../useAtomsGetInstalledConferencingApps.ts | 28 ++ .../conferencing-apps/hooks/useConnect.ts | 93 ++++++ .../hooks/useDeleteCredential.ts | 30 ++ .../hooks/useGetDefaultConferencingApp.ts | 34 +++ .../useUpdateUserDefaultConferencingApp.ts | 34 +++ packages/platform/atoms/index.ts | 1 + packages/platform/constants/apps.ts | 6 +- .../base/src/components/Navbar/index.tsx | 4 + .../base/src/pages/conferencing-apps.tsx | 22 ++ packages/platform/libraries/index.ts | 8 + .../loggedInViewer/integrations.handler.ts | 225 +-------------- .../bulkUpdateToDefaultLocation.handler.ts | 55 +--- 54 files changed, 1898 insertions(+), 413 deletions(-) create mode 100644 apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts create mode 100644 apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts create mode 100644 apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts create mode 100644 apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts create mode 100644 packages/lib/bulkUpdateEventsToDefaultLocation.ts create mode 100644 packages/lib/getConnectedApps.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewPlatformWrapper.tsx create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useAtomBulkUpdateEventTypesToDefaultLocation.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useAtomGetEventTypes.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts create mode 100644 packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts create mode 100644 packages/platform/examples/base/src/pages/conferencing-apps.tsx diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index d5e7c0d439..fd1841d9a8 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -29,7 +29,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.69", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.70", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts index e045f084d0..5495abb49f 100644 --- a/apps/api/v2/src/modules/apps/apps.repository.ts +++ b/apps/api/v2/src/modules/apps/apps.repository.ts @@ -22,6 +22,15 @@ export class AppsRepository { }); } + async deleteAppCredentials(credentialIdsToDelete: number[], userId: number) { + return this.dbWrite.prisma.credential.deleteMany({ + where: { + id: { in: credentialIdsToDelete }, + userId, + }, + }); + } + async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) { return this.dbWrite.prisma.credential.create({ data: { @@ -32,4 +41,28 @@ export class AppsRepository { }, }); } + + async findAppCredential({ + type, + appId, + userId, + teamId, + }: { + type: string; + appId: string; + userId?: number; + teamId?: number; + }) { + return this.dbWrite.prisma.credential.findMany({ + select: { + id: true, + }, + where: { + type, + userId, + teamId, + appId, + }, + }); + } } diff --git a/apps/api/v2/src/modules/atoms/atoms.module.ts b/apps/api/v2/src/modules/atoms/atoms.module.ts index 157913db3a..47c4065c20 100644 --- a/apps/api/v2/src/modules/atoms/atoms.module.ts +++ b/apps/api/v2/src/modules/atoms/atoms.module.ts @@ -2,6 +2,7 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0 import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository"; import { AtomsRepository } from "@/modules/atoms/atoms.repository"; import { AtomsController } from "@/modules/atoms/controllers/atoms.controller"; +import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service"; import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; @@ -15,6 +16,7 @@ import { Module } from "@nestjs/common"; imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule], providers: [ EventTypesAtomService, + ConferencingAtomsService, MembershipsRepository, CredentialsRepository, UsersRepository, diff --git a/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts b/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts index dcfb6253ac..5452415077 100644 --- a/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts +++ b/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts @@ -1,5 +1,9 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; -import { EventTypesAppInput } from "@/modules/atoms/inputs/event-types-app.input"; +import { + BulkUpdateEventTypeToDefaultLocationDto, + EventTypesAppInput, +} from "@/modules/atoms/inputs/event-types-app.input"; +import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service"; import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; @@ -18,8 +22,9 @@ import { } from "@nestjs/common"; import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; -import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; import type { UpdateEventTypeReturn } from "@calcom/platform-libraries"; +import { ConnectedApps } from "@calcom/platform-libraries"; import { ApiResponse } from "@calcom/platform-types"; /* @@ -36,7 +41,10 @@ these endpoints should not be recommended for use by third party and are exclude @DocsTags("Atoms - endpoints for atoms") @DocsExcludeController(true) export class AtomsController { - constructor(private readonly eventTypesService: EventTypesAtomService) {} + constructor( + private readonly eventTypesService: EventTypesAtomService, + private readonly conferencingService: ConferencingAtomsService + ) {} @Get("event-types/:eventTypeId") @Version(VERSION_NEUTRAL) @@ -52,6 +60,17 @@ export class AtomsController { }; } + @Get("/event-types") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async getAtomEventTypes(@GetUser("id") userId: number): Promise> { + const eventType = await this.eventTypesService.getUserEventTypes(userId); + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + @Get("event-types-app/:appSlug") @Version(VERSION_NEUTRAL) @UseGuards(ApiAuthGuard) @@ -84,6 +103,19 @@ export class AtomsController { }; } + @Patch("/event-types/bulk-update-to-default-location") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async bulkUpdateAtomEventTypes( + @GetUser() user: UserWithProfile, + @Body() body: BulkUpdateEventTypeToDefaultLocationDto + ): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> { + await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds); + return { + status: SUCCESS_STATUS, + }; + } + @Patch("event-types/:eventTypeId") @Version(VERSION_NEUTRAL) @UseGuards(ApiAuthGuard) @@ -114,4 +146,13 @@ export class AtomsController { data: eventType, }; } + + @Get("/conferencing") + @Version(VERSION_NEUTRAL) + @UseGuards(ApiAuthGuard) + async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise> { + const conferencingApps = await this.conferencingService.getConferencingApps(user); + + return { status: SUCCESS_STATUS, data: conferencingApps }; + } } diff --git a/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts b/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts index 5bb99c4b50..42f0e7ce1c 100644 --- a/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts +++ b/apps/api/v2/src/modules/atoms/inputs/event-types-app.input.ts @@ -1,5 +1,5 @@ -import { Transform } from "class-transformer"; -import { IsNumber, IsOptional } from "class-validator"; +import { Transform, Type } from "class-transformer"; +import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator"; export class EventTypesAppInput { @Transform(({ value }: { value: string }) => value && parseInt(value)) @@ -7,3 +7,10 @@ export class EventTypesAppInput { @IsOptional() teamId?: number; } + +export class BulkUpdateEventTypeToDefaultLocationDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + eventTypeIds!: number[]; +} diff --git a/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts new file mode 100644 index 0000000000..de591fcccb --- /dev/null +++ b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts @@ -0,0 +1,25 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { getConnectedApps, ConnectedApps } from "@calcom/platform-libraries"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class ConferencingAtomsService { + private logger = new Logger("ConferencingAtomService"); + + constructor(private readonly dbWrite: PrismaWriteService) {} + + async getConferencingApps(user: UserWithProfile): Promise { + return getConnectedApps({ + user, + input: { + variant: "conferencing", + onlyInstalled: true, + }, + prisma: this.dbWrite.prisma as unknown as PrismaClient, + }); + } +} diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index 0d86ca8671..b568579cd4 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -19,6 +19,9 @@ import { getAppFromSlug, MembershipRole, EventTypeMetaDataSchema, + getClientSecretFromPayment, + getBulkEventTypes, + bulkUpdateEventsToDefaultLocation, } from "@calcom/platform-libraries"; import type { App, @@ -29,8 +32,7 @@ import type { TDependencyData, CredentialPayload, } from "@calcom/platform-libraries"; -import { getClientSecretFromPayment } from "@calcom/platform-libraries"; -import { PrismaClient } from "@calcom/prisma/client"; +import { PrismaClient } from "@calcom/prisma"; type EnabledAppType = App & { credential: CredentialDataWithTeamName; @@ -81,6 +83,10 @@ export class EventTypesAtomService { return eventType; } + async getUserEventTypes(userId: number) { + return getBulkEventTypes(userId); + } + async updateTeamEventType( eventTypeId: number, body: TUpdateEventTypeInputSchema, @@ -317,4 +323,12 @@ export class EventTypesAtomService { profile, }; } + + async bulkUpdateEventTypesDefaultLocation(user: UserWithProfile, eventTypeIds: number[]) { + return bulkUpdateEventsToDefaultLocation({ + eventTypeIds, + user, + prisma: this.dbWrite.prisma as unknown as PrismaClient, + }); + } } diff --git a/apps/api/v2/src/modules/conferencing/conferencing.module.ts b/apps/api/v2/src/modules/conferencing/conferencing.module.ts index efad9fd8ee..0bc314c754 100644 --- a/apps/api/v2/src/modules/conferencing/conferencing.module.ts +++ b/apps/api/v2/src/modules/conferencing/conferencing.module.ts @@ -1,20 +1,27 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller"; import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository"; import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service"; +import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +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], + imports: [PrismaModule, ConfigModule], providers: [ ConferencingService, ConferencingRepository, GoogleMeetService, CredentialsRepository, UsersRepository, + TokensRepository, + ZoomVideoService, + AppsRepository, ], exports: [], controllers: [ConferencingController], 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 97fb9e52d5..83a6eb618e 100644 --- a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts @@ -1,17 +1,27 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { + ConferencingAppsOauthUrlOutputDto, + GetConferencingAppsOauthUrlResponseDto, +} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url"; import { ConferencingAppsOutputResponseDto, ConferencingAppOutputResponseDto, ConferencingAppsOutputDto, + DisconnectConferencingAppOutputResponseDto, } from "@/modules/conferencing/outputs/get-conferencing-apps.output"; +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 { GoogleMeetService } from "@/modules/conferencing/services/google-meet.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 { Controller, Get, + Query, HttpCode, HttpStatus, Logger, @@ -20,11 +30,24 @@ import { Param, BadRequestException, Delete, + Headers, + Redirect, + UnauthorizedException, + Req, } from "@nestjs/common"; import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { plainToInstance } from "class-transformer"; +import { Request } from "express"; -import { CONFERENCING_APPS, GOOGLE_MEET, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export type OAuthCallbackState = { + accessToken: string; + teamId?: number; + fromApp?: boolean; + returnTo?: string; + onErrorReturnTo?: string; +}; @Controller({ path: "/v2/conferencing", @@ -35,8 +58,10 @@ export class ConferencingController { private readonly logger = new Logger("Platform Gcal Provider"); constructor( + private readonly tokensRepository: TokensRepository, private readonly conferencingService: ConferencingService, - private readonly googleMeetService: GoogleMeetService + private readonly googleMeetService: GoogleMeetService, + private readonly zoomVideoService: ZoomVideoService ) {} @Post("/:app/connect") @@ -56,16 +81,89 @@ export class ConferencingController { default: throw new BadRequestException( "Invalid conferencing app, available apps are: ", - CONFERENCING_APPS.join(", ") + [GOOGLE_MEET].join(", ") ); } } + @Get("/:app/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Get oauth conferencing app auth url" }) + async redirect( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @Param("app") app: string, + @Query("returnTo") returnTo?: string, + @Query("onErrorReturnTo") onErrorReturnTo?: string + ): Promise { + let credential; + const origin = req.headers.origin; + const accessToken = authorization.replace("Bearer ", ""); + + const state: OAuthCallbackState = { + returnTo: returnTo ?? origin, + onErrorReturnTo: onErrorReturnTo ?? origin, + fromApp: false, + accessToken, + }; + + switch (app) { + case ZOOM: + credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state)); + return { + status: SUCCESS_STATUS, + data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential), + }; + + default: + throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", ")); + } + } + + @Get("/:app/oauth/callback") + @UseGuards() + @Redirect(undefined, 301) + @ApiOperation({ summary: "conferencing apps oauths callback" }) + async save( + @Query("state") state: string, + @Param("app") app: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined + ): Promise<{ url: string }> { + 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."); + } + + switch (app) { + case ZOOM: + return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId); + + default: + throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", ")); + } + } catch (error) { + return { + url: decodedCallbackState.onErrorReturnTo ?? "", + }; + } + } + @Get("/") @HttpCode(HttpStatus.OK) @UseGuards(ApiAuthGuard) @ApiOperation({ summary: "List your conferencing applications" }) - async listConferencingApps(@GetUser("id") userId: number): Promise { + async listInstalledConferencingApps( + @GetUser("id") userId: number + ): Promise { const conferencingApps = await this.conferencingService.getConferencingApps(userId); const data = conferencingApps.map((conferencingApps) => @@ -83,18 +181,17 @@ export class ConferencingController { @GetUser("id") userId: number, @Param("app") app: string ): Promise { - switch (app) { - case GOOGLE_MEET: - await this.googleMeetService.setDefault(userId); + await this.conferencingService.setDefaultConferencingApp(userId, app); + return { status: SUCCESS_STATUS }; + } - return { status: SUCCESS_STATUS }; - - default: - throw new BadRequestException( - "Invalid conferencing app, available apps are: ", - CONFERENCING_APPS.join(", ") - ); - } + @Get("/default") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ summary: "Get your default conferencing application" }) + async getDefault(@GetUser("id") userId: number): Promise { + const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId); + return { status: SUCCESS_STATUS, data: defaultconferencingApp }; } @Delete("/:app/disconnect") @@ -102,20 +199,10 @@ export class ConferencingController { @UseGuards(ApiAuthGuard) @ApiOperation({ summary: "Disconnect your conferencing application" }) async disconnect( - @GetUser("id") userId: number, + @GetUser() user: UserWithProfile, @Param("app") app: string - ): Promise { - switch (app) { - case GOOGLE_MEET: - const credential = await this.googleMeetService.disconnectGoogleMeetApp(userId); - - return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) }; - - default: - throw new BadRequestException( - "Invalid conferencing app, available apps are: ", - CONFERENCING_APPS.join(", ") - ); - } + ): Promise { + await this.conferencingService.disconnectConferencingApp(user, app); + return { status: SUCCESS_STATUS }; } } diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts new file mode 100644 index 0000000000..cf9827b7e4 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps-oauth-url.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class ConferencingAppsOauthUrlOutputDto { + @IsString() + @Expose() + readonly url!: string; +} + +export class GetConferencingAppsOauthUrlResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => ConferencingAppsOauthUrlOutputDto) + data!: ConferencingAppsOauthUrlOutputDto; +} diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts index c1f4510384..3327ebc26c 100644 --- a/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts +++ b/apps/api/v2/src/modules/conferencing/outputs/get-conferencing-apps.output.ts @@ -48,3 +48,9 @@ export class ConferencingAppOutputResponseDto { @Type(() => ConferencingAppsOutputDto) data!: ConferencingAppsOutputDto; } + +export class DisconnectConferencingAppOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts b/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts new file mode 100644 index 0000000000..4ed25b2099 --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/outputs/get-default-conferencing-app.output.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class DefaultConferencingAppsOutputDto { + @IsString() + @IsOptional() + @Expose() + readonly appSlug?: string; + + @IsString() + @IsOptional() + @Expose() + readonly appLink?: string; +} + +export class GetDefaultConferencingAppOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @IsOptional() + @Type(() => DefaultConferencingAppsOutputDto) + data?: DefaultConferencingAppsOutputDto; +} diff --git a/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts b/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts index 481b52b65b..546a5df73e 100644 --- a/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts +++ b/apps/api/v2/src/modules/conferencing/repositories/conferencing.respository.ts @@ -22,4 +22,10 @@ export class ConferencingRepository { where: { userId, type: GOOGLE_MEET_TYPE }, }); } + + async findConferencingApp(userId: number, app: string) { + return this.dbRead.prisma.credential.findFirst({ + where: { userId, appId: app }, + }); + } } 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 1ef8ec4a0d..c80b30f876 100644 --- a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts @@ -1,14 +1,61 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository"; -import { Logger } from "@nestjs/common"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common"; import { Injectable } from "@nestjs/common"; +import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants"; +import { userMetadata, handleDeleteCredential } from "@calcom/platform-libraries"; + @Injectable() export class ConferencingService { private logger = new Logger("ConferencingService"); - constructor(private readonly conferencingRepository: ConferencingRepository) {} + constructor( + private readonly conferencingRepository: ConferencingRepository, + private readonly usersRepository: UsersRepository + ) {} async getConferencingApps(userId: number) { return this.conferencingRepository.findConferencingApps(userId); } + + async getUserDefaultConferencingApp(userId: number) { + const user = await this.usersRepository.findById(userId); + return userMetadata.parse(user?.metadata)?.defaultConferencingApp; + } + + async checkAppIsValidAndConnected(userId: number, app: string) { + if (!CONFERENCING_APPS.includes(app)) { + throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", ")); + } + const credential = await this.conferencingRepository.findConferencingApp(userId, app); + + if (!credential) { + throw new BadRequestException(`${app} not connected.`); + } + return credential; + } + + async disconnectConferencingApp(user: UserWithProfile, app: string) { + const credential = await this.checkAppIsValidAndConnected(user.id, app); + return handleDeleteCredential({ + userId: user.id, + userMetadata: user?.metadata, + credentialId: credential.id, + }); + } + + async setDefaultConferencingApp(userId: number, app: string) { + // cal-video is global, so we can skip this check + if (app !== CAL_VIDEO) { + await this.checkAppIsValidAndConnected(userId, app); + } + const user = await this.usersRepository.setDefaultConferencingApp(userId, app); + const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } }; + if (metadata?.defaultConferencingApp?.appSlug !== app) { + throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`); + } + return true; + } } diff --git a/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts b/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts index 233a641a64..85272c360e 100644 --- a/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts @@ -43,29 +43,4 @@ export class GoogleMeetService { return googleMeetCredential; } - - async disconnectGoogleMeetApp(userId: number) { - const googleMeet = await this.conferencingRepository.findGoogleMeet(userId); - - if (!googleMeet) { - throw new BadRequestException("Google Meet is not connected."); - } - - const googleMeetCredential = await this.credentialsRepository.deleteUserCredentialById( - userId, - googleMeet.id - ); - - return googleMeetCredential; - } - - async setDefault(userId: number) { - const user = await this.usersRepository.setDefaultConferencingApp(userId, GOOGLE_MEET); - const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } }; - - if (metadata?.defaultConferencingApp?.appSlug !== GOOGLE_MEET) { - throw new InternalServerErrorException("Could not set Google Meet as default conferencing app"); - } - return true; - } } diff --git a/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts new file mode 100644 index 0000000000..e03b6f6dae --- /dev/null +++ b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts @@ -0,0 +1,113 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller"; +import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; + +import { ZOOM, ZOOM_TYPE } from "@calcom/platform-constants"; + +import stringify = require("qs-stringify"); + +const zoomAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +@Injectable() +export class ZoomVideoService { + private logger = new Logger("ZoomVideoService"); + private redirectUri = `${this.config.get("api.url")}/conferencing/zoom/oauth/callback`; + + constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {} + + async getZoomAppKeys() { + const app = await this.appsRepository.getAppBySlug(ZOOM); + + const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys); + + if (!client_id) { + throw new NotFoundException("Zoom app not found"); + } + + if (!client_secret) { + throw new NotFoundException("Zoom app not found"); + } + + return { client_id, client_secret }; + } + + async generateZoomAuthUrl(state: string) { + const { client_id } = await this.getZoomAppKeys(); + + const params = { + response_type: "code", + client_id, + redirect_uri: this.redirectUri, + state: state, + }; + + const query = stringify(params); + const url = `https://zoom.us/oauth/authorize?${query}`; + return { url }; + } + + async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) { + const { client_id, client_secret } = await this.getZoomAppKeys(); + const redirectUri = encodeURI(this.redirectUri); + const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; + const result = await fetch( + `https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`, + { + method: "POST", + headers: { + Authorization: authHeader, + }, + } + ); + + if (result.status !== 200) { + let errorMessage = "Something is wrong with Zoom API"; + try { + const responseBody = await result.json(); + errorMessage = responseBody.error; + } catch (e) { + errorMessage = await result.clone().text(); + } + throw new BadRequestException(errorMessage); + } + + const responseBody = await result.json(); + + if (responseBody.error) { + throw new BadRequestException(responseBody.error); + } + + responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); + delete responseBody.expires_in; + + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } + const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({ + type: ZOOM_TYPE, + userId, + appId: ZOOM, + }); + + const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id); + if (credentialIdsToDelete.length > 0) { + await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId); + } + + await this.appsRepository.createAppCredential( + ZOOM_TYPE, + responseBody as unknown as Prisma.InputJsonObject, + userId, + ZOOM + ); + + return { url: state.returnTo ?? "" }; + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 72b6a7404e..5edde71e5e 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3862,9 +3862,104 @@ ] } }, + "/v2/conferencing/{app}/oauth/auth-url": { + "get": { + "operationId": "ConferencingController_redirect", + "summary": "Get oauth conferencing app auth url", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "app", + "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/GetConferencingAppsOauthUrlResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, + "/v2/conferencing/{app}/oauth/callback": { + "get": { + "operationId": "ConferencingController_save", + "summary": "conferencing apps oauths callback", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Conferencing" + ] + } + }, "/v2/conferencing": { "get": { - "operationId": "ConferencingController_listConferencingApps", + "operationId": "ConferencingController_listInstalledConferencingApps", "summary": "List your conferencing applications", "parameters": [], "responses": { @@ -3915,6 +4010,28 @@ ] } }, + "/v2/conferencing/default": { + "get": { + "operationId": "ConferencingController_getDefault", + "summary": "Get your default conferencing application", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetDefaultConferencingAppOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Conferencing" + ] + } + }, "/v2/conferencing/{app}/disconnect": { "delete": { "operationId": "ConferencingController_disconnect", @@ -3935,7 +4052,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConferencingAppOutputResponseDto" + "$ref": "#/components/schemas/DisconnectConferencingAppOutputResponseDto" } } } @@ -14726,6 +14843,22 @@ "data" ] }, + "GetConferencingAppsOauthUrlResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, "ConferencingAppsOutputResponseDto": { "type": "object", "properties": { @@ -14764,6 +14897,52 @@ "required": [ "status" ] + }, + "DefaultConferencingAppsOutputDto": { + "type": "object", + "properties": { + "appSlug": { + "type": "string" + }, + "appLink": { + "type": "string" + } + } + }, + "GetDefaultConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/DefaultConferencingAppsOutputDto" + } + }, + "required": [ + "status" + ] + }, + "DisconnectConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] } } } diff --git a/apps/web/modules/apps/installed/[category]/installed-category-view.tsx b/apps/web/modules/apps/installed/[category]/installed-category-view.tsx index 87fa5ef212..ba392420aa 100644 --- a/apps/web/modules/apps/installed/[category]/installed-category-view.tsx +++ b/apps/web/modules/apps/installed/[category]/installed-category-view.tsx @@ -3,8 +3,8 @@ import { useReducer } from "react"; import getAppCategoryTitle from "@calcom/app-store/_utils/getAppCategoryTitle"; -import type { UpdateDefaultConferencingAppParams } from "@calcom/features/apps/components/AppList"; -import { AppList } from "@calcom/features/apps/components/AppList"; +import { AppList, type HandleDisconnect } from "@calcom/features/apps/components/AppList"; +import type { UpdateUsersDefaultConferencingAppParams } from "@calcom/features/apps/components/AppSetDefaultLinkDialog"; import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; import type { RemoveAppParams } from "@calcom/features/apps/components/DisconnectIntegrationModal"; import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal"; @@ -31,7 +31,7 @@ import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout"; interface IntegrationsContainerProps { variant?: AppCategories; exclude?: AppCategories[]; - handleDisconnect: (credentialId: number) => void; + handleDisconnect: HandleDisconnect; } const IntegrationsContainer = ({ @@ -54,17 +54,25 @@ const IntegrationsContainer = ({ const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation(); - const handleUpdateDefaultConferencingApp = ({ appSlug, callback }: UpdateDefaultConferencingAppParams) => { + const { data: eventTypesQueryData, isFetching: isEventTypesFetching } = + trpc.viewer.eventTypes.bulkEventFetch.useQuery(); + + const handleUpdateUserDefaultConferencingApp = ({ + appSlug, + onSuccessCallback, + onErrorCallback, + }: UpdateUsersDefaultConferencingAppParams) => { updateDefaultAppMutation.mutate( { appSlug }, { onSuccess: () => { showToast("Default app updated successfully", "success"); utils.viewer.getUsersDefaultConferencingApp.invalidate(); - callback(); + onSuccessCallback(); }, onError: (error) => { showToast(`Error: ${error.message}`, "error"); + onErrorCallback(); }, } ); @@ -84,6 +92,14 @@ const IntegrationsContainer = ({ ); }; + const handleConnectDisconnectIntegrationMenuToggle = () => { + utils.viewer.integrations.invalidate(); + }; + + const handleBulkEditDialogToggle = () => { + utils.viewer.getUsersDefaultConferencingApp.invalidate(); + }; + // TODO: Refactor and reuse getAppCategories? const emptyIcon: Record["name"]> = { calendar: "calendar", @@ -146,9 +162,13 @@ const IntegrationsContainer = ({ data={data} variant={variant} defaultConferencingApp={defaultConferencingApp} - handleUpdateDefaultConferencingApp={handleUpdateDefaultConferencingApp} + handleUpdateUserDefaultConferencingApp={handleUpdateUserDefaultConferencingApp} handleBulkUpdateDefaultLocation={handleBulkUpdateDefaultLocation} isBulkUpdateDefaultLocationPending={updateDefaultAppMutation.isPending} + eventTypes={eventTypesQueryData?.eventTypes} + isEventTypesFetching={isEventTypesFetching} + handleConnectDisconnectIntegrationMenuToggle={handleConnectDisconnectIntegrationMenuToggle} + handleBulkEditDialogToggle={handleBulkEditDialogToggle} /> ); @@ -187,7 +207,7 @@ export default function InstalledApps(props: PageProps) { updateData({ isOpen: false, credentialId: null }); }; - const handleDisconnect = (credentialId: number, teamId?: number) => { + const handleDisconnect = (credentialId: number, app: string, teamId?: number) => { updateData({ isOpen: true, credentialId, teamId }); }; diff --git a/apps/web/modules/availability/[schedule]/schedule-view.tsx b/apps/web/modules/availability/[schedule]/schedule-view.tsx index 4de9f78185..fc5a8abad0 100644 --- a/apps/web/modules/availability/[schedule]/schedule-view.tsx +++ b/apps/web/modules/availability/[schedule]/schedule-view.tsx @@ -50,6 +50,9 @@ export const AvailabilitySettingsWebWrapper = ({ const bulkUpdateDefaultAvailabilityMutation = trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation(); + const { data: eventTypesQueryData, isFetching: isEventTypesFetching } = + trpc.viewer.eventTypes.bulkEventFetch.useQuery(); + const bulkUpdateFunction = ({ eventTypeIds, callback }: BulkUpdatParams) => { bulkUpdateDefaultAvailabilityMutation.mutate( { @@ -65,6 +68,10 @@ export const AvailabilitySettingsWebWrapper = ({ ); }; + const handleBulkEditDialogToggle = () => { + utils.viewer.getUsersDefaultConferencingApp.invalidate(); + }; + const isDefaultSchedule = me.data?.defaultScheduleId === scheduleId; const updateMutation = trpc.viewer.availability.schedule.update.useMutation({ @@ -142,6 +149,9 @@ export const AvailabilitySettingsWebWrapper = ({ setIsOpen: setIsBulkUpdateModalOpen, save: bulkUpdateFunction, isSaving: bulkUpdateDefaultAvailabilityMutation.isPending, + eventTypes: eventTypesQueryData?.eventTypes, + isEventTypesFetching, + handleBulkEditDialogToggle: handleBulkEditDialogToggle, }} /> ); diff --git a/apps/web/modules/availability/availability-view.tsx b/apps/web/modules/availability/availability-view.tsx index a884ab62a8..8196fded62 100644 --- a/apps/web/modules/availability/availability-view.tsx +++ b/apps/web/modules/availability/availability-view.tsx @@ -83,6 +83,9 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab const bulkUpdateDefaultAvailabilityMutation = trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation(); + const { data: eventTypesQueryData, isFetching: isEventTypesFetching } = + trpc.viewer.eventTypes.bulkEventFetch.useQuery(); + const bulkUpdateFunction = ({ eventTypeIds, callback }: BulkUpdatParams) => { bulkUpdateDefaultAvailabilityMutation.mutate( { @@ -98,6 +101,10 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab ); }; + const handleBulkEditDialogToggle = () => { + utils.viewer.getUsersDefaultConferencingApp.invalidate(); + }; + const duplicateMutation = trpc.viewer.availability.schedule.duplicate.useMutation({ onSuccess: async ({ schedule }) => { await router.push(`/availability/${schedule.id}`); @@ -161,6 +168,9 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab setOpen={setBulkUpdateModal} bulkUpdateFunction={bulkUpdateFunction} description={t("default_schedules_bulk_description")} + eventTypes={eventTypesQueryData?.eventTypes} + isEventTypesFetching={isEventTypesFetching} + handleBulkEditDialogToggle={handleBulkEditDialogToggle} /> )} diff --git a/apps/web/modules/settings/developer/api-keys-view.tsx b/apps/web/modules/settings/developer/api-keys-view.tsx index a5ec587782..71e8e77129 100644 --- a/apps/web/modules/settings/developer/api-keys-view.tsx +++ b/apps/web/modules/settings/developer/api-keys-view.tsx @@ -9,15 +9,7 @@ import ApiKeyListItem from "@calcom/features/ee/api-keys/components/ApiKeyListIt import { APP_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { - Button, - Dialog, - DialogContent, - EmptyScreen, - Meta, - SkeletonContainer, - SkeletonText, -} from "@calcom/ui"; +import { Button, Dialog, DialogContent, EmptyScreen, SkeletonContainer, SkeletonText } from "@calcom/ui"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { return ( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4cd6e36d0a..8999016efd 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2834,6 +2834,8 @@ "multi_select": "Multi Select", "group_meeting": "Group Meeting", "salesforce_ignore_guests": "Do not create new records for guests added to the booking", + "google_meet": "Google Meet", + "zoom": "Zoom", "lock_attribute_for_assignment": "Lock for assignment", "lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync", "attribute_edited_successfully": "Attribute edited successfully", diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index dfb70e5bc5..f1c6d1548b 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -3662,9 +3662,100 @@ "tags": ["Conferencing"] } }, + "/v2/conferencing/{app}/oauth/auth-url": { + "get": { + "operationId": "ConferencingController_redirect", + "summary": "Get oauth conferencing app auth url", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "app", + "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/GetConferencingAppsOauthUrlResponseDto" + } + } + } + } + }, + "tags": ["Conferencing"] + } + }, + "/v2/conferencing/{app}/oauth/callback": { + "get": { + "operationId": "ConferencingController_save", + "summary": "conferencing apps oauths callback", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "app", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["Conferencing"] + } + }, "/v2/conferencing": { "get": { - "operationId": "ConferencingController_listConferencingApps", + "operationId": "ConferencingController_listInstalledConferencingApps", "summary": "List your conferencing applications", "parameters": [], "responses": { @@ -3711,6 +3802,26 @@ "tags": ["Conferencing"] } }, + "/v2/conferencing/default": { + "get": { + "operationId": "ConferencingController_getDefault", + "summary": "Get your default conferencing application", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetDefaultConferencingAppOutputResponseDto" + } + } + } + } + }, + "tags": ["Conferencing"] + } + }, "/v2/conferencing/{app}/disconnect": { "delete": { "operationId": "ConferencingController_disconnect", @@ -3731,7 +3842,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConferencingAppOutputResponseDto" + "$ref": "#/components/schemas/DisconnectConferencingAppOutputResponseDto" } } } @@ -13210,6 +13321,17 @@ }, "required": ["status", "data"] }, + "GetConferencingAppsOauthUrlResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + } + }, + "required": ["status"] + }, "ConferencingAppsOutputResponseDto": { "type": "object", "properties": { @@ -13237,6 +13359,42 @@ } }, "required": ["status"] + }, + "DefaultConferencingAppsOutputDto": { + "type": "object", + "properties": { + "appSlug": { + "type": "string" + }, + "appLink": { + "type": "string" + } + } + }, + "GetDefaultConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/DefaultConferencingAppsOutputDto" + } + }, + "required": ["status"] + }, + "DisconnectConferencingAppOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + } + }, + "required": ["status"] } } } diff --git a/packages/features/apps/components/AppList.tsx b/packages/features/apps/components/AppList.tsx index 9533029fc6..e29c9e3e60 100644 --- a/packages/features/apps/components/AppList.tsx +++ b/packages/features/apps/components/AppList.tsx @@ -5,12 +5,16 @@ import { InstallAppButton } from "@calcom/app-store/components"; import { getLocationFromApp, type EventLocationType } from "@calcom/app-store/locations"; import type { CredentialOwner } from "@calcom/app-store/types"; import AppListCard from "@calcom/features/apps/components/AppListCard"; +import type { UpdateUsersDefaultConferencingAppParams } from "@calcom/features/apps/components/AppSetDefaultLinkDialog"; import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog"; -import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal"; +import type { + BulkUpdatParams, + EventTypes, +} from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal"; import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AppCategories } from "@calcom/prisma/enums"; -import { trpc, type RouterOutputs } from "@calcom/trpc"; +import { type RouterOutputs } from "@calcom/trpc"; import type { App } from "@calcom/types/App"; import { Alert, @@ -24,17 +28,21 @@ import { showToast, } from "@calcom/ui"; -export type UpdateDefaultConferencingAppParams = { appSlug: string; callback: () => void }; +export type HandleDisconnect = (credentialId: number, app: App["slug"], teamId?: number) => void; interface AppListProps { variant?: AppCategories; data: RouterOutputs["viewer"]["integrations"]; - handleDisconnect: (credentialId: number) => void; + handleDisconnect: HandleDisconnect; listClassName?: string; defaultConferencingApp: RouterOutputs["viewer"]["getUsersDefaultConferencingApp"]; - handleUpdateDefaultConferencingApp: (params: UpdateDefaultConferencingAppParams) => void; + handleUpdateUserDefaultConferencingApp: (params: UpdateUsersDefaultConferencingAppParams) => void; handleBulkUpdateDefaultLocation: (params: BulkUpdatParams) => void; isBulkUpdateDefaultLocationPending: boolean; + eventTypes?: EventTypes; + isEventTypesFetching?: boolean; + handleConnectDisconnectIntegrationMenuToggle: () => void; + handleBulkEditDialogToggle: () => void; } export const AppList = ({ @@ -43,15 +51,18 @@ export const AppList = ({ variant, listClassName, defaultConferencingApp, - handleUpdateDefaultConferencingApp, + handleUpdateUserDefaultConferencingApp, handleBulkUpdateDefaultLocation, isBulkUpdateDefaultLocationPending, + eventTypes, + isEventTypesFetching, + handleConnectDisconnectIntegrationMenuToggle, + handleBulkEditDialogToggle, }: AppListProps) => { const [bulkUpdateModal, setBulkUpdateModal] = useState(false); const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>( undefined ); - const onSuccessCallback = useCallback(() => { setBulkUpdateModal(true); showToast("Default app updated successfully", "success"); @@ -98,9 +109,12 @@ export const AppList = ({ if (locationType?.linkType === "static") { setLocationType({ ...locationType, slug: appSlug }); } else { - handleUpdateDefaultConferencingApp({ + handleUpdateUserDefaultConferencingApp({ appSlug, - callback: () => setBulkUpdateModal(true), + onSuccessCallback: () => setBulkUpdateModal(true), + onErrorCallback: () => { + return; + }, }); } }}> @@ -111,11 +125,15 @@ export const AppList = ({ @@ -171,6 +189,7 @@ export const AppList = ({ locationType={locationType} setLocationType={() => setLocationType(undefined)} onSuccess={onSuccessCallback} + handleUpdateUserDefaultConferencingApp={handleUpdateUserDefaultConferencingApp} /> )} @@ -181,6 +200,9 @@ export const AppList = ({ setOpen={setBulkUpdateModal} isPending={isBulkUpdateDefaultLocationPending} description={t("default_conferencing_bulk_description")} + eventTypes={eventTypes} + isEventTypesFetching={isEventTypesFetching} + handleBulkEditDialogToggle={handleBulkEditDialogToggle} /> )} @@ -194,22 +216,19 @@ function ConnectOrDisconnectIntegrationMenuItem(props: { installed?: boolean; invalidCredentialIds?: number[]; teamId?: number; - handleDisconnect: (credentialId: number, teamId?: number) => void; + app: App["slug"]; + handleDisconnect: HandleDisconnect; + handleConnectDisconnectIntegrationMenuToggle: () => void; }) { - const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props; + const { type, credentialId, isGlobal, installed, handleDisconnect, teamId, app } = props; const { t } = useLocale(); - const utils = trpc.useUtils(); - const handleOpenChange = () => { - utils.viewer.integrations.invalidate(); - }; - if (credentialId || type === "stripe_payment" || isGlobal) { return ( handleDisconnect(credentialId, teamId)} + onClick={() => handleDisconnect(credentialId, app, teamId)} disabled={isGlobal} StartIcon="trash"> {t("remove_app")} @@ -234,7 +253,7 @@ function ConnectOrDisconnectIntegrationMenuItem(props: { {t("install")} )} - onChanged={handleOpenChange} + onChanged={props.handleConnectDisconnectIntegrationMenuToggle} /> ); } diff --git a/packages/features/apps/components/AppListCard.tsx b/packages/features/apps/components/AppListCard.tsx index 200b56dde7..1de39ea731 100644 --- a/packages/features/apps/components/AppListCard.tsx +++ b/packages/features/apps/components/AppListCard.tsx @@ -37,7 +37,8 @@ export type AppListCardProps = { const schema = z.object({ hl: z.string().optional() }); function AppListCardPlatformWrapper(props: AppListCardProps) { - return ; + const logo = `https://app.cal.com${props.logo}`; + return ; } function AppListCardWebWrapper(props: AppListCardProps) { const { slug, shouldHighlight } = props; diff --git a/packages/features/apps/components/AppSetDefaultLinkDialog.tsx b/packages/features/apps/components/AppSetDefaultLinkDialog.tsx index 825a245d1f..a01e50f086 100644 --- a/packages/features/apps/components/AppSetDefaultLinkDialog.tsx +++ b/packages/features/apps/components/AppSetDefaultLinkDialog.tsx @@ -6,7 +6,6 @@ import { z } from "zod"; import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationType } from "@calcom/app-store/locations"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc/react"; import { Button, Dialog, @@ -18,6 +17,13 @@ import { TextField, } from "@calcom/ui"; +export type UpdateUsersDefaultConferencingAppParams = { + appSlug: string; + appLink?: string; + onSuccessCallback: () => void; + onErrorCallback: () => void; +}; + type LocationTypeSetLinkDialogFormProps = { link?: string; type: EventLocationType["type"]; @@ -27,10 +33,12 @@ export function AppSetDefaultLinkDialog({ locationType, setLocationType, onSuccess, + handleUpdateUserDefaultConferencingApp, }: { locationType: EventLocationType & { slug: string }; setLocationType: Dispatch>; onSuccess: () => void; + handleUpdateUserDefaultConferencingApp: (params: UpdateUsersDefaultConferencingAppParams) => void; }) { const { t } = useLocale(); const eventLocationTypeOptions = getEventLocationType(locationType.type); @@ -41,15 +49,6 @@ export function AppSetDefaultLinkDialog({ ), }); - const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({ - onSuccess: () => { - onSuccess(); - }, - onError: () => { - showToast(`Invalid App Link Format`, "error"); - }, - }); - return ( setLocationType(undefined)}> { - updateDefaultAppMutation.mutate({ + handleUpdateUserDefaultConferencingApp({ appSlug: locationType.slug, appLink: values.link, + onSuccessCallback: () => { + onSuccess(); + }, + onErrorCallback: () => { + showToast(`Invalid App Link Format`, "error"); + }, }); setLocationType(undefined); }}> diff --git a/packages/features/apps/components/DisconnectIntegrationModal.tsx b/packages/features/apps/components/DisconnectIntegrationModal.tsx index b49c4aa49c..59ca90dbed 100644 --- a/packages/features/apps/components/DisconnectIntegrationModal.tsx +++ b/packages/features/apps/components/DisconnectIntegrationModal.tsx @@ -1,23 +1,33 @@ +import { useIsPlatform } from "@calcom/atoms/monorepo"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { App } from "@calcom/types/App"; import { Dialog, ConfirmationDialogContent } from "@calcom/ui"; -export type RemoveAppParams = { credentialId: number; teamId?: number; callback: () => void }; +export type RemoveAppParams = { + credentialId: number; + app?: App["slug"]; + teamId?: number; + callback: () => void; +}; interface DisconnectIntegrationModalProps { credentialId: number | null; isOpen: boolean; handleModelClose: () => void; teamId?: number; handleRemoveApp: (params: RemoveAppParams) => void; + app?: App["slug"] | null; } export default function DisconnectIntegrationModal({ credentialId, + app, isOpen, handleModelClose, teamId, handleRemoveApp, }: DisconnectIntegrationModalProps) { const { t } = useLocale(); + const isPlatform = useIsPlatform(); return ( { - if (credentialId) { + if (isPlatform && app && credentialId) { + handleRemoveApp({ credentialId, app, teamId, callback: () => handleModelClose() }); + } else if (credentialId) { handleRemoveApp({ credentialId, teamId, callback: () => handleModelClose() }); } }}> diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index ebb46b8296..dfc45f3299 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -39,7 +39,7 @@ const handleDeleteCredential = async ({ teamId, }: { userId: number; - userMetadata: Prisma.JsonValue; + userMetadata?: Prisma.JsonValue; credentialId: number; teamId?: number; }) => { diff --git a/packages/features/ee/components/CommonSkeletonLoaders.tsx b/packages/features/ee/components/CommonSkeletonLoaders.tsx index 5979a9decf..1280a71f67 100644 --- a/packages/features/ee/components/CommonSkeletonLoaders.tsx +++ b/packages/features/ee/components/CommonSkeletonLoaders.tsx @@ -1,5 +1,5 @@ import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; -import { Meta, SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui"; +import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui"; export const AppearanceSkeletonLoader = () => { return ( diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx index 9a72871cb5..d75e0dbe2a 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx @@ -17,7 +17,6 @@ import { DropdownMenuItem, Icon, showToast, - useMeta, } from "@calcom/ui"; import { DeleteAttributeModal } from "./DeleteAttributeModal"; diff --git a/packages/features/ee/teams/pages/team-appearance-view.tsx b/packages/features/ee/teams/pages/team-appearance-view.tsx index 870d21ec01..57c9590109 100644 --- a/packages/features/ee/teams/pages/team-appearance-view.tsx +++ b/packages/features/ee/teams/pages/team-appearance-view.tsx @@ -14,7 +14,7 @@ import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; -import { Button, Form, Meta, showToast, SettingsToggle } from "@calcom/ui"; +import { Button, Form, showToast, SettingsToggle } from "@calcom/ui"; import ThemeLabel from "../../../settings/ThemeLabel"; diff --git a/packages/features/ee/teams/pages/team-listing-view.tsx b/packages/features/ee/teams/pages/team-listing-view.tsx index f31e0a9597..ada02ac789 100644 --- a/packages/features/ee/teams/pages/team-listing-view.tsx +++ b/packages/features/ee/teams/pages/team-listing-view.tsx @@ -1,7 +1,6 @@ "use client"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Meta } from "@calcom/ui"; import { TeamsListing } from "../components"; diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index 09cf37dcad..0d7f5c3a77 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -8,7 +8,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; -import { Meta } from "@calcom/ui"; import DisableTeamImpersonation from "../components/DisableTeamImpersonation"; import InviteLinkSettingsModal from "../components/InviteLinkSettingsModal"; diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index cbd6d2f9b2..e975cf7def 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -35,7 +35,6 @@ import { ImageUploader, Label, LinkIconButton, - Meta, showToast, SkeletonAvatar, SkeletonButton, diff --git a/packages/features/eventtypes/components/BulkEditDefaultForEventsModal.tsx b/packages/features/eventtypes/components/BulkEditDefaultForEventsModal.tsx index 32544b43a3..39222328a7 100644 --- a/packages/features/eventtypes/components/BulkEditDefaultForEventsModal.tsx +++ b/packages/features/eventtypes/components/BulkEditDefaultForEventsModal.tsx @@ -3,7 +3,6 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc/react"; import { Dialog, DialogContent, Form, DialogFooter, DialogClose, Button, CheckboxField } from "@calcom/ui"; export const BulkUpdateEventSchema = z.object({ @@ -11,28 +10,35 @@ export const BulkUpdateEventSchema = z.object({ }); export type BulkUpdatParams = { eventTypeIds: number[]; callback: () => void }; +export type EventTypes = Array<{ id: number; title: string }>; -export function BulkEditDefaultForEventsModal(props: { +export function BulkEditDefaultForEventsModal({ + eventTypes, + isEventTypesFetching, + ...props +}: { open: boolean; setOpen: (open: boolean) => void; bulkUpdateFunction: (params: BulkUpdatParams) => void; isPending: boolean; description: string; + isEventTypesFetching?: boolean; + eventTypes?: EventTypes; + handleBulkEditDialogToggle: () => void; }) { const { t } = useLocale(); - const utils = trpc.useUtils(); - const { data, isFetching } = trpc.viewer.eventTypes.bulkEventFetch.useQuery(); + const form = useForm({ resolver: zodResolver(BulkUpdateEventSchema), defaultValues: { - eventTypeIds: data?.eventTypes.map((e) => e.id) ?? [], + eventTypeIds: eventTypes?.map((e) => e.id) ?? [], }, }); const eventTypesSelected = form.watch("eventTypeIds"); const isButtonDisabled = eventTypesSelected.length === 0; - if (isFetching || !open || !data?.eventTypes) return null; + if (isEventTypesFetching || !open || !eventTypes) return null; return ( @@ -50,19 +56,19 @@ export function BulkEditDefaultForEventsModal(props: { }); }}>
- {data.eventTypes.length > 0 && ( + {eventTypes.length > 0 && (
{ - form.setValue("eventTypeIds", e.target.checked ? data.eventTypes.map((e) => e.id) : []); + form.setValue("eventTypeIds", e.target.checked ? eventTypes.map((e) => e.id) : []); }} - checked={eventTypesSelected.length === data.eventTypes.length} + checked={eventTypesSelected.length === eventTypes.length} />
)} - {data.eventTypes.map((eventType) => ( + {eventTypes.map((eventType) => (
{ - utils.viewer.getUsersDefaultConferencingApp.invalidate(); + props.handleBulkEditDialogToggle(); }} /> + + + {installedApps && !installedApps.find((app) => app.slug == GOOGLE_MEET) && ( + + connect(GOOGLE_MEET)}> + {t("google_meet")} + + + )} + {installedApps && !installedApps?.find((app) => app.slug == ZOOM) && ( + + connect(ZOOM)}> + {t("zoom")} + + + )} + + + ); + }; + + return ( + + } + borderInShellHeader={true}> + <> +
+ } + success={({ data }) => { + if (!data.items.length) { + return ( + } + /> + ); + } + return ( + + ); + }} + /> +
+ + +
+
+ ); +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx index 0571f4b03d..5fd3e4b063 100644 --- a/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx +++ b/packages/platform/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper.tsx @@ -16,8 +16,14 @@ type ConferencingAppsViewWebWrapperProps = { add: string; }; -type UpdateDefaultConferencingAppParams = { appSlug: string; callback: () => void }; -type BulkUpdatParams = { eventTypeIds: number[]; callback: () => void }; +export type UpdateUsersDefaultConferencingAppParams = { + appSlug: string; + appLink?: string; + onSuccessCallback: () => void; + onErrorCallback: () => void; +}; + +export type BulkUpdatParams = { eventTypeIds: number[]; callback: () => void }; type RemoveAppParams = { credentialId: number; teamId?: number; callback: () => void }; const SkeletonLoader = () => { @@ -73,6 +79,9 @@ export const ConferencingAppsViewWebWrapper = ({ const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation(); + const { data: eventTypesQueryData, isFetching: isEventTypesFetching } = + trpc.viewer.eventTypes.bulkEventFetch.useQuery(); + const handleRemoveApp = ({ credentialId, teamId, callback }: RemoveAppParams) => { deleteCredentialMutation.mutate( { id: credentialId, teamId }, @@ -91,17 +100,31 @@ export const ConferencingAppsViewWebWrapper = ({ ); }; - const handleUpdateDefaultConferencingApp = ({ appSlug, callback }: UpdateDefaultConferencingAppParams) => { + const handleConnectDisconnectIntegrationMenuToggle = () => { + utils.viewer.integrations.invalidate(); + }; + + const handleBulkEditDialogToggle = () => { + utils.viewer.getUsersDefaultConferencingApp.invalidate(); + }; + + const handleUpdateUserDefaultConferencingApp = ({ + appSlug, + appLink, + onSuccessCallback, + onErrorCallback, + }: UpdateUsersDefaultConferencingAppParams) => { updateDefaultAppMutation.mutate( - { appSlug }, + { appSlug, appLink }, { onSuccess: () => { showToast("Default app updated successfully", "success"); utils.viewer.getUsersDefaultConferencingApp.invalidate(); - callback(); + onSuccessCallback(); }, onError: (error) => { showToast(`Error: ${error.message}`, "error"); + onErrorCallback(); }, } ); @@ -167,9 +190,13 @@ export const ConferencingAppsViewWebWrapper = ({ data={data} variant="conferencing" defaultConferencingApp={defaultConferencingApp} - handleUpdateDefaultConferencingApp={handleUpdateDefaultConferencingApp} + handleUpdateUserDefaultConferencingApp={handleUpdateUserDefaultConferencingApp} handleBulkUpdateDefaultLocation={handleBulkUpdateDefaultLocation} isBulkUpdateDefaultLocationPending={updateDefaultAppMutation.isPending} + eventTypes={eventTypesQueryData?.eventTypes} + isEventTypesFetching={isEventTypesFetching} + handleConnectDisconnectIntegrationMenuToggle={handleConnectDisconnectIntegrationMenuToggle} + handleBulkEditDialogToggle={handleBulkEditDialogToggle} /> ); }} diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomBulkUpdateEventTypesToDefaultLocation.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomBulkUpdateEventTypesToDefaultLocation.ts new file mode 100644 index 0000000000..07818d3b5b --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomBulkUpdateEventTypesToDefaultLocation.ts @@ -0,0 +1,43 @@ +import { useMutation } from "@tanstack/react-query"; + +import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { EventType } from "@calcom/prisma/client"; + +import http from "../../../lib/http"; + +export const QUERY_KEY = "bulk-update-event-types-to-default-location"; + +export type UseAtomBulkUpdateEventTypesToDefaultLocationProps = { + onSuccess?: () => void; + onError?: (err: Error) => void; + onSettled?: () => void; +}; +export const useAtomBulkUpdateEventTypesToDefaultLocation = ({ + onSuccess = () => { + return; + }, + onError = () => { + return; + }, + onSettled = () => { + return; + }, +}: UseAtomBulkUpdateEventTypesToDefaultLocationProps) => { + return useMutation({ + onSuccess, + onError, + onSettled, + mutationFn: (eventTypeIds: number[]) => { + if (!eventTypeIds || eventTypeIds.length < 1) throw new Error("Event type ids are required"); + const pathname = `/atoms/${V2_ENDPOINTS.eventTypes}/bulk-update-to-default-location`; + + return http?.patch(pathname, { eventTypeIds }).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + }); +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomGetEventTypes.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomGetEventTypes.ts new file mode 100644 index 0000000000..fba0e8fb63 --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomGetEventTypes.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; + +import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +import { useAtomsContext } from "../../../hooks/useAtomsContext"; +import http from "../../../lib/http"; + +export const QUERY_KEY = "use-get-event-types"; + +type ResponseEventType = { eventTypes: Array<{ id: number; title: string }> }; + +export const useAtomGetEventTypes = () => { + const pathname = `/atoms/${V2_ENDPOINTS.eventTypes}/`; + const { isInit, accessToken } = useAtomsContext(); + + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: () => { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: isInit && !!accessToken, + }); +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts new file mode 100644 index 0000000000..634df8c212 --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useAtomsGetInstalledConferencingApps.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ConnectedApps } from "@calcom/platform-libraries"; +import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +import { useAtomsContext } from "../../../hooks/useAtomsContext"; +import http from "../../../lib/http"; + +export const QUERY_KEY = "get-installed-conferencing-apps"; + +export const useAtomsGetInstalledConferencingApps = () => { + const pathname = `/atoms/conferencing`; + const { isInit, accessToken } = useAtomsContext(); + + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: () => { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: isInit && !!accessToken, + }); +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts new file mode 100644 index 0000000000..efb4b034e9 --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useConnect.ts @@ -0,0 +1,93 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS, ERROR_STATUS, ZOOM, GOOGLE_MEET } from "@calcom/platform-constants"; +import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; +import type { App } from "@calcom/types/App"; + +import http from "../../../lib/http"; + +export type UseGetOauthAuthUrlProps = { + returnTo?: string; + onErrorReturnTo?: string; +}; + +export const useGetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => { + return useQuery({ + queryKey: ["get-zoom-auth-url"], + staleTime: Infinity, + enabled: false, + queryFn: () => { + return http + ?.get>( + `conferencing/${ZOOM}/oauth/auth-url${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ""}${ + onErrorReturnTo ? `&onErrorReturnTo=${encodeURIComponent(onErrorReturnTo)}` : "" + }` + ) + .then(({ data: responseBody }) => { + if (responseBody.status === SUCCESS_STATUS) { + return responseBody.data.url; + } + if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message); + return ""; + }); + }, + }); +}; + +export type UseConnectGoogleMeetProps = { + onSuccess?: (res: ApiResponse) => void; + onError?: (err: ApiErrorResponse) => void; + returnTo?: string; + onErrorReturnTo?: string; +}; + +export const useConnectNonOauthApp = ( + { onSuccess, onError }: UseConnectGoogleMeetProps = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + return useMutation({ + mutationFn: (app: string) => { + const pathname = `/conferencing/${app}/connect`; + return http?.post(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return { status: res.data }; + } else { + throw new Error(res.data.error.message); + } + }); + }, + onSuccess, + onError, + }); +}; + +export const useConnect = ({ returnTo, onErrorReturnTo, ...props }: UseConnectGoogleMeetProps) => { + const { refetch } = useGetOauthAuthUrl({ returnTo, onErrorReturnTo }); + const connectNonOauthApp = useConnectNonOauthApp(props); + + const connect = async (app: App["slug"]) => { + switch (app) { + case ZOOM: + const redirectUri = await refetch(); + if (redirectUri.data) { + window.location.href = redirectUri.data; + } + break; + + case GOOGLE_MEET: + connectNonOauthApp.mutate(app); + + default: + break; + } + }; + + return { connect }; +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts new file mode 100644 index 0000000000..eb5ab262b4 --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useDeleteCredential.ts @@ -0,0 +1,30 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { App } from "@calcom/types/App"; + +import http from "../../../lib/http"; + +export const QUERY_KEY = "use-delete-credential"; +export type UseDeleteEventTypeProps = { + onSuccess?: () => void; + onError?: (err: Error) => void; + onSettled?: () => void; +}; +export const useDeleteCredential = ({ onSuccess, onError, onSettled }: UseDeleteEventTypeProps) => { + return useMutation({ + onSuccess, + onError, + onSettled, + mutationFn: (app: App["slug"]) => { + if (!app) throw new Error("app is required"); + const pathname = `/conferencing/${app}/disconnect`; + return http?.delete(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return; + } + throw new Error(res.data.error.message); + }); + }, + }); +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts new file mode 100644 index 0000000000..51480e8fe1 --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useGetDefaultConferencingApp.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { ApiResponse } from "@calcom/platform-types"; + +import { useAtomsContext } from "../../../hooks/useAtomsContext"; +import http from "../../../lib/http"; + +export const QUERY_KEY = "use-get-default-conferencing-app"; + +type ResponseDataType = + | { + appSlug?: string; + appLink?: string; + } + | undefined; +export const useGetDefaultConferencingApp = () => { + const pathname = `/conferencing/default`; + const { isInit, accessToken } = useAtomsContext(); + + return useQuery({ + queryKey: [QUERY_KEY], + queryFn: () => { + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: isInit && !!accessToken, + }); +}; diff --git a/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts b/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts new file mode 100644 index 0000000000..6602eb8314 --- /dev/null +++ b/packages/platform/atoms/connect/conferencing-apps/hooks/useUpdateUserDefaultConferencingApp.ts @@ -0,0 +1,34 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { App } from "@calcom/types/App"; + +import http from "../../../lib/http"; + +export const QUERY_KEY = "use-update-user-default-conferencing-app"; +export type UseUpdateUserDefaultConferencingAppProps = { + onSuccess?: () => void; + onError?: (err: Error) => void; + onSettled?: () => void; +}; +export const useUpdateUserDefaultConferencingApp = ({ + onSuccess, + onError, + onSettled, +}: UseUpdateUserDefaultConferencingAppProps) => { + return useMutation({ + onSuccess, + onError, + onSettled, + mutationFn: (app: App["slug"]) => { + if (!app) throw new Error("app is required"); + const pathname = `/conferencing/${app}/default`; + return http?.post(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return; + } + throw new Error(res.data.error.message); + }); + }, + }); +}; diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index d7b4bc17c6..806dbcdcf7 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -25,6 +25,7 @@ export { DestinationCalendarSettingsPlatformWrapper as DestinationCalendarSettin export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/index"; export type { UpdateScheduleInput_2024_06_11 as UpdateScheduleBody } from "@calcom/platform-types"; export { EventTypePlatformWrapper as EventTypeSettings } from "./event-types/wrappers/EventTypePlatformWrapper"; +export { ConferencingAppsViewPlatformWrapper as ConferencingAppsSettings } from "./connect/conferencing-apps/ConferencingAppsViewPlatformWrapper"; export { StripeConnect } from "./connect/stripe/StripeConnect"; export { CreateEventTypePlatformWrapper as CreateEventType } from "./event-types/wrappers/CreateEventTypePlatformWrapper"; export { PaymentForm } from "./event-types/payments/PaymentForm"; diff --git a/packages/platform/constants/apps.ts b/packages/platform/constants/apps.ts index 91e0762b2c..e26aff7820 100644 --- a/packages/platform/constants/apps.ts +++ b/packages/platform/constants/apps.ts @@ -17,8 +17,12 @@ export const GOOGLE_MEET = "google-meet"; export const GOOGLE_MEET_TYPE = "google_video"; export const GOOGLE_MEET_ID = "google-meet"; -export const CONFERENCING_APPS = [GOOGLE_MEET]; +export const ZOOM = "zoom"; +export const ZOOM_TYPE = "zoom_video"; +export const CAL_VIDEO = "daily-video"; + +export const CONFERENCING_APPS = [GOOGLE_MEET, ZOOM]; export const APPS_TYPE_ID_MAPPING = { [GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID, [OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID, diff --git a/packages/platform/examples/base/src/components/Navbar/index.tsx b/packages/platform/examples/base/src/components/Navbar/index.tsx index ad9455fec9..051ad00697 100644 --- a/packages/platform/examples/base/src/components/Navbar/index.tsx +++ b/packages/platform/examples/base/src/components/Navbar/index.tsx @@ -34,6 +34,10 @@ export function Navbar({ username }: { username?: string }) {
  • Embed
  • + +
  • + Conferencing Apps +
  • diff --git a/packages/platform/examples/base/src/pages/conferencing-apps.tsx b/packages/platform/examples/base/src/pages/conferencing-apps.tsx new file mode 100644 index 0000000000..7bec6014a2 --- /dev/null +++ b/packages/platform/examples/base/src/pages/conferencing-apps.tsx @@ -0,0 +1,22 @@ +import { Navbar } from "@/components/Navbar"; +import { Inter } from "next/font/google"; +import { usePathname } from "next/navigation"; + +import { ConferencingAppsSettings } from "@calcom/atoms"; + +const inter = Inter({ subsets: ["latin"] }); + +export default function ConferencingApps(props: { calUsername: string; calEmail: string }) { + const pathname = usePathname(); + const callbackUri = `${window.location.origin}${pathname}`; + + return ( +
    + +
    + +
    +
    + ); +} diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index dd977dd3b8..d596fe0df0 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -20,6 +20,7 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking"; +import handleDeleteCredential from "@calcom/features/credentials/handleDeleteCredential"; import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment"; import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall"; @@ -28,6 +29,7 @@ import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleI import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials"; import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings"; import { symmetricEncrypt, symmetricDecrypt } from "@calcom/lib/crypto"; +import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes"; import { getTranslation } from "@calcom/lib/server/i18n"; import { MembershipRole } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -81,6 +83,10 @@ export { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleN export { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars"; export type { ConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars"; + +export { getConnectedApps } from "@calcom/lib/getConnectedApps"; +export { bulkUpdateEventsToDefaultLocation } from "@calcom/lib/bulkUpdateEventsToDefaultLocation"; +export type { ConnectedApps } from "@calcom/lib/getConnectedApps"; export { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; export { @@ -218,3 +224,5 @@ export { AttendeeUpdatedEmail }; export { OrganizerRequestEmail }; export { AttendeeRequestEmail }; +export { handleDeleteCredential }; +export { getBulkEventTypes }; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts index ebbfbb972f..3029038b1b 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -1,18 +1,8 @@ import type { Prisma } from "@prisma/client"; -import appStore from "@calcom/app-store"; -import type { TDependencyData } from "@calcom/app-store/_appRegistry"; -import type { CredentialOwner } from "@calcom/app-store/types"; -import { getAppFromSlug } from "@calcom/app-store/utils"; -import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials"; -import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; -import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; -import prisma from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import { getConnectedApps } from "@calcom/lib/getConnectedApps"; +import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -import type { CredentialPayload } from "@calcom/types/Credential"; -import type { PaymentApp } from "@calcom/types/PaymentService"; import type { TIntegrationsInputSchema } from "./integrations.schema"; @@ -39,214 +29,7 @@ export type TeamQuery = Prisma.TeamGetPayload<{ }; }>; -// type TeamQueryWithParent = TeamQuery & { -// parent?: TeamQuery | null; -// }; - export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) => { - const { user } = ctx; - const { - variant, - exclude, - onlyInstalled, - includeTeamInstalledApps, - extendsFeature, - teamId, - sortByMostPopular, - appId, - } = input; - let credentials = await getUsersCredentials(user); - let userTeams: TeamQuery[] = []; - - if (includeTeamInstalledApps || teamId) { - const teamsQuery = await prisma.team.findMany({ - where: { - members: { - some: { - userId: user.id, - accepted: true, - }, - }, - }, - select: { - id: true, - credentials: { - select: credentialForCalendarServiceSelect, - }, - name: true, - logoUrl: true, - members: { - where: { - userId: user.id, - }, - select: { - role: true, - }, - }, - parent: { - select: { - id: true, - credentials: { - select: credentialForCalendarServiceSelect, - }, - name: true, - logoUrl: true, - members: { - where: { - userId: user.id, - }, - select: { - role: true, - }, - }, - }, - }, - }, - }); - // If a team is a part of an org then include those apps - // Don't want to iterate over these parent teams - const filteredTeams: TeamQuery[] = []; - const parentTeams: TeamQuery[] = []; - // Only loop and grab parent teams if a teamId was given. If not then all teams will be queried - if (teamId) { - teamsQuery.forEach((team) => { - if (team?.parent) { - const { parent, ...filteredTeam } = team; - filteredTeams.push(filteredTeam); - // Only add parent team if it's not already in teamsQuery - if (!teamsQuery.some((t) => t.id === parent.id)) { - parentTeams.push(parent); - } - } - }); - } - - userTeams = [...teamsQuery, ...parentTeams]; - - const teamAppCredentials: CredentialPayload[] = userTeams.flatMap((teamApp) => { - return teamApp.credentials ? teamApp.credentials.flat() : []; - }); - if (!includeTeamInstalledApps || teamId) { - credentials = teamAppCredentials; - } else { - credentials = credentials.concat(teamAppCredentials); - } - } - - const enabledApps = await getEnabledAppsFromCredentials(credentials, { - filterOnCredentials: onlyInstalled, - ...(appId ? { where: { slug: appId } } : {}), - }); - //TODO: Refactor this to pick up only needed fields and prevent more leaking - let apps = await Promise.all( - enabledApps.map(async ({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => { - const userCredentialIds = credentials.filter((c) => c.appId === app.slug && !c.teamId).map((c) => c.id); - const invalidCredentialIds = credentials - .filter((c) => c.appId === app.slug && c.invalid) - .map((c) => c.id); - const teams = await Promise.all( - credentials - .filter((c) => c.appId === app.slug && c.teamId) - .map(async (c) => { - const team = userTeams.find((team) => team.id === c.teamId); - if (!team) { - return null; - } - return { - teamId: team.id, - name: team.name, - logoUrl: team.logoUrl, - credentialId: c.id, - isAdmin: - team.members[0].role === MembershipRole.ADMIN || - team.members[0].role === MembershipRole.OWNER, - }; - }) - ); - // type infer as CredentialOwner - const credentialOwner: CredentialOwner = { - name: user.name, - avatar: user.avatar, - }; - - // We need to know if app is payment type - // undefined it means that app don't require app/setup/page - let isSetupAlready = undefined; - if (credential && app.categories.includes("payment")) { - const paymentApp = (await appStore[app.dirName as keyof typeof appStore]?.()) as PaymentApp | null; - if (paymentApp && "lib" in paymentApp && paymentApp?.lib && "PaymentService" in paymentApp?.lib) { - const PaymentService = paymentApp.lib.PaymentService; - const paymentInstance = new PaymentService(credential); - isSetupAlready = paymentInstance.isSetupAlready(); - } - } - - let dependencyData: TDependencyData = []; - if (app.dependencies?.length) { - dependencyData = app.dependencies.map((dependency) => { - const dependencyInstalled = enabledApps.some( - (dbAppIterator) => dbAppIterator.credentials.length && dbAppIterator.slug === dependency - ); - // If the app marked as dependency is simply deleted from the codebase, we can have the situation where App is marked installed in DB but we couldn't get the app. - const dependencyName = getAppFromSlug(dependency)?.name; - return { name: dependencyName, installed: dependencyInstalled }; - }); - } - - return { - ...app, - ...(teams.length && { - credentialOwner, - }), - userCredentialIds, - invalidCredentialIds, - teams, - isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal, - isSetupAlready, - ...(app.dependencies && { dependencyData }), - }; - }) - ); - - if (variant) { - // `flatMap()` these work like `.filter()` but infers the types correctly - apps = apps - // variant check - .flatMap((item) => (item.variant.startsWith(variant) ? [item] : [])); - } - - if (exclude) { - // exclusion filter - apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); - } - - if (onlyInstalled) { - apps = apps.flatMap((item) => - item.userCredentialIds.length > 0 || item.teams.length || item.isGlobal ? [item] : [] - ); - } - - if (extendsFeature) { - apps = apps - .filter((app) => app.extendsFeature?.includes(extendsFeature)) - .map((app) => ({ - ...app, - isInstalled: !!app.userCredentialIds?.length || !!app.teams?.length || app.isGlobal, - })); - } - - if (sortByMostPopular) { - const installCountPerApp = await getInstallCountPerApp(); - - // sort the apps array by the most popular apps - apps.sort((a, b) => { - const aCount = installCountPerApp[a.slug] || 0; - const bCount = installCountPerApp[b.slug] || 0; - return bCount - aCount; - }); - } - - return { - items: apps, - }; + const user = ctx.user; + return getConnectedApps({ user, input, prisma }); }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts index d960138506..2f9c75e59b 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/bulkUpdateToDefaultLocation.handler.ts @@ -1,9 +1,5 @@ -import type { LocationObject } from "@calcom/app-store/locations"; -import { getAppFromSlug } from "@calcom/app-store/utils"; +import { bulkUpdateEventsToDefaultLocation } from "@calcom/lib/bulkUpdateEventsToDefaultLocation"; import { prisma } from "@calcom/prisma"; -import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; - -import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; import type { TBulkUpdateToDefaultLocationInputSchema } from "./bulkUpdateToDefaultLocation.schema"; @@ -15,50 +11,11 @@ type BulkUpdateToDefaultLocationOptions = { input: TBulkUpdateToDefaultLocationInputSchema; }; -export const bulkUpdateToDefaultLocationHandler = async ({ - ctx, - input, -}: BulkUpdateToDefaultLocationOptions) => { +export const bulkUpdateToDefaultLocationHandler = ({ ctx, input }: BulkUpdateToDefaultLocationOptions) => { const { eventTypeIds } = input; - const defaultApp = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp; - - if (!defaultApp) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Default conferencing app not set", - }); - } - - const foundApp = getAppFromSlug(defaultApp.appSlug); - const appType = foundApp?.appData?.location?.type; - if (!appType) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Default conferencing app '${defaultApp.appSlug}' doesnt exist.`, - }); - } - - const credential = await prisma.credential.findFirst({ - where: { - userId: ctx.user.id, - appId: foundApp.slug, - }, - select: { - id: true, - }, - }); - - return await prisma.eventType.updateMany({ - where: { - id: { - in: eventTypeIds, - }, - userId: ctx.user.id, - }, - data: { - locations: [ - { type: appType, link: defaultApp.appLink, credentialId: credential?.id }, - ] as LocationObject[], - }, + return bulkUpdateEventsToDefaultLocation({ + eventTypeIds, + user: ctx.user, + prisma, }); };