-
Notifications
You must be signed in to change notification settings - Fork 12k
feat: orgs conferencing apps #22211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: orgs conferencing apps #22211
Changes from all commits
402c357
58e02de
069da95
022a921
4b8df31
22e0288
8b041e5
3cd6935
219766e
2e9e6ff
82db763
ea302a2
7b772cb
dd5efed
63aa9ca
9feca1a
4f9df68
8f49564
05c406a
1d9ca2c
978cb56
d64cec0
516ae3b
ec6551e
ffa37a9
24a1d72
73f1c65
0be5562
003ab64
f23b1cb
dce050f
238a00e
f539fa0
a150d1f
80c1ef8
b4a6eec
77f854c
bcf610d
56d867f
dca1636
f5081c5
b270660
ea31aa8
0611db2
cca5d8a
5c885f0
e3334a2
a6d4395
5a0ff01
f0cc40e
404c162
5ffc1c2
1522749
21b345f
8d0235e
fe7f086
eaae740
79f6c28
b698e67
387c616
9938438
7e77820
bf0097f
bcab76c
12dfa90
b613d75
81bd404
fda04a8
d9e12fb
01c6a6b
7144577
cdffc6d
1c464a8
1f5266b
f05b20b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ export class ConferencingAtomsService { | |
| input: { | ||
| variant: "conferencing", | ||
| onlyInstalled: true, | ||
| includeTeamInstalledApps: true, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Display the installed team or organizational conferencing apps in the conferencing atom when viewed at the user level. |
||
| }, | ||
| prisma: this.dbWrite.prisma as unknown as PrismaClient, | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,25 +17,25 @@ import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci | |
| import { ConferencingService } from "@/modules/conferencing/services/conferencing.service"; | ||
| import { UserWithProfile } from "@/modules/users/users.repository"; | ||
| import { HttpService } from "@nestjs/axios"; | ||
| import { Logger } from "@nestjs/common"; | ||
| import { Logger, ParseIntPipe } from "@nestjs/common"; | ||
| import { | ||
| Controller, | ||
| Delete, | ||
| Get, | ||
| Query, | ||
| HttpCode, | ||
| HttpStatus, | ||
| UseGuards, | ||
| Post, | ||
| Param, | ||
| Post, | ||
| Query, | ||
| Req, | ||
| UseGuards, | ||
| HttpException, | ||
| BadRequestException, | ||
| Delete, | ||
| Headers, | ||
| Redirect, | ||
| Req, | ||
| HttpException, | ||
| } from "@nestjs/common"; | ||
| import { ConfigService } from "@nestjs/config"; | ||
| import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger"; | ||
| import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger"; | ||
| import { plainToInstance } from "class-transformer"; | ||
| import { Request } from "express"; | ||
|
|
||
|
|
@@ -80,7 +80,10 @@ export class ConferencingController { | |
| @Param("app") app: string | ||
| ): Promise<ConferencingAppOutputResponseDto> { | ||
| const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id); | ||
| return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) }; | ||
| return { | ||
| status: SUCCESS_STATUS, | ||
| data: plainToInstance(ConferencingAppsOutputDto, credential, { strategy: "excludeAll" }), | ||
| }; | ||
| } | ||
|
|
||
| @Get("/:app/oauth/auth-url") | ||
|
|
@@ -169,6 +172,21 @@ export class ConferencingController { | |
| const fallbackUrl = decodedCallbackState.onErrorReturnTo || ""; | ||
| return { url: fallbackUrl }; | ||
| } | ||
| } else if (decodedCallbackState.orgId) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new branch repeats almost the same logic as the preceding team-level branch (building the same params / headers, performing the same axios call, and handling identical success & error flows). Copy-pasting this block increases maintenance cost and risks future inconsistencies. Extract the shared logic into a reusable helper instead of duplicating it. |
||
| const apiUrl = this.config.get("api.url"); | ||
| const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/conferencing/${app}/oauth/callback`; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar to this #20540 (comment) |
||
| const params: Record<string, string | undefined> = { state, code, error, error_description }; | ||
| const headers = { | ||
| Authorization: `Bearer ${decodedCallbackState.accessToken}`, | ||
| }; | ||
| try { | ||
| const response = await this.httpService.axiosRef.get(url, { params, headers }); | ||
| const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || ""; | ||
| return { url: redirectUrl }; | ||
| } catch (err) { | ||
| const fallbackUrl = decodedCallbackState.onErrorReturnTo || ""; | ||
| return { url: fallbackUrl }; | ||
| } | ||
| } | ||
|
|
||
| return this.conferencingService.connectOauthApps(app, code, decodedCallbackState); | ||
|
|
@@ -190,10 +208,12 @@ export class ConferencingController { | |
| async listInstalledConferencingApps( | ||
| @GetUser() user: UserWithProfile | ||
| ): Promise<ConferencingAppsOutputResponseDto> { | ||
| const conferencingApps = await this.conferencingService.getConferencingApps(user.id); | ||
| const conferencingApps = await this.conferencingService.getConferencingApps(user); | ||
| return { | ||
| status: SUCCESS_STATUS, | ||
| data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)), | ||
| data: conferencingApps.map((app) => | ||
| plainToInstance(ConferencingAppsOutputDto, app, { strategy: "excludeAll" }) | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -216,6 +236,32 @@ export class ConferencingController { | |
| return { status: SUCCESS_STATUS }; | ||
| } | ||
|
|
||
| @Post("/:app/default/:credentialId") | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting a default app now requires us to include the credentialId, as previously we only had one instance of an app. but now with the introduction of multiple instances at the org, team, and user levels, we need to specify the credentialId to identify which app we're referring to. |
||
| @HttpCode(HttpStatus.OK) | ||
| @UseGuards(ApiAuthGuard) | ||
| @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) | ||
| @ApiOperation({ summary: "Set your default conferencing application with specific credential ID" }) | ||
| @ApiParam({ | ||
| name: "app", | ||
| description: "Conferencing application type", | ||
| enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO], | ||
| required: true, | ||
| }) | ||
| @ApiParam({ | ||
| name: "credentialId", | ||
| description: "Specific credential ID to use for the conferencing app", | ||
| type: Number, | ||
| required: true, | ||
| }) | ||
| async defaultWithCredential( | ||
| @GetUser() user: UserWithProfile, | ||
| @Param("app") app: string, | ||
| @Param("credentialId", ParseIntPipe) credentialId: number | ||
| ): Promise<SetDefaultConferencingAppOutputResponseDto> { | ||
| await this.conferencingService.setDefaultConferencingApp(user, app, credentialId); | ||
| return { status: SUCCESS_STATUS }; | ||
| } | ||
|
|
||
| @Get("/default") | ||
| @HttpCode(HttpStatus.OK) | ||
| @UseGuards(ApiAuthGuard) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,27 @@ | ||
| import { ApiProperty } from "@nestjs/swagger"; | ||
| import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; | ||
| import { Expose, Type } from "class-transformer"; | ||
| import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; | ||
| import { IsEnum, IsOptional, IsString, IsNumber, ValidateNested } from "class-validator"; | ||
|
|
||
| import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; | ||
|
|
||
| export class DefaultConferencingAppsOutputDto { | ||
| @IsString() | ||
| @IsOptional() | ||
| @Expose() | ||
| @ApiProperty() | ||
| readonly appSlug?: string; | ||
|
|
||
| @IsString() | ||
| @IsOptional() | ||
| @Expose() | ||
| @ApiProperty() | ||
| readonly appLink?: string; | ||
|
|
||
| @IsNumber() | ||
| @IsOptional() | ||
| @Expose() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that the properties in |
||
| @ApiProperty() | ||
| readonly credentialId?: number; | ||
| } | ||
|
|
||
| export class GetDefaultConferencingAppOutputResponseDto { | ||
|
|
@@ -25,5 +33,6 @@ export class GetDefaultConferencingAppOutputResponseDto { | |
| @ValidateNested() | ||
| @IsOptional() | ||
| @Type(() => DefaultConferencingAppsOutputDto) | ||
| @ApiPropertyOptional({ type: DefaultConferencingAppsOutputDto }) | ||
| data?: DefaultConferencingAppsOutputDto; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,9 +33,25 @@ export class ConferencingRepository { | |
| } | ||
|
|
||
| async findTeamConferencingApps(teamId: number) { | ||
| const parentTeam = await this.dbRead.prisma.team.findUnique({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't this be just |
||
| where: { | ||
| id: teamId, | ||
| }, | ||
| select: { | ||
| parentId: true, | ||
| }, | ||
| }); | ||
|
|
||
| const teamIds = [teamId]; | ||
| if (parentTeam?.parentId) { | ||
| teamIds.push(parentTeam.parentId); | ||
| } | ||
|
|
||
| return this.dbRead.prisma.credential.findMany({ | ||
| where: { | ||
| teamId, | ||
| teamId: { | ||
| in: teamIds, | ||
| }, | ||
| type: { endsWith: "_video" }, | ||
| }, | ||
| select: this.credentialSelect, | ||
|
|
@@ -62,4 +78,11 @@ export class ConferencingRepository { | |
| select: this.credentialSelect, | ||
| }); | ||
| } | ||
|
|
||
| async findMultipleTeamsConferencingApp(teamIds: number[], app: string) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why in |
||
| return this.dbRead.prisma.credential.findMany({ | ||
| where: { teamId: { in: teamIds }, appId: app }, | ||
| select: this.credentialSelect, | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,24 +22,29 @@ import { | |
| OFFICE_365_VIDEO, | ||
| } from "@calcom/platform-constants"; | ||
| import { userMetadata } from "@calcom/platform-libraries"; | ||
| import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store"; | ||
| import { getUsersAndTeamsCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store"; | ||
| import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store"; | ||
|
|
||
| @Injectable() | ||
| export class ConferencingService { | ||
| private logger = new Logger("ConferencingService"); | ||
|
|
||
| constructor( | ||
| private readonly conferencingRepository: ConferencingRepository, | ||
| private readonly usersRepository: UsersRepository, | ||
| private readonly tokensRepository: TokensRepository, | ||
| private readonly googleMeetService: GoogleMeetService, | ||
| private readonly zoomVideoService: ZoomVideoService, | ||
| private readonly office365VideoService: Office365VideoService | ||
| ) {} | ||
|
|
||
| async getConferencingApps(userId: number) { | ||
| return this.conferencingRepository.findConferencingApps(userId); | ||
| async getConferencingApps(user: UserWithProfile) { | ||
| const credentials = await getUsersAndTeamsCredentialsIncludeServiceAccountKey(user); | ||
|
|
||
| // Remove sensitive key field from each credential | ||
| return credentials.map((credential) => { | ||
| const { key: _key, ...credentialWithoutKey } = credential; | ||
| return credentialWithoutKey; | ||
| }); | ||
| } | ||
|
|
||
| async connectUserNonOauthApp(app: string, userId: number) { | ||
|
|
@@ -87,14 +92,20 @@ export class ConferencingService { | |
| return userMetadata.parse(user?.metadata)?.defaultConferencingApp; | ||
| } | ||
|
|
||
| async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) { | ||
| async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string, credentialId?: number) { | ||
| if (!CONFERENCING_APPS.includes(appSlug)) { | ||
| throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", ")); | ||
| } | ||
| const credentials = await getUsersCredentialsIncludeServiceAccountKey(user); | ||
| const credentials = await getUsersAndTeamsCredentialsIncludeServiceAccountKey(user); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added a new function that will also include the credentials for the teams and organizations the user is a part of. |
||
|
|
||
| const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0]; | ||
| if (credentialId) { | ||
| const specificCredential = credentials.find((cred) => cred.id === credentialId); | ||
| if (!specificCredential) { | ||
| throw new BadRequestException(`Credential with ID ${credentialId} not found for app ${appSlug}.`); | ||
| } | ||
| } | ||
|
|
||
| const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0]; | ||
| const appLocation = foundApp?.appData?.location; | ||
|
|
||
| if (!foundApp || !appLocation) { | ||
|
|
@@ -112,12 +123,17 @@ export class ConferencingService { | |
| }); | ||
| } | ||
|
|
||
| async setDefaultConferencingApp(user: UserWithProfile, app: string) { | ||
| async setDefaultConferencingApp(user: UserWithProfile, app: string, credentialId?: number) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar to #22211 (comment) |
||
| // cal-video is global, so we can skip this check | ||
| if (app !== CAL_VIDEO) { | ||
| await this.checkAppIsValidAndConnected(user, app); | ||
| await this.checkAppIsValidAndConnected(user, app, credentialId); | ||
| } | ||
| const updatedUser = await this.usersRepository.setDefaultConferencingApp(user.id, app); | ||
| const updatedUser = await this.usersRepository.setDefaultConferencingApp( | ||
| user.id, | ||
| app, | ||
| undefined, | ||
| credentialId | ||
| ); | ||
| const metadata = updatedUser.metadata as { defaultConferencingApp?: { appSlug?: string } }; | ||
| if (metadata?.defaultConferencingApp?.appSlug !== app) { | ||
| throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
api endpoint to list all the conferencing apps for in the conferencing atom for an org