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>
This commit is contained in:
Somay Chauhan
2024-12-06 19:03:04 +05:30
committed by GitHub
co-authored by Morgan
parent 44115ac7fa
commit 063400fcbc
54 changed files with 1898 additions and 413 deletions
+1 -1
View File
@@ -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": "*",
@@ -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,
},
});
}
}
@@ -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,
@@ -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<ApiResponse<unknown>> {
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<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getConferencingApps(user);
return { status: SUCCESS_STATUS, data: conferencingApps };
}
}
@@ -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[];
}
@@ -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<ConnectedApps> {
return getConnectedApps({
user,
input: {
variant: "conferencing",
onlyInstalled: true,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -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,
});
}
}
@@ -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],
@@ -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<GetConferencingAppsOauthUrlResponseDto> {
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<ConferencingAppsOutputResponseDto> {
async listInstalledConferencingApps(
@GetUser("id") userId: number
): Promise<ConferencingAppsOutputResponseDto> {
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<SetDefaultConferencingAppOutputResponseDto> {
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<GetDefaultConferencingAppOutputResponseDto> {
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<ConferencingAppOutputResponseDto> {
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<DisconnectConferencingAppOutputResponseDto> {
await this.conferencingService.disconnectConferencingApp(user, app);
return { status: SUCCESS_STATUS };
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 },
});
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 ?? "" };
}
}
+181 -2
View File
@@ -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"
]
}
}
}
@@ -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<AppCategories, React.ComponentProps<typeof Icon>["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}
/>
</div>
);
@@ -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 });
};
@@ -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,
}}
/>
);
@@ -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}
/>
)}
</>
@@ -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 (
@@ -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",
+160 -2
View File
@@ -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"]
}
}
}
+37 -18
View File
@@ -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 = ({
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
app={item.slug}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
handleConnectDisconnectIntegrationMenuToggle={
handleConnectDisconnectIntegrationMenuToggle
}
/>
</DropdownMenuContent>
</Dropdown>
@@ -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 (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
onClick={() => handleDisconnect(credentialId, app, teamId)}
disabled={isGlobal}
StartIcon="trash">
{t("remove_app")}
@@ -234,7 +253,7 @@ function ConnectOrDisconnectIntegrationMenuItem(props: {
{t("install")}
</Button>
)}
onChanged={handleOpenChange}
onChanged={props.handleConnectDisconnectIntegrationMenuToggle}
/>
);
}
@@ -37,7 +37,8 @@ export type AppListCardProps = {
const schema = z.object({ hl: z.string().optional() });
function AppListCardPlatformWrapper(props: AppListCardProps) {
return <AppListCardComponent {...props} />;
const logo = `https://app.cal.com${props.logo}`;
return <AppListCardComponent {...props} logo={logo} />;
}
function AppListCardWebWrapper(props: AppListCardProps) {
const { slug, shouldHighlight } = props;
@@ -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<SetStateAction<(EventLocationType & { slug: string }) | undefined>>;
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 (
<Dialog open={!!locationType} onOpenChange={() => setLocationType(undefined)}>
<DialogContent
@@ -60,9 +59,15 @@ export function AppSetDefaultLinkDialog({
<Form
form={form}
handleSubmit={(values) => {
updateDefaultAppMutation.mutate({
handleUpdateUserDefaultConferencingApp({
appSlug: locationType.slug,
appLink: values.link,
onSuccessCallback: () => {
onSuccess();
},
onErrorCallback: () => {
showToast(`Invalid App Link Format`, "error");
},
});
setLocationType(undefined);
}}>
@@ -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 (
<Dialog open={isOpen} onOpenChange={handleModelClose}>
<ConfirmationDialogContent
@@ -25,7 +35,9 @@ export default function DisconnectIntegrationModal({
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={() => {
if (credentialId) {
if (isPlatform && app && credentialId) {
handleRemoveApp({ credentialId, app, teamId, callback: () => handleModelClose() });
} else if (credentialId) {
handleRemoveApp({ credentialId, teamId, callback: () => handleModelClose() });
}
}}>
@@ -39,7 +39,7 @@ const handleDeleteCredential = async ({
teamId,
}: {
userId: number;
userMetadata: Prisma.JsonValue;
userMetadata?: Prisma.JsonValue;
credentialId: number;
teamId?: number;
}) => {
@@ -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 (
@@ -17,7 +17,6 @@ import {
DropdownMenuItem,
Icon,
showToast,
useMeta,
} from "@calcom/ui";
import { DeleteAttributeModal } from "./DeleteAttributeModal";
@@ -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";
@@ -1,7 +1,6 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta } from "@calcom/ui";
import { TeamsListing } from "../components";
@@ -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";
@@ -35,7 +35,6 @@ import {
ImageUploader,
Label,
LinkIconButton,
Meta,
showToast,
SkeletonAvatar,
SkeletonButton,
@@ -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 (
<Dialog name="Bulk Default Location Update" open={props.open} onOpenChange={props.setOpen}>
@@ -50,19 +56,19 @@ export function BulkEditDefaultForEventsModal(props: {
});
}}>
<div className="flex flex-col space-y-2">
{data.eventTypes.length > 0 && (
{eventTypes.length > 0 && (
<div className="flex items-center space-x-2 rounded-md px-3 pb-2.5 pt-1">
<CheckboxField
description={t("select_all")}
descriptionAsLabel
onChange={(e) => {
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}
/>
</div>
)}
{data.eventTypes.map((eventType) => (
{eventTypes.map((eventType) => (
<div key={eventType.id} className="bg-muted flex items-center space-x-2 rounded-md px-3 py-2.5">
<CheckboxField
description={eventType.title}
@@ -83,7 +89,7 @@ export function BulkEditDefaultForEventsModal(props: {
<DialogFooter showDivider className="mt-10">
<DialogClose
onClick={() => {
utils.viewer.getUsersDefaultConferencingApp.invalidate();
props.handleBulkEditDialogToggle();
}}
/>
<Button type="submit" color="primary" loading={props.isPending} disabled={isButtonDisabled}>
@@ -0,0 +1,59 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import type { PrismaClient } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
export const bulkUpdateEventsToDefaultLocation = async ({
eventTypeIds,
user,
prisma,
}: {
eventTypeIds: number[];
user: Pick<User, "id" | "metadata">;
prisma: PrismaClient;
}) => {
const defaultApp = userMetadataSchema.parse(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: user.id,
appId: foundApp.slug,
},
select: {
id: true,
},
});
return await prisma.eventType.updateMany({
where: {
id: {
in: eventTypeIds,
},
userId: user.id,
},
data: {
locations: [
{ type: appType, link: defaultApp.appLink, credentialId: credential?.id },
] as LocationObject[],
},
});
};
+232
View File
@@ -0,0 +1,232 @@
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 type { PrismaClient } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { TeamQuery } from "@calcom/trpc/server/routers/loggedInViewer/integrations.handler";
import type { TIntegrationsInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/integrations.schema";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PaymentApp } from "@calcom/types/PaymentService";
export type ConnectedApps = Awaited<ReturnType<typeof getConnectedApps>>;
export async function getConnectedApps({
user,
prisma,
input,
}: {
user: Pick<User, "id" | "name" | "avatarUrl"> & { avatar?: string };
prisma: PrismaClient;
input: TIntegrationsInputSchema;
}) {
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 ?? user?.avatarUrl,
};
// 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,
};
}
@@ -5,7 +5,10 @@ import { useMemo, useState } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import dayjs from "@calcom/dayjs";
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 { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
import WebSchedule, {
@@ -109,6 +112,9 @@ type AvailabilitySettingsProps = {
setIsOpen: Dispatch<SetStateAction<boolean>>;
save: (params: BulkUpdatParams) => void;
isSaving: boolean;
eventTypes?: EventTypes;
isEventTypesFetching?: boolean;
handleBulkEditDialogToggle: () => void;
};
};
@@ -365,6 +371,9 @@ export function AvailabilitySettings({
setOpen={bulkUpdateModalProps.setIsOpen}
bulkUpdateFunction={bulkUpdateModalProps?.save}
description={t("default_schedules_bulk_description")}
eventTypes={bulkUpdateModalProps?.eventTypes}
isEventTypesFetching={bulkUpdateModalProps?.isEventTypesFetching}
handleBulkEditDialogToggle={bulkUpdateModalProps.handleBulkEditDialogToggle}
/>
)}
@@ -0,0 +1,264 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useReducer } from "react";
import { AppList } from "@calcom/features/apps/components/AppList";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { GOOGLE_MEET, ZOOM } from "@calcom/platform-constants";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import type { App } from "@calcom/types/App";
import {
Button,
SkeletonContainer,
SkeletonText,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
EmptyScreen,
} from "@calcom/ui";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import type {
BulkUpdatParams,
UpdateUsersDefaultConferencingAppParams,
} from "./ConferencingAppsViewWebWrapper";
import { useAtomBulkUpdateEventTypesToDefaultLocation } from "./hooks/useAtomBulkUpdateEventTypesToDefaultLocation";
import { useAtomGetEventTypes } from "./hooks/useAtomGetEventTypes";
import {
useAtomsGetInstalledConferencingApps,
QUERY_KEY as atomsConferencingAppsQueryKey,
} from "./hooks/useAtomsGetInstalledConferencingApps";
import { useConnect } from "./hooks/useConnect";
import { useDeleteCredential } from "./hooks/useDeleteCredential";
import {
useGetDefaultConferencingApp,
QUERY_KEY as defaultConferencingAppQueryKey,
} from "./hooks/useGetDefaultConferencingApp";
import { useUpdateUserDefaultConferencingApp } from "./hooks/useUpdateUserDefaultConferencingApp";
type ConferencingAppsViewPlatformWrapperProps = {
disableToasts?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
};
type RemoveAppParams = { callback: () => void; app?: App["slug"] };
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
type ModalState = {
isOpen: boolean;
credentialId: null | number;
app: App["slug"] | null;
};
export const ConferencingAppsViewPlatformWrapper = ({
disableToasts = false,
returnTo,
onErrorReturnTo,
}: ConferencingAppsViewPlatformWrapperProps) => {
const { t } = useLocale();
const queryClient = useQueryClient();
const { toast } = useToast();
const showToast = (message: string, variant: "success" | "warning" | "error") => {
if (!disableToasts) {
toast({ description: message });
}
};
const [modal, updateModal] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
app: null,
}
);
const handleModelClose = () => {
updateModal({ isOpen: false, credentialId: null, app: null });
};
const handleDisconnect = (credentialId: number, app: App["slug"]) => {
updateModal({ isOpen: true, credentialId, app });
};
const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps();
const { data: defaultConferencingApp } = useGetDefaultConferencingApp();
const { data: eventTypesQuery, isFetching: isEventTypesFetching } = useAtomGetEventTypes();
const deleteCredentialMutation = useDeleteCredential({
onSuccess: () => {
showToast(t("app_removed_successfully"), "success");
handleModelClose();
queryClient.invalidateQueries({
queryKey: [atomsConferencingAppsQueryKey],
});
queryClient.resetQueries({
queryKey: [defaultConferencingAppQueryKey],
});
},
onError: () => {
showToast(t("error_removing_app"), "error");
handleModelClose();
},
});
const updateDefaultAppMutation = useUpdateUserDefaultConferencingApp({});
const bulkUpdateEventTypesToDefaultLocation = useAtomBulkUpdateEventTypesToDefaultLocation({});
const handleRemoveApp = ({ app }: RemoveAppParams) => {
!!app && deleteCredentialMutation.mutate(app);
};
const handleUpdateUserDefaultConferencingApp = ({
appSlug,
onSuccessCallback,
onErrorCallback,
}: UpdateUsersDefaultConferencingAppParams) => {
updateDefaultAppMutation.mutate(appSlug, {
onSuccess: () => {
showToast("Default app updated successfully", "success");
queryClient.invalidateQueries({ queryKey: [defaultConferencingAppQueryKey] });
onSuccessCallback();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
onErrorCallback();
},
});
};
const handleBulkUpdateDefaultLocation = ({ eventTypeIds, callback }: BulkUpdatParams) => {
bulkUpdateEventTypesToDefaultLocation.mutate(eventTypeIds, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [defaultConferencingAppQueryKey] });
callback();
},
});
};
const handleConnectDisconnectIntegrationMenuToggle = () => {
queryClient.invalidateQueries({ queryKey: [atomsConferencingAppsQueryKey] });
};
const handleBulkEditDialogToggle = () => {
queryClient.invalidateQueries({ queryKey: [defaultConferencingAppQueryKey] });
};
const { connect } = useConnect({
onSuccess: () => {
showToast("app installed successfully", "success");
queryClient.invalidateQueries({ queryKey: [atomsConferencingAppsQueryKey] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: [atomsConferencingAppsQueryKey] });
showToast(`Error: unable to install app`, "error");
},
returnTo,
onErrorReturnTo,
});
const AddConferencingButtonPlatform = ({ installedApps }: { installedApps?: Array<{ slug: string }> }) => {
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button color="secondary" StartIcon="plus">
{t("add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{installedApps && !installedApps.find((app) => app.slug == GOOGLE_MEET) && (
<DropdownMenuItem>
<DropdownItem color="secondary" onClick={() => connect(GOOGLE_MEET)}>
{t("google_meet")}
</DropdownItem>
</DropdownMenuItem>
)}
{installedApps && !installedApps?.find((app) => app.slug == ZOOM) && (
<DropdownMenuItem>
<DropdownItem color="secondary" onClick={() => connect(ZOOM)}>
{t("zoom")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
);
};
return (
<AtomsWrapper>
<SettingsHeader
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButtonPlatform installedApps={installedIntegrationsQuery.data?.items} />}
borderInShellHeader={true}>
<>
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<QueryCell
query={installedIntegrationsQuery}
customLoader={<SkeletonLoader />}
success={({ data }) => {
if (!data.items.length) {
return (
<EmptyScreen
Icon="calendar"
headline={t("no_category_apps", {
category: t("conferencing").toLowerCase(),
})}
description={t("no_category_apps_description_conferencing")}
buttonRaw={<AddConferencingButtonPlatform installedApps={data?.items} />}
/>
);
}
return (
<AppList
listClassName="rounded-lg rounded-t-none border-t-0 max-w-full"
handleDisconnect={handleDisconnect}
data={data}
variant="conferencing"
defaultConferencingApp={defaultConferencingApp}
handleUpdateUserDefaultConferencingApp={handleUpdateUserDefaultConferencingApp}
handleBulkUpdateDefaultLocation={handleBulkUpdateDefaultLocation}
isBulkUpdateDefaultLocationPending={bulkUpdateEventTypesToDefaultLocation?.isPending}
eventTypes={eventTypesQuery?.eventTypes}
isEventTypesFetching={isEventTypesFetching}
handleConnectDisconnectIntegrationMenuToggle={
handleConnectDisconnectIntegrationMenuToggle
}
handleBulkEditDialogToggle={handleBulkEditDialogToggle}
/>
);
}}
/>
</div>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={modal.isOpen}
credentialId={modal.credentialId}
app={modal.app}
handleRemoveApp={handleRemoveApp}
/>
</>
</SettingsHeader>
</AtomsWrapper>
);
};
@@ -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}
/>
);
}}
@@ -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<EventType>).data;
}
throw new Error(res.data.error.message);
});
},
});
};
@@ -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<ApiResponse<ResponseEventType>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<ResponseEventType>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: isInit && !!accessToken,
});
};
@@ -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<ApiResponse<ConnectedApps>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<ConnectedApps>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: isInit && !!accessToken,
});
};
@@ -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<ApiResponse<{ url: string }>>(
`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 };
};
@@ -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);
});
},
});
};
@@ -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<ApiResponse<ResponseDataType>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<ResponseDataType>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: isInit && !!accessToken,
});
};
@@ -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);
});
},
});
};
+1
View File
@@ -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";
+5 -1
View File
@@ -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,
@@ -34,6 +34,10 @@ export function Navbar({ username }: { username?: string }) {
<li>
<Link href="/embed">Embed</Link>
</li>
<li>
<Link href="/conferencing-apps">Conferencing Apps</Link>
</li>
</ul>
</div>
</nav>
@@ -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 (
<main
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
<Navbar username={props.calUsername} />
<div className="my-8">
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} />
</div>
</main>
);
}
+8
View File
@@ -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 };
@@ -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 });
};
@@ -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,
});
};