Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
402c357
feat: implement organization-level conferencing app management endpoi…
ibex088 Jun 21, 2025
58e02de
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jun 24, 2025
069da95
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jun 25, 2025
022a921
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jun 26, 2025
4b8df31
fix: Org conferencing app not visible as a location in user event types
ibex088 Jun 26, 2025
22e0288
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jun 30, 2025
8b041e5
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jun 30, 2025
3cd6935
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 1, 2025
219766e
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 1, 2025
2e9e6ff
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 2, 2025
82db763
fix: atoms typescript locally
supalarry Jul 2, 2025
ea302a2
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 3, 2025
7b772cb
Revert "fix: atoms typescript locally"
supalarry Jul 3, 2025
dd5efed
Merge branch 'main' into feat/orgs-conferencing-apps
supalarry Jul 3, 2025
63aa9ca
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 4, 2025
9feca1a
fix: Show installed apps from the organization in the team dropdown
ibex088 Jul 4, 2025
4f9df68
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 4, 2025
8f49564
Update getConnectedApps.ts
ibex088 Jul 4, 2025
05c406a
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 7, 2025
1d9ca2c
feat: allow setting org conferencing as default app at sub-team/user …
ibex088 Jul 11, 2025
978cb56
feat: hide google meet for teams/orgs
ibex088 Jul 11, 2025
d64cec0
fix: expose dwd credentials in get conferencing apps endpoint
ibex088 Jul 11, 2025
516ae3b
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 14, 2025
ec6551e
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 14, 2025
ffa37a9
Update user.ts
ibex088 Jul 14, 2025
24a1d72
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 14, 2025
73f1c65
refactor: extract handleOrgOAuthCallback method for cleaner organizat…
ibex088 Jul 14, 2025
0be5562
fix: prevent empty teams array from causing invalid prisma query in g…
ibex088 Jul 14, 2025
003ab64
fix: prevent empty teams array from causing invalid database query in…
ibex088 Jul 14, 2025
f23b1cb
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 14, 2025
dce050f
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 14, 2025
238a00e
undo yarn.lock changes
ibex088 Jul 14, 2025
f539fa0
Update useAtomGetEventTypes.ts
ibex088 Jul 14, 2025
a150d1f
refactor: rename team to org in conferencing controller methods for c…
ibex088 Jul 14, 2025
80c1ef8
chore: add IsNumber validation decorator to delegationCredentialId field
ibex088 Jul 14, 2025
b4a6eec
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 15, 2025
77f854c
fix: update API documentation to correctly reference organization ins…
ibex088 Jul 15, 2025
bcf610d
refactor: rename OAuth callback handlers
ibex088 Jul 15, 2025
56d867f
chore: bump @calcom/platform-libraries from 0.0.258 to 0.0.259
ibex088 Jul 15, 2025
dca1636
Revert "refactor: extract handleOrgOAuthCallback method for cleaner o…
ibex088 Jul 15, 2025
f5081c5
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 15, 2025
b270660
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 17, 2025
ea31aa8
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 23, 2025
0611db2
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 23, 2025
cca5d8a
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 24, 2025
5c885f0
fix: user installed conferencing apps not visible in the locations dr…
ibex088 Jul 24, 2025
e3334a2
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 28, 2025
a6d4395
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 29, 2025
5a0ff01
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 30, 2025
f0cc40e
fix: expired/revoked permissions error showing for all app instances
ibex088 Jul 30, 2025
404c162
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Jul 31, 2025
5ffc1c2
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 4, 2025
1522749
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 4, 2025
21b345f
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 11, 2025
8d0235e
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 11, 2025
fe7f086
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 12, 2025
eaae740
feat: support team conferencing apps in user event-types
ibex088 Aug 12, 2025
79f6c28
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 21, 2025
b698e67
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 22, 2025
387c616
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 25, 2025
9938438
fix: type-error
ibex088 Aug 25, 2025
7e77820
fix: update mock to use enrichUserWithItsProfileSkipPlatformCheck
ibex088 Aug 25, 2025
bf0097f
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 25, 2025
bcab76c
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Aug 26, 2025
12dfa90
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Sep 26, 2025
b613d75
Update AppList.tsx
ibex088 Sep 26, 2025
81bd404
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Sep 26, 2025
fda04a8
fix: add missing API property decorators for conferencing app output …
ibex088 Oct 6, 2025
d9e12fb
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Oct 6, 2025
01c6a6b
fix: move user metadata parse after existence check to prevent null a…
ibex088 Oct 6, 2025
7144577
refactor: remove commented out check
ibex088 Oct 6, 2025
cdffc6d
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Oct 30, 2025
1c464a8
feat: add team credentials support for calendar delegation
ibex088 Oct 30, 2025
1f5266b
feat: add team and org-level conferencing app installation docs
ibex088 Oct 30, 2025
f05b20b
Merge branch 'main' into feat/orgs-conferencing-apps
ibex088 Oct 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Copy link
Contributor Author

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

Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ These endpoints should not be recommended for use by third party and are exclude
export class AtomsConferencingAppsController {
constructor(private readonly conferencingService: ConferencingAtomsService) {}

@Get("/organizations/:orgId/conferencing")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listOrgInstalledConferencingApps(
@GetUser() user: UserWithProfile,
@Param("orgId", ParseIntPipe) orgId: number
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, orgId);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}

@Get("/organizations/:orgId/teams/:teamId/conferencing")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class ConferencingAtomsService {
input: {
variant: "conferencing",
onlyInstalled: true,
includeTeamInstalledApps: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -169,6 +172,21 @@ export class ConferencingController {
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
return { url: fallbackUrl };
}
} else if (decodedCallbackState.orgId) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Expand All @@ -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" })
),
};
}

Expand All @@ -216,6 +236,32 @@ export class ConferencingController {
return { status: SUCCESS_STATUS };
}

@Post("/:app/default/:credentialId")
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export class ConferencingAppsOutputDto {
@ApiProperty({ description: "Id of the conferencing app credentials" })
id!: number;

@ApiProperty({ example: "zoom", description: "App identifier" })
@Expose()
@IsString()
@IsOptional()
appId?: string;

@ApiProperty({ example: GOOGLE_MEET_TYPE, description: "Type of conferencing app" })
@Expose()
@IsString()
Expand All @@ -18,7 +24,17 @@ export class ConferencingAppsOutputDto {
@ApiProperty({ description: "Id of the user associated to the conferencing app" })
@Expose()
@IsNumber()
userId!: number;
@IsOptional()
userId!: number | null;

@ApiPropertyOptional({
description: "Team ID if the credential belongs to a team",
nullable: true,
})
@Expose()
@IsNumber()
@IsOptional()
teamId?: number | null;

@ApiPropertyOptional({
example: true,
Expand All @@ -29,6 +45,15 @@ export class ConferencingAppsOutputDto {
@IsBoolean()
@IsOptional()
invalid?: boolean | null;

@ApiPropertyOptional({
description: "Delegation credential ID",
nullable: true,
})
@IsNumber()
@Expose()
@IsOptional()
delegationCredentialId?: number | null;
}

export class ConferencingAppsOutputResponseDto {
Expand Down
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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the properties in DefaultConferencingAppsOutputDto won't show up in v2 docs because missing ApiProperty - we need to add it so customers know what data to expect.

@ApiProperty()
readonly credentialId?: number;
}

export class GetDefaultConferencingAppOutputResponseDto {
Expand All @@ -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
Expand Up @@ -33,9 +33,25 @@ export class ConferencingRepository {
}

async findTeamConferencingApps(teamId: number) {
const parentTeam = await this.dbRead.prisma.team.findUnique({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be just const team because we then check ?.parentId

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,
Expand All @@ -62,4 +78,11 @@ export class ConferencingRepository {
select: this.credentialSelect,
});
}

async findMultipleTeamsConferencingApp(teamIds: number[], app: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why in findTeamConferencingApps we check parentId and here not?

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
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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) {
Expand All @@ -112,12 +123,17 @@ export class ConferencingService {
});
}

async setDefaultConferencingApp(user: UserWithProfile, app: string) {
async setDefaultConferencingApp(user: UserWithProfile, app: string, credentialId?: number) {
Copy link
Contributor Author

@ibex088 ibex088 Aug 26, 2025

Choose a reason for hiding this comment

The 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`);
Expand Down
Loading
Loading