feat: allow conferencing apps to be installed on teams (#19784)

* feat: added zoom, office365-video as valid event-type locations

* added docs

* Update stripe.service.ts

* enable conferencing apps for teams initial commit

* fix: make conferencing atom work with teams

* update team events in bulk update default location

* feat: Separate endpoints for team conferencing apps instead of query params

* Display all team events in 'Bulk update existing event types' modal when conferencing atom is enabled for Teams

* fix: bulk update default location for team events

* removed the organizations endpoints

* Refactor: Separate functions for team events to follow SRP

* undo extra changes

* undo extra changes

* fix: platform libraries build breaking

* feat: only allow integrations which are installed in create/update team/user event-type endpoints

* fix: merge conflicts

* remove redundant imports

* Update ConferencingAppsViewPlatformWrapper.tsx

* Update useBanners.ts

* fix: axios error response interceptor

* feat: update :app param docs in `organizations-conferencing.controller`

* Revert "fix: axios error response interceptor"

This reverts commit 729eac3eee9f81c19b5368bbae4b1666884221fd.

* Update useAtomsGetInstalledConferencingApps.ts

* upgrade platform libraries

* Update apps.ts

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
authored and GitHub committed 2025-04-02 11:32:11 -04:00
1 parent 563b31b7d0
commit fad5497363
62 files changed
+1443 -288

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.164",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.166",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
@Injectable()
export class BookingsRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -122,7 +122,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -150,11 +149,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeDefined();
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
const googleCalendarLink = response.body.data.find(
(item: { id: string }) => item.id === "googleCalendar"
).link;
const microsoftOfficeLink = response.body.data.find(
(item: { id: string }) => item.id === "microsoftOffice"
).link;
const microsoftOutlookLink = response.body.data.find(
(item: { id: string }) => item.id === "microsoftOutlook"
).link;
const icsLink = response.body.data.find((item: { id: string }) => item.id === "ics").link;
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
@@ -30,7 +30,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
const appleCalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);
@@ -94,7 +94,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
}
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
await this.credentialRepository.upsertAppCredential(
await this.credentialRepository.upsertUserAppCredential(
APPLE_CALENDAR_TYPE,
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
userId,
@@ -126,7 +126,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
await this.credentialRepository.upsertUserAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
@@ -172,7 +172,7 @@ export class CalendarsService {
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
credentialId?: number | null
) {
const credential = await this.credentialsRepository.upsertAppCredential(
const credential = await this.credentialsRepository.upsertUserAppCredential(
calendarType,
key,
userId,
@@ -84,7 +84,10 @@ export class GoogleCalendarService implements OAuthCalendarApp {
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"google_calendar",
userId
);
if (!gcalCredentials) {
throw new BadRequestException("Credentials for google_calendar not found.");
@@ -52,7 +52,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
);
}
const credential = await this.credentialRepository.upsertAppCredential(
const credential = await this.credentialRepository.upsertUserAppCredential(
ICS_CALENDAR_TYPE,
data.key,
userId
@@ -75,7 +75,10 @@ export class IcsFeedService implements ICSFeedCalendarApp {
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
const icsFeedCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
ICS_CALENDAR_TYPE,
userId
);
if (!icsFeedCredentials) {
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
@@ -71,7 +71,7 @@ export class OutlookService implements OAuthCalendarApp {
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
const office365CalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"office365_calendar",
userId
);
@@ -377,6 +377,27 @@ describe("Event types Endpoints", () => {
);
});
it("should not allow creating an event type with integration not installed on user", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "zoom",
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should create an event type", async () => {
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
type: "name",
@@ -1090,6 +1111,23 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should not allow to update event type with integration not installed on user", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
locations: [
{
type: "integration",
integration: "office365-video",
},
],
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -75,7 +75,7 @@ export class EventTypesController_2024_06_14 {
@GetUser() user: UserWithProfile
): Promise<CreateEventTypeOutput_2024_06_14> {
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
user.id,
user,
body
);
@@ -150,7 +150,7 @@ export class EventTypesController_2024_06_14 {
): Promise<UpdateEventTypeOutput_2024_06_14> {
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
body,
user.id,
user,
eventTypeId
);
@@ -8,6 +8,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -18,7 +19,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
providers: [
EventTypesRepository_2024_06_14,
EventTypesService_2024_06_14,
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -1,6 +1,7 @@
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
@@ -49,13 +50,15 @@ interface ValidationContext {
export class InputEventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly calendarsService: CalendarsService
private readonly calendarsService: CalendarsService,
private readonly conferencingService: ConferencingService
) {}
async transformAndValidateCreateEventTypeInput(
userId: UserWithProfile["id"],
user: UserWithProfile,
inputEventType: CreateEventTypeInput_2024_06_14
) {
await this.validateInputLocations(user, inputEventType.locations);
const transformedBody = this.transformInputCreateEventType(inputEventType);
await this.validateEventTypeInputs({
@@ -66,19 +69,21 @@ export class InputEventTypesService_2024_06_14 {
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
(await this.validateInputUseDestinationCalendarEmail(user.id));
return transformedBody;
}
async transformAndValidateUpdateEventTypeInput(
inputEventType: UpdateEventTypeInput_2024_06_14,
userId: UserWithProfile["id"],
user: UserWithProfile,
eventTypeId: number
) {
await this.validateInputLocations(user, inputEventType.locations);
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
await this.validateEventTypeInputs({
@@ -90,10 +95,10 @@ export class InputEventTypesService_2024_06_14 {
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
(await this.validateInputUseDestinationCalendarEmail(user.id));
return transformedBody;
}
@@ -448,4 +453,20 @@ export class InputEventTypesService_2024_06_14 {
return;
}
async validateInputLocations(
user: UserWithProfile,
inputLocations: CreateEventTypeInput_2024_06_14["locations"] | undefined
) {
await Promise.all(
inputLocations?.map(async (location) => {
if (location.type === "integration") {
// cal-video is global, so we can skip this check
if (location.integration !== "cal-video") {
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
}
}
}) ?? []
);
}
}
+4 -1
View File
@@ -94,7 +94,10 @@ export class GcalController {
@Permissions([APPS_READ])
@ApiOperation({ summary: "Check a calendar connection status" })
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"google_calendar",
userId
);
if (!gcalCredentials) {
throw new BadRequestException("Credentials for google_calendar not found.");
@@ -31,6 +31,15 @@ export class AppsRepository {
});
}
async deleteTeamAppCredentials(credentialIdsToDelete: number[], teamId: number) {
return this.dbWrite.prisma.credential.deleteMany({
where: {
id: { in: credentialIdsToDelete },
teamId,
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
@@ -8,6 +8,7 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -24,6 +25,7 @@ import { Module } from "@nestjs/common";
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
RedisService,
],
exports: [EventTypesAtomService],
controllers: [AtomsController],
@@ -5,8 +5,15 @@ import {
} 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 { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -60,14 +67,27 @@ export class AtomsController {
};
}
@Get("/organizations/:orgId/teams/:teamId/event-types")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/event-types")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventTypes(userId);
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
return {
status: SUCCESS_STATUS,
data: eventType,
data: eventTypes,
};
}
@@ -116,6 +136,22 @@ export class AtomsController {
};
}
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async bulkUpdateAtomTeamEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
return {
status: SUCCESS_STATUS,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -135,9 +171,11 @@ export class AtomsController {
};
}
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async updateAtomTeamEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@@ -155,13 +193,33 @@ export class AtomsController {
data: eventType,
};
}
@Get("/organizations/:orgId/teams/:teamId/conferencing")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamInstalledConferencingApps(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
@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 };
async listUserInstalledConferencingApps(
@GetUser() user: UserWithProfile
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
}
@@ -12,7 +12,7 @@ export class ConferencingAtomsService {
constructor(private readonly dbWrite: PrismaWriteService) {}
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
async getUserConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
return getConnectedApps({
user,
input: {
@@ -22,4 +22,17 @@ export class ConferencingAtomsService {
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
async getTeamConferencingApps(user: UserWithProfile, teamId: number): Promise<ConnectedApps> {
return getConnectedApps({
user,
input: {
variant: "conferencing",
onlyInstalled: true,
teamId,
includeTeamInstalledApps: true,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -22,9 +22,11 @@ import type {
LocationOption,
} from "@calcom/platform-libraries/app-store";
import {
getBulkEventTypes,
getEventTypeById,
bulkUpdateEventsToDefaultLocation,
bulkUpdateTeamEventsToDefaultLocation,
getBulkUserEventTypes,
getBulkTeamEventTypes,
} from "@calcom/platform-libraries/event-types";
import {
updateEventType,
@@ -83,7 +85,11 @@ export class EventTypesAtomService {
}
async getUserEventTypes(userId: number) {
return getBulkEventTypes(userId);
return getBulkUserEventTypes(userId);
}
async getTeamEventTypes(teamId: number) {
return getBulkTeamEventTypes(teamId);
}
async updateTeamEventType(
@@ -345,4 +351,12 @@ export class EventTypesAtomService {
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
async bulkUpdateTeamEventTypesDefaultLocation(eventTypeIds: number[], teamId: number) {
return bulkUpdateTeamEventsToDefaultLocation({
eventTypeIds,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
teamId,
});
}
}
+29 -3
View File
@@ -1,16 +1,22 @@
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
@Module({
@@ -23,8 +29,28 @@ import { PassportModule } from "@nestjs/passport";
TokensModule,
DeploymentsModule,
ProfilesModule,
forwardRef(() => OrganizationsModule),
],
providers: [
NextAuthGuard,
NextAuthStrategy,
ApiAuthGuard,
ApiAuthStrategy,
OAuthFlowService,
IsTeamInOrg,
RolesGuard,
IsOrgGuard,
IsAdminAPIEnabledGuard,
PlatformPlanGuard,
],
exports: [
NextAuthGuard,
ApiAuthGuard,
IsTeamInOrg,
RolesGuard,
IsOrgGuard,
IsAdminAPIEnabledGuard,
PlatformPlanGuard,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
})
export class AuthModule {}
@@ -21,7 +21,21 @@ export class PlatformPlanGuard implements CanActivate {
const orgId = request.params.orgId as string;
const user = request.user as ApiAuthGuardUser;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
return canAccess;
}
async checkPlatformPlanAccess({
teamId,
orgId,
user,
minimumPlan,
}: {
teamId?: string;
orgId?: string;
user: ApiAuthGuardUser;
minimumPlan: PlatformPlanType;
}): Promise<{ canAccess: boolean }> {
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:platformbilling:${minimumPlan}`;
@@ -29,7 +43,7 @@ export class PlatformPlanGuard implements CanActivate {
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
return { canAccess: cachedAccess };
}
let canAccess = false;
@@ -56,7 +70,7 @@ export class PlatformPlanGuard implements CanActivate {
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
if (canAccess) {
return canAccess;
return { canAccess };
}
throw new ForbiddenException(
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
@@ -18,22 +18,34 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const request = context.switchToHttp().getRequest<Request & { organization?: Team }>();
const organizationId: string = request.params.orgId;
if (!organizationId) {
throw new ForbiddenException("No organization id found in request params.");
}
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
if (organization) {
request.organization = organization;
}
return canAccess;
}
async checkAdminAPIEnabled(
organizationId: string
): Promise<{ canAccess: boolean; organization?: Team | null }> {
let canAccess = false;
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
return {
canAccess: cachedCanAccess,
organization: cachedOrg,
};
}
}
@@ -57,6 +69,6 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
"EX",
300
));
return canAccess;
return { canAccess, organization: org };
}
}
@@ -18,7 +18,6 @@ export class IsOrgGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const organizationId: string = request.params.orgId;
@@ -26,33 +25,45 @@ export class IsOrgGuard implements CanActivate {
throw new ForbiddenException("No organization id found in request params.");
}
const { canAccess, org } = await this.checkOrgAccess(organizationId);
if (canAccess && org) {
request.organization = org;
}
return canAccess;
}
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
let canAccess = false;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
return {
org: cachedOrg,
canAccess: cachedCanAccess,
};
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization) {
request.organization = org;
canAccess = true;
}
if (org) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
JSON.stringify({ org, canAccess } satisfies CachedData),
"EX",
300
);
}
return canAccess;
return { canAccess, org };
}
}
@@ -24,13 +24,22 @@ export class RolesGuard implements CanActivate {
const orgId = request.params.orgId as string;
const user = request.user as ApiAuthGuardUser;
const allowedRole = this.reflector.get(Roles, context.getHandler());
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
return canAccess;
}
async checkUserRoleAccess(
user: ApiAuthGuardUser,
orgId: string,
teamId: string,
allowedRole: string
): Promise<{ canAccess: boolean }> {
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:roles:${allowedRole}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
return { canAccess: cachedAccess };
}
let canAccess = false;
@@ -130,7 +139,7 @@ export class RolesGuard implements CanActivate {
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
return { canAccess };
}
}
@@ -15,7 +15,7 @@ export class IsTeamInOrg implements CanActivate {
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const request = context.switchToHttp().getRequest<Request & { team?: Team }>();
const teamId: string = request.params.teamId;
const orgId: string = request.params.orgId;
@@ -27,17 +27,25 @@ export class IsTeamInOrg implements CanActivate {
throw new ForbiddenException("No team id found in request params.");
}
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
if (canAccess && team) {
request.team = team;
return true;
}
return canAccess;
}
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
if (!team) {
throw new NotFoundException(`Team (${teamId}) not found.`);
}
return false;
if (!team.isOrganization && team.parentId === Number(orgId)) {
return { canAccess: true, team };
}
return { canAccess: false };
}
}
@@ -6,14 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [PrismaModule, ConfigModule],
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
providers: [
ConferencingService,
ConferencingRepository,
@@ -25,7 +26,13 @@ import { ConfigModule } from "@nestjs/config";
Office365VideoService,
AppsRepository,
],
exports: [],
exports: [
ZoomVideoService,
Office365VideoService,
GoogleMeetService,
ConferencingService,
ConferencingRepository,
],
controllers: [ConferencingController],
})
export class ConferencingModule {}
@@ -15,18 +15,16 @@ import {
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Logger } from "@nestjs/common";
import {
Controller,
Get,
Query,
HttpCode,
HttpStatus,
Logger,
UseGuards,
Post,
Param,
@@ -46,7 +44,8 @@ import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "
export type OAuthCallbackState = {
accessToken: string;
teamId?: number;
teamId?: string;
orgId?: string;
fromApp?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
@@ -63,9 +62,7 @@ export class ConferencingController {
constructor(
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
private readonly organizationsConferencingService: OrganizationsConferencingService
) {}
@Post("/:app/connect")
@@ -80,21 +77,11 @@ export class ConferencingController {
required: true,
})
async connect(
@GetUser("id") userId: number,
@GetUser() user: UserWithProfile,
@Param("app") app: string
): Promise<ConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
const credential = await this.googleMeetService.connectGoogleMeetApp(userId);
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[GOOGLE_MEET].join(", ")
);
}
const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id);
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
}
@Get("/:app/oauth/auth-url")
@@ -115,7 +102,6 @@ export class ConferencingController {
@Query("returnTo") returnTo?: string,
@Query("onErrorReturnTo") onErrorReturnTo?: string
): Promise<GetConferencingAppsOauthUrlResponseDto> {
let credential;
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
@@ -126,27 +112,12 @@ export class ConferencingController {
accessToken,
};
switch (app) {
case ZOOM:
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
const credential = await this.conferencingService.generateOAuthUrl(app, state);
case OFFICE_365_VIDEO:
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
}
@Get("/:app/oauth/callback")
@@ -176,23 +147,18 @@ export class ConferencingController {
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);
case OFFICE_365_VIDEO:
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
if (decodedCallbackState.orgId) {
return this.organizationsConferencingService.connectTeamOauthApps({
app,
code,
userId,
decodedCallbackState,
});
} else {
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
}
} catch (error) {
if (error instanceof HttpException || error instanceof Error) {
@@ -210,15 +176,13 @@ export class ConferencingController {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "List your conferencing applications" })
async listInstalledConferencingApps(
@GetUser("id") userId: number
@GetUser() user: UserWithProfile
): Promise<ConferencingAppsOutputResponseDto> {
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
const data = conferencingApps.map((conferencingApps) =>
plainToInstance(ConferencingAppsOutputDto, conferencingApps)
);
return { status: SUCCESS_STATUS, data };
const conferencingApps = await this.conferencingService.getConferencingApps(user.id);
return {
status: SUCCESS_STATUS,
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
};
}
@Post("/:app/default")
@@ -245,8 +209,8 @@ export class ConferencingController {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get your default conferencing application" })
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
async getDefault(@GetUser() user: UserWithProfile): Promise<GetDefaultConferencingAppOutputResponseDto> {
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(user.id);
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
}
@@ -17,6 +17,15 @@ export class ConferencingRepository {
});
}
async findTeamConferencingApps(teamId: number) {
return this.dbRead.prisma.credential.findMany({
where: {
teamId,
type: { endsWith: "_video" },
},
});
}
async findGoogleMeet(userId: number) {
return this.dbRead.prisma.credential.findFirst({
where: { userId, type: GOOGLE_MEET_TYPE },
@@ -28,4 +37,10 @@ export class ConferencingRepository {
where: { userId, appId: app },
});
}
async findTeamConferencingApp(teamId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({
where: { teamId, appId: app },
});
}
}
@@ -1,10 +1,20 @@
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { 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 {
CONFERENCING_APPS,
CAL_VIDEO,
GOOGLE_MEET,
ZOOM,
OFFICE_365_VIDEO,
} from "@calcom/platform-constants";
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
@@ -14,13 +24,53 @@ export class ConferencingService {
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
) {}
async getConferencingApps(userId: number) {
return this.conferencingRepository.findConferencingApps(userId);
}
async connectUserNonOauthApp(app: string, userId: number) {
switch (app) {
case GOOGLE_MEET:
const credential = await this.googleMeetService.connectGoogleMeetToUser(userId);
return credential;
default:
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
}
}
async connectOauthApps(
app: string,
code: string,
userId: number,
decodedCallbackState: OAuthCallbackState,
teamId?: number
) {
switch (app) {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
case OFFICE_365_VIDEO:
return await this.office365VideoService.connectOffice365App(
decodedCallbackState,
code,
userId,
teamId
);
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
}
async getUserDefaultConferencingApp(userId: number) {
const user = await this.usersRepository.findById(userId);
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
@@ -63,4 +113,20 @@ export class ConferencingService {
}
return true;
}
async generateOAuthUrl(app: string, state: OAuthCallbackState) {
switch (app) {
case ZOOM:
return await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
case OFFICE_365_VIDEO:
return await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
}
}
@@ -1,10 +1,9 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import { BadRequestException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants";
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
@Injectable()
export class GoogleMeetService {
@@ -12,35 +11,51 @@ export class GoogleMeetService {
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly usersRepository: UsersRepository
private readonly credentialsRepository: CredentialsRepository
) {}
async connectGoogleMeetApp(userId: number) {
const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId);
async connectGoogleMeetToUser(userId: number) {
await this.validateGoogleCalendarConnection(userId, "user");
const googleMeetExists = await this.conferencingRepository.findGoogleMeet(userId);
if (googleMeetExists) {
throw new BadRequestException("Google Meet is already connected for this user.");
}
return this.credentialsRepository.upsertUserAppCredential(GOOGLE_MEET_TYPE, {}, userId);
}
async connectGoogleMeetToTeam(teamId: number) {
await this.validateGoogleCalendarConnection(teamId, "team");
const googleMeetExists = await this.credentialsRepository.findCredentialByTypeAndTeamId(
GOOGLE_MEET_TYPE,
teamId
);
if (googleMeetExists) {
throw new BadRequestException("Google Meet is already connected for this team.");
}
return this.credentialsRepository.upsertTeamAppCredential(GOOGLE_MEET_TYPE, {}, teamId);
}
/**
* Validate that Google Calendar is connected and valid for either a user or a team.
*/
private async validateGoogleCalendarConnection(id: number, entity: "user" | "team") {
const googleCalendar =
entity === "user"
? await this.credentialsRepository.findCredentialByTypeAndUserId(GOOGLE_CALENDAR_TYPE, id)
: await this.credentialsRepository.findCredentialByTypeAndTeamId(GOOGLE_CALENDAR_TYPE, id);
if (!googleCalendar) {
throw new BadRequestException("Google Meet app requires a Google Calendar connection");
throw new BadRequestException("Google Meet requires a Google Calendar connection.");
}
if (googleCalendar.invalid) {
throw new BadRequestException(
"Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar."
"Google Meet requires a valid Google Calendar connection. Please reconnect Google Calendar."
);
}
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
if (googleMeet) {
throw new BadRequestException("Google Meet is already connected.");
}
const googleMeetCredential = await this.credentialsRepository.upsertAppCredential(
GOOGLE_MEET_TYPE,
{},
userId
);
return googleMeetCredential;
}
}
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -21,7 +22,11 @@ export class Office365VideoService {
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
constructor(
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialsRepository: CredentialsRepository
) {}
async getOffice365AppKeys() {
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
@@ -56,7 +61,7 @@ export class Office365VideoService {
return { url };
}
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
const { client_id, client_secret } = await this.getOffice365AppKeys();
const toUrlEncoded = (payload: Record<string, string>) =>
@@ -98,23 +103,30 @@ export class Office365VideoService {
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
type: OFFICE_365_VIDEO_TYPE,
userId,
appId: OFFICE_365_VIDEO,
});
const existingCredentialOffice365Video = teamId
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(OFFICE_365_VIDEO_TYPE, teamId)
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(OFFICE_365_VIDEO_TYPE, userId);
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
teamId
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
OFFICE_365_VIDEO_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
OFFICE_365_VIDEO
);
teamId
? await this.appsRepository.createTeamAppCredential(
OFFICE_365_VIDEO_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
teamId,
OFFICE_365_VIDEO
)
: await this.appsRepository.createAppCredential(
OFFICE_365_VIDEO_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
OFFICE_365_VIDEO
);
return { url: state.returnTo ?? "" };
}
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -20,7 +21,11 @@ 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) {}
constructor(
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialsRepository: CredentialsRepository
) {}
async getZoomAppKeys() {
const app = await this.appsRepository.getAppBySlug(ZOOM);
@@ -53,7 +58,7 @@ export class ZoomVideoService {
return { url };
}
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number, teamId?: 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")}`;
@@ -90,23 +95,31 @@ export class ZoomVideoService {
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
type: ZOOM_TYPE,
userId,
appId: ZOOM,
});
const existingCredentialZoomVideo = teamId
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(ZOOM_TYPE, teamId)
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(ZOOM_TYPE, userId);
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
teamId
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
ZOOM_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
ZOOM
);
teamId
? await this.appsRepository.createTeamAppCredential(
ZOOM_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
teamId,
ZOOM
)
: await this.appsRepository.createAppCredential(
ZOOM_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
ZOOM
);
return { url: state.returnTo ?? "" };
}
@@ -10,7 +10,7 @@ import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
export class CredentialsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async upsertAppCredential(
async upsertUserAppCredential(
type: keyof typeof APPS_TYPE_ID_MAPPING,
key: Prisma.InputJsonValue,
userId: number,
@@ -33,14 +33,45 @@ export class CredentialsRepository {
});
}
getByTypeAndUserId(type: string, userId: number) {
async upsertTeamAppCredential(
type: keyof typeof APPS_TYPE_ID_MAPPING,
key: Prisma.InputJsonValue,
teamId: number,
credentialId?: number | null
) {
return this.dbWrite.prisma.credential.upsert({
create: {
type,
key,
teamId,
appId: APPS_TYPE_ID_MAPPING[type],
},
update: {
key,
invalid: false,
},
where: {
id: credentialId ?? 0,
},
});
}
findCredentialByTypeAndUserId(type: string, userId: number) {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
getByTypeAndTeamId(type: string, teamId: number) {
findAllCredentialsByTypeAndUserId(type: string, userId: number) {
return this.dbWrite.prisma.credential.findMany({ where: { type, userId } });
}
findCredentialByTypeAndTeamId(type: string, teamId: number) {
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
}
findAllCredentialsByTypeAndTeamId(type: string, teamId: number) {
return this.dbWrite.prisma.credential.findMany({ where: { type, teamId } });
}
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
@@ -0,0 +1,224 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import {
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 { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Query,
HttpCode,
HttpStatus,
UseGuards,
Post,
Param,
Delete,
Headers,
Req,
ParseIntPipe,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
export type OAuthCallbackState = {
accessToken: string;
teamId?: string;
orgId?: string;
fromApp?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
};
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@DocsTags("Organizations/Teams Conferencing")
export class OrganizationsConferencingController {
constructor(
private readonly conferencingService: ConferencingService,
private readonly organizationsConferencingService: OrganizationsConferencingService
) {}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiParam({
name: "app",
description: "Conferencing application type",
enum: [GOOGLE_MEET],
required: true,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Post("/teams/:teamId/conferencing/:app/connect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Connect your conferencing application to a team" })
async connectTeamApp(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@Param("app") app: string
): Promise<ConferencingAppOutputResponseDto> {
const credential = await this.organizationsConferencingService.connectTeamNonOauthApps({
teamId,
app,
});
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiParam({
name: "app",
description: "Conferencing application type",
enum: [ZOOM, OFFICE_365_VIDEO],
required: true,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
async getTeamOAuthUrl(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("teamId") teamId: string,
@Param("orgId") orgId: string,
@Param("app") app: string,
@Query("returnTo") returnTo?: string,
@Query("onErrorReturnTo") onErrorReturnTo?: string
): Promise<GetConferencingAppsOauthUrlResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
const state: OAuthCallbackState = {
returnTo: returnTo ?? origin,
onErrorReturnTo: onErrorReturnTo ?? origin,
fromApp: false,
accessToken,
teamId,
orgId,
};
const credential = await this.conferencingService.generateOAuthUrl(app, state);
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/conferencing")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "List team conferencing applications" })
async listTeamConferencingApps(
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ConferencingAppsOutputResponseDto> {
const conferencingApps = await this.organizationsConferencingService.getConferencingApps({
teamId,
});
return {
status: SUCCESS_STATUS,
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
};
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Post("/teams/:teamId/conferencing/:app/default")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Set team default conferencing application" })
@ApiParam({
name: "app",
description: "Conferencing application type",
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
required: true,
})
async setTeamDefaultApp(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("app") app: string
): Promise<SetDefaultConferencingAppOutputResponseDto> {
await this.organizationsConferencingService.setDefaultConferencingApp({
teamId,
app,
});
return { status: SUCCESS_STATUS };
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/conferencing/default")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get team default conferencing application" })
@ApiParam({
name: "app",
description: "Conferencing application type",
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
required: true,
})
async getTeamDefaultApp(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<GetDefaultConferencingAppOutputResponseDto> {
const defaultConferencingApp = await this.organizationsConferencingService.getDefaultConferencingApp({
teamId,
});
return { status: SUCCESS_STATUS, data: defaultConferencingApp };
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Delete("/teams/:teamId/conferencing/:app/disconnect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Disconnect team conferencing application" })
@ApiParam({
name: "app",
description: "Conferencing application type",
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO],
required: true,
})
async disconnectTeamApp(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("app") app: string
): Promise<DisconnectConferencingAppOutputResponseDto> {
await this.organizationsConferencingService.disconnectConferencingApp({
teamId,
user,
app,
});
return { status: SUCCESS_STATUS };
}
}
@@ -0,0 +1,265 @@
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { GOOGLE_MEET } from "@calcom/platform-constants";
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
import { teamMetadataSchema } from "@calcom/platform-libraries";
import { handleDeleteCredential } from "@calcom/platform-libraries/app-store";
@Injectable()
export class OrganizationsConferencingService {
private logger = new Logger("OrganizationsConferencingService");
constructor(
private readonly conferencingRepository: ConferencingRepository,
private teamsRepository: TeamsRepository,
private usersRepository: UsersRepository,
private readonly googleMeetService: GoogleMeetService,
private readonly conferencingService: ConferencingService,
private readonly platformPlanGuard: PlatformPlanGuard,
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
private readonly isOrgGuard: IsOrgGuard,
private readonly isTeamInOrg: IsTeamInOrg,
private readonly rolesGuard: RolesGuard
) {}
async verifyAccess({
user,
orgId,
teamId,
requiredRole,
minimumPlan,
}: {
user: UserWithProfile;
orgId: string;
teamId?: string;
requiredRole?: string;
minimumPlan?: PlatformPlanType;
}): Promise<{ orgId: number; teamId: number | null }> {
const userWithAdminFlag = {
...user,
isSystemAdmin: user.role === "ADMIN",
};
if (!orgId) {
throw new BadRequestException("Organization ID is required");
}
await this.ensureOrganizationAccess(orgId);
await this.ensureAdminAPIEnabled(orgId);
// Only validate team if a teamId is provided
if (teamId && teamId.trim() !== "") {
await this.ensureTeamBelongsToOrg(orgId, teamId);
}
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
const parsedOrgId = parseInt(orgId, 10);
// For organization-level operations, teamId might be empty or null
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
return {
orgId: parsedOrgId,
teamId: parsedTeamId,
};
}
private async ensureOrganizationAccess(orgId: string): Promise<void> {
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
if (!canAccess) {
throw new ForbiddenException("Organization validation failed.");
}
}
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
if (!canAccess) {
throw new ForbiddenException("Admin API is not enabled for this organization.");
}
}
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
if (!canAccess) {
throw new ForbiddenException("Team is not part of the organization.");
}
}
private async ensureUserRoleAccess(
user: ApiAuthGuardUser,
orgId: string,
teamId?: string,
requiredRole?: string
): Promise<void> {
if (!requiredRole) return;
// For team-level roles, we need both orgId and teamId
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
throw new ForbiddenException("Team ID is required for team-level role access");
}
// Use empty string for teamId if it's an org-level operation
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
user,
orgId,
effectiveTeamId,
requiredRole
);
if (!canAccess) {
throw new ForbiddenException("User does not have the required role.");
}
}
private async ensurePlatformPlanAccess({
teamId,
orgId,
user,
minimumPlan,
}: {
teamId?: string;
orgId?: string;
user: ApiAuthGuardUser;
minimumPlan?: PlatformPlanType;
}): Promise<void> {
if (!minimumPlan) return;
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
teamId,
orgId,
user,
minimumPlan,
});
if (!canAccess) {
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
}
}
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
switch (app) {
case GOOGLE_MEET:
return this.googleMeetService.connectGoogleMeetToTeam(teamId);
default:
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
}
}
async connectTeamOauthApps({
decodedCallbackState,
app,
code,
userId,
}: {
userId: number;
app: string;
decodedCallbackState: OAuthCallbackState;
code: string;
}) {
const user = await this.usersRepository.findByIdWithProfile(userId);
const { orgId, teamId } = decodedCallbackState;
if (!orgId) {
throw new BadRequestException("orgId required");
}
if (!user) {
throw new BadRequestException("user not found");
}
// Determine if this is a team-level or organization-level operation
const isTeamLevel = !!teamId;
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
// Verify access with appropriate parameters
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
user,
orgId,
teamId,
requiredRole,
minimumPlan: "ESSENTIALS",
});
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
}
async getConferencingApps({ teamId }: { teamId: number }) {
return this.conferencingRepository.findTeamConferencingApps(teamId);
}
async getDefaultConferencingApp({
teamId,
}: {
teamId: number;
}): Promise<DefaultConferencingAppsOutputDto | undefined> {
const team = await this.teamsRepository.getById(teamId);
return teamMetadataSchema.parse(team?.metadata)?.defaultConferencingApp;
}
async checkAppIsValidAndConnected(teamId: number, app: string) {
if (!CONFERENCING_APPS.includes(app)) {
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
}
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
if (!credential) {
throw new BadRequestException(`${app} not connected.`);
}
return credential;
}
async disconnectConferencingApp({
teamId,
user,
app,
}: {
teamId: number;
user: UserWithProfile;
app: string;
}) {
const credential = await this.checkAppIsValidAndConnected(teamId, app);
return handleDeleteCredential({
userId: user.id,
teamId,
userMetadata: user?.metadata,
credentialId: credential.id,
});
}
async setDefaultConferencingApp({ teamId, app }: { teamId: number; app: string }) {
// cal-video is global, so we can skip this check
if (app !== CAL_VIDEO) {
await this.checkAppIsValidAndConnected(teamId, app);
}
const team = await this.teamsRepository.setDefaultConferencingApp(teamId, app);
const metadata = team.metadata as { defaultConferencingApp?: { appSlug?: string } };
if (metadata?.defaultConferencingApp?.appSlug !== app) {
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
}
return true;
}
}
@@ -1,4 +1,5 @@
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -25,13 +26,15 @@ export class InputOrganizationsEventTypesService {
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly teamsRepository: TeamsRepository,
private readonly usersRepository: UsersRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly conferencingService: OrganizationsConferencingService
) {}
async transformAndValidateCreateTeamEventTypeInput(
userId: number,
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
await this.validateInputLocations(teamId, inputEventType.locations);
await this.validateHosts(teamId, inputEventType.hosts);
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
@@ -62,6 +65,7 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
await this.validateInputLocations(teamId, inputEventType.locations);
await this.validateHosts(teamId, inputEventType.hosts);
if (inputEventType.slug) {
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
@@ -293,6 +297,22 @@ export class InputOrganizationsEventTypesService {
}
}
}
async validateInputLocations(
teamId: number,
inputLocations?: CreateTeamEventTypeInput_2024_06_14["locations"]
) {
await Promise.all(
inputLocations?.map(async (location) => {
if (location.type === "integration") {
// cal-video is global, so we can skip this check
if (location.integration !== "cal-video") {
await this.conferencingService.checkAppIsValidAndConnected(teamId, location.integration);
}
}
}) ?? []
);
}
}
function getPriorityValue(priority: keyof typeof HostPriority): number {
@@ -1,5 +1,7 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { AuthModule } from "@/modules/auth/auth.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
@@ -11,6 +13,8 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
@@ -52,7 +56,7 @@ import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
@Module({
imports: [
@@ -65,6 +69,8 @@ import { Module } from "@nestjs/common";
EventTypesModule_2024_06_14,
TeamsEventTypesModule,
TeamsModule,
forwardRef(() => ConferencingModule),
forwardRef(() => AuthModule),
OrganizationsDelegationCredentialModule,
OrganizationsOrganizationsModule,
OrganizationsTeamsRoutingFormsModule,
@@ -101,6 +107,7 @@ import { Module } from "@nestjs/common";
UserOOORepository,
OrgUsersOOOService,
OrgUsersOOORepository,
OrganizationsConferencingService,
],
exports: [
OrganizationsService,
@@ -121,6 +128,7 @@ import { Module } from "@nestjs/common";
WebhooksRepository,
WebhooksService,
OrganizationsEventTypesService,
OrganizationsConferencingService,
],
controllers: [
OrganizationsTeamsController,
@@ -134,6 +142,7 @@ import { Module } from "@nestjs/common";
OrganizationsWebhooksController,
OrganizationsTeamsSchedulesController,
OrganizationsUsersOOOController,
OrganizationsConferencingController,
],
})
export class OrganizationsModule {}
@@ -142,13 +142,19 @@ export class StripeService {
}
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"stripe_payment",
userId
);
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
"stripe_payment",
teamId
);
return await this.validateStripeCredentials(stripeCredentials);
}
@@ -217,6 +217,32 @@ describe("Organizations Event Types Endpoints", () => {
);
});
it("should not allow creating an event type with integration not installed on team", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation",
slug: "coding-consultation",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "zoom",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teamMember1.id,
},
{
userId: teamMember2.id,
},
],
};
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/event-types`).send(body).expect(400);
});
it("should create a collective team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-collective-${randomString()}`,
@@ -447,6 +473,22 @@ describe("Organizations Event Types Endpoints", () => {
.expect(400);
});
it("should not allow to update event type with integration not installed on team", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
locations: [
{
type: "integration",
integration: "office365-video",
},
],
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(400);
});
it("should update collective event-type", async () => {
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
@@ -1,5 +1,8 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { AuthModule } from "@/modules/auth/auth.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
@@ -11,7 +14,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
@Module({
imports: [
@@ -21,6 +24,8 @@ import { Module } from "@nestjs/common";
EventTypesModule_2024_06_14,
UsersModule,
TeamsModule,
forwardRef(() => ConferencingModule),
forwardRef(() => AuthModule),
],
providers: [
TeamsEventTypesRepository,
@@ -29,6 +34,7 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OutputTeamEventTypesResponsePipe,
OutputOrganizationsEventTypesService,
OrganizationsConferencingService,
],
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
controllers: [TeamsEventTypesController],
@@ -1,7 +1,8 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { teamMetadataSchema } from "@calcom/platform-libraries";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -83,4 +84,30 @@ export class TeamsRepository {
where: { id: teamId },
});
}
async setDefaultConferencingApp(teamId: number, appSlug?: string, appLink?: string) {
const team = await this.getById(teamId);
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
if (!team) {
throw new NotFoundException("user not found");
}
return await this.dbWrite.prisma.team.update({
data: {
metadata:
typeof teamMetadata === "object"
? {
...teamMetadata,
defaultConferencingApp: {
appSlug: appSlug,
appLink: appLink,
},
}
: {},
},
where: { id: teamId },
});
}
}
+4
View File
@@ -33,6 +33,8 @@ export function createAppsFixture(page: Page) {
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
@@ -79,6 +81,8 @@ export function createAppsFixture(page: Page) {
await page.getByTestId("install-app-button").click();
await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`);
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
@@ -1,11 +1,11 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
import { getBulkUserEventTypes } from "@calcom/lib/event-types/getBulkEventTypes";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
const setDefaultConferencingApp = async (userId: number, appSlug: string) => {
const eventTypes = await getBulkEventTypes(userId);
const eventTypes = await getBulkUserEventTypes(userId);
const eventTypeIds = eventTypes.eventTypes.map((item) => item.id);
const foundApp = getAppFromSlug(appSlug);
const appType = foundApp?.appData?.location?.type;
@@ -99,7 +99,7 @@ export const AppList = ({
<Button StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
{!appIsDefault && variant === "conferencing" && (
<DropdownMenuItem>
<DropdownItem
type="button"
@@ -0,0 +1,62 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import type { PrismaClient } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
export const bulkUpdateTeamEventsToDefaultLocation = async ({
eventTypeIds,
teamId,
prisma,
}: {
eventTypeIds: number[];
teamId: number;
prisma: PrismaClient;
}) => {
const team = await prisma.team.findFirst({
where: { id: teamId },
select: { metadata: true },
});
const defaultApp = teamMetadataSchema.parse(team?.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: {
teamId,
appId: foundApp.slug,
},
select: {
id: true,
},
});
return await prisma.eventType.updateMany({
where: {
id: {
in: eventTypeIds,
},
teamId,
},
data: {
locations: [
{ type: appType, link: defaultApp.appLink, credentialId: credential?.id },
] as LocationObject[],
},
});
};
+36 -14
View File
@@ -2,19 +2,11 @@ import { getAppFromLocationValue } from "@calcom/app-store/utils";
import { prisma } from "@calcom/prisma";
import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/zod-utils";
const getBulkEventTypes = async (userId: number) => {
const eventTypes = await prisma.eventType.findMany({
where: {
userId,
team: null,
},
select: {
id: true,
title: true,
locations: true,
},
});
/**
* Process event types to add logo information
* @param eventTypes - The event types to process
*/
const processEventTypes = (eventTypes: { id: number; title: string; locations: unknown }[]) => {
const eventTypesWithLogo = eventTypes.map((eventType) => {
const locationParsed = eventTypeLocationsSchema.safeParse(eventType.locations);
@@ -35,4 +27,34 @@ const getBulkEventTypes = async (userId: number) => {
};
};
export default getBulkEventTypes;
export const getBulkUserEventTypes = async (userId: number) => {
const eventTypes = await prisma.eventType.findMany({
where: {
userId: userId,
teamId: null,
},
select: {
id: true,
title: true,
locations: true,
},
});
return processEventTypes(eventTypes);
};
export const getBulkTeamEventTypes = async (teamId: number) => {
const eventTypes = await prisma.eventType.findMany({
where: {
userId: null,
teamId: teamId,
},
select: {
id: true,
title: true,
locations: true,
},
});
return processEventTypes(eventTypes);
};
@@ -3,6 +3,8 @@ import { z } from "zod";
const integrationsApiAvailable = {
"cal-video": z.literal("integrations:daily"),
"google-meet": z.literal("integrations:google:meet"),
zoom: z.literal("integrations:zoom"),
"office365-video": z.literal("integrations:office365_video"),
};
// note(Lauris): these are read only aka they exist in database and are to be returned by a READ operation
+1
View File
@@ -137,6 +137,7 @@ export async function getConnectedApps({
const teamAppCredentials = userTeams.flatMap((teamApp) => {
return teamApp.credentials ? buildNonDelegationCredentials(teamApp.credentials.flat()) : [];
});
if (!includeTeamInstalledApps || teamId) {
credentials = teamAppCredentials;
} else {
@@ -46,6 +46,7 @@ type ConferencingAppsViewPlatformWrapperProps = {
disableToasts?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
teamId?: number;
};
type RemoveAppParams = { callback: () => void; app?: App["slug"] };
@@ -71,6 +72,7 @@ export const ConferencingAppsViewPlatformWrapper = ({
disableToasts = false,
returnTo,
onErrorReturnTo,
teamId,
}: ConferencingAppsViewPlatformWrapperProps) => {
const { t } = useLocale();
const queryClient = useQueryClient();
@@ -101,9 +103,9 @@ export const ConferencingAppsViewPlatformWrapper = ({
updateModal({ isOpen: true, credentialId, app });
};
const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps();
const { data: defaultConferencingApp } = useGetDefaultConferencingApp();
const { data: eventTypesQuery, isFetching: isEventTypesFetching } = useAtomGetEventTypes();
const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps(teamId);
const { data: defaultConferencingApp } = useGetDefaultConferencingApp(teamId);
const { data: eventTypesQuery, isFetching: isEventTypesFetching } = useAtomGetEventTypes(teamId);
const deleteCredentialMutation = useDeleteCredential({
onSuccess: () => {
@@ -120,11 +122,16 @@ export const ConferencingAppsViewPlatformWrapper = ({
showToast(t("error_removing_app"), "error");
handleModelClose();
},
teamId,
});
const updateDefaultAppMutation = useUpdateUserDefaultConferencingApp({});
const updateDefaultAppMutation = useUpdateUserDefaultConferencingApp({
teamId,
});
const bulkUpdateEventTypesToDefaultLocation = useAtomBulkUpdateEventTypesToDefaultLocation({});
const bulkUpdateEventTypesToDefaultLocation = useAtomBulkUpdateEventTypesToDefaultLocation({
teamId,
});
const handleRemoveApp = ({ app }: RemoveAppParams) => {
!!app && deleteCredentialMutation.mutate(app);
@@ -176,6 +183,7 @@ export const ConferencingAppsViewPlatformWrapper = ({
},
returnTo,
onErrorReturnTo,
teamId,
});
const AddConferencingButtonPlatform = ({ installedApps }: { installedApps?: Array<{ slug: string }> }) => {
@@ -4,6 +4,7 @@ import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { EventType } from "@calcom/prisma/client";
import { useAtomsContext } from "../../../hooks/useAtomsContext";
import http from "../../../lib/http";
export const QUERY_KEY = "bulk-update-event-types-to-default-location";
@@ -12,6 +13,7 @@ export type UseAtomBulkUpdateEventTypesToDefaultLocationProps = {
onSuccess?: () => void;
onError?: (err: Error) => void;
onSettled?: () => void;
teamId?: number;
};
export const useAtomBulkUpdateEventTypesToDefaultLocation = ({
onSuccess = () => {
@@ -23,14 +25,20 @@ export const useAtomBulkUpdateEventTypesToDefaultLocation = ({
onSettled = () => {
return;
},
teamId,
}: UseAtomBulkUpdateEventTypesToDefaultLocationProps) => {
const { organizationId } = useAtomsContext();
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`;
let pathname = `/atoms/${V2_ENDPOINTS.eventTypes}/bulk-update-to-default-location`;
if (teamId) {
pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/${V2_ENDPOINTS.eventTypes}/bulk-update-to-default-location`;
}
return http?.patch(pathname, { eventTypeIds }).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import { useAtomsContext } from "../../../hooks/useAtomsContext";
@@ -10,12 +10,17 @@ 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();
export const useAtomGetEventTypes = (teamId?: number) => {
const { isInit, accessToken, organizationId } = useAtomsContext();
let pathname = "/atoms/event-types";
if (teamId) {
pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/event-types`;
}
return useQuery({
queryKey: [QUERY_KEY],
queryKey: [QUERY_KEY, teamId, organizationId],
queryFn: () => {
return http?.get<ApiResponse<ResponseEventType>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type { ConnectedApps } from "@calcom/lib/getConnectedApps";
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";
@@ -9,19 +9,29 @@ import http from "../../../lib/http";
export const QUERY_KEY = "get-installed-conferencing-apps";
export const useAtomsGetInstalledConferencingApps = () => {
const pathname = `/atoms/conferencing`;
const { isInit, accessToken } = useAtomsContext();
export const useAtomsGetInstalledConferencingApps = (teamId?: number) => {
const { isInit, accessToken, organizationId } = useAtomsContext();
let pathname = "/atoms/conferencing";
if (teamId) {
pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/conferencing`;
}
return useQuery({
queryKey: [QUERY_KEY],
queryKey: [QUERY_KEY, teamId, organizationId],
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);
});
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);
})
.catch(() => {
throw new Error("Failed to get installed conferencing apps");
});
},
enabled: isInit && !!accessToken,
});
@@ -11,55 +11,76 @@ import {
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
import type { App } from "@calcom/types/App";
import { useAtomsContext } from "../../../hooks/useAtomsContext";
import http from "../../../lib/http";
export type UseGetOauthAuthUrlProps = {
returnTo?: string;
onErrorReturnTo?: string;
teamId?: number;
};
export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo, teamId }: UseGetOauthAuthUrlProps) => {
const { organizationId } = useAtomsContext();
let pathname = `conferencing/${ZOOM}/oauth/auth-url`;
if (teamId) {
pathname = `organizations/${organizationId}/teams/${teamId}/conferencing/${ZOOM}/oauth/auth-url`;
}
const queryParams = new URLSearchParams();
if (returnTo) queryParams.append("returnTo", returnTo);
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
return useQuery({
queryKey: ["get-zoom-auth-url"],
queryKey: ["get-zoom-auth-url", teamId, organizationId],
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 "";
});
return http?.get<ApiResponse<{ url: string }>>(fullPath).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 const useOffice365GetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
export const useOffice365GetOauthAuthUrl = ({
returnTo,
onErrorReturnTo,
teamId,
}: UseGetOauthAuthUrlProps) => {
const { organizationId } = useAtomsContext();
let pathname = `conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`;
if (teamId) {
pathname = `organizations/${organizationId}/teams/${teamId}/conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`;
}
// Add query parameters
const queryParams = new URLSearchParams();
if (returnTo) queryParams.append("returnTo", returnTo);
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
return useQuery({
queryKey: ["get-office365-auth-url"],
queryKey: ["get-office365-auth-url", teamId, organizationId],
staleTime: Infinity,
enabled: false,
queryFn: () => {
return http
?.get<ApiResponse<{ url: string }>>(
`conferencing/${OFFICE_365_VIDEO}/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 "";
});
return http?.get<ApiResponse<{ url: string }>>(fullPath).then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
return responseBody.data.url;
}
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
return "";
});
},
});
};
@@ -69,10 +90,11 @@ export type UseConnectGoogleMeetProps = {
onError?: (err: ApiErrorResponse) => void;
returnTo?: string;
onErrorReturnTo?: string;
teamId?: number;
};
export const useConnectNonOauthApp = (
{ onSuccess, onError }: UseConnectGoogleMeetProps = {
{ onSuccess, onError, teamId, returnTo, onErrorReturnTo }: UseConnectGoogleMeetProps = {
onSuccess: () => {
return;
},
@@ -81,10 +103,21 @@ export const useConnectNonOauthApp = (
},
}
) => {
const { organizationId } = useAtomsContext();
return useMutation({
mutationFn: (app: string) => {
const pathname = `/conferencing/${app}/connect`;
return http?.post(pathname).then((res) => {
let pathname = `/conferencing/${app}/connect`;
if (teamId) {
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/connect`;
}
const queryParams = new URLSearchParams();
if (returnTo) queryParams.append("returnTo", returnTo);
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
return http?.post(fullPath).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return { status: res.data };
} else {
@@ -97,9 +130,9 @@ export const useConnectNonOauthApp = (
});
};
export const useConnect = ({ returnTo, onErrorReturnTo, ...props }: UseConnectGoogleMeetProps) => {
const { refetch: refetchZoomAuthUrl } = useGetZoomOauthAuthUrl({ returnTo, onErrorReturnTo });
const { refetch: refetchOffice365AuthUrl } = useOffice365GetOauthAuthUrl({ returnTo, onErrorReturnTo });
export const useConnect = (props: UseConnectGoogleMeetProps) => {
const { refetch: refetchZoomAuthUrl } = useGetZoomOauthAuthUrl(props);
const { refetch: refetchOffice365AuthUrl } = useOffice365GetOauthAuthUrl(props);
const connectNonOauthApp = useConnectNonOauthApp(props);
const connect = async (app: App["slug"]) => {
@@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { App } from "@calcom/types/App";
import { useAtomsContext } from "../../../hooks/useAtomsContext";
import http from "../../../lib/http";
export const QUERY_KEY = "use-delete-credential";
@@ -10,15 +11,22 @@ export type UseDeleteEventTypeProps = {
onSuccess?: () => void;
onError?: (err: Error) => void;
onSettled?: () => void;
teamId?: number;
};
export const useDeleteCredential = ({ onSuccess, onError, onSettled }: UseDeleteEventTypeProps) => {
export const useDeleteCredential = ({ onSuccess, onError, onSettled, teamId }: UseDeleteEventTypeProps) => {
const { organizationId } = useAtomsContext();
return useMutation({
onSuccess,
onError,
onSettled,
mutationFn: (app: App["slug"]) => {
if (!app) throw new Error("app is required");
const pathname = `/conferencing/${app}/disconnect`;
let pathname = `/conferencing/${app}/disconnect`;
if (teamId) {
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/disconnect`;
}
return http?.delete(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return;
@@ -15,12 +15,17 @@ type ResponseDataType =
appLink?: string;
}
| undefined;
export const useGetDefaultConferencingApp = () => {
const pathname = `/conferencing/default`;
const { isInit, accessToken } = useAtomsContext();
export const useGetDefaultConferencingApp = (teamId?: number) => {
const { isInit, accessToken, organizationId } = useAtomsContext();
let pathname = `/conferencing/default`;
if (teamId) {
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/default`;
}
return useQuery({
queryKey: [QUERY_KEY],
queryKey: [QUERY_KEY, teamId, organizationId],
queryFn: () => {
return http?.get<ApiResponse<ResponseDataType>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
@@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { App } from "@calcom/types/App";
import { useAtomsContext } from "../../../hooks/useAtomsContext";
import http from "../../../lib/http";
export const QUERY_KEY = "use-update-user-default-conferencing-app";
@@ -10,19 +11,27 @@ export type UseUpdateUserDefaultConferencingAppProps = {
onSuccess?: () => void;
onError?: (err: Error) => void;
onSettled?: () => void;
teamId?: number;
};
export const useUpdateUserDefaultConferencingApp = ({
onSuccess,
onError,
onSettled,
teamId,
}: UseUpdateUserDefaultConferencingAppProps) => {
const { organizationId } = useAtomsContext();
return useMutation({
onSuccess,
onError,
onSettled,
mutationFn: (app: App["slug"]) => {
if (!app) throw new Error("app is required");
const pathname = `/conferencing/${app}/default`;
let pathname = `/conferencing/${app}/default`;
if (teamId) {
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/default`;
}
return http?.post(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return;
@@ -15,7 +15,7 @@ export default function ConferencingApps(props: { calUsername: string; calEmail:
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
<Navbar username={props.calUsername} />
<div className="my-8">
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} />
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} teamId={35} />
</div>
</main>
);
+8 -5
View File
@@ -1,5 +1,6 @@
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
export { getBulkUserEventTypes, getBulkTeamEventTypes } from "@calcom/lib/event-types/getBulkEventTypes";
export { createHandler as createEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/create.handler";
export { updateHandler as updateEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/update.handler";
@@ -54,7 +55,11 @@ export type {
InternalLocation,
} from "@calcom/lib/event-types/transformers";
export { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export {
EventTypeMetaDataSchema,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/prisma/zod-utils";
export { validateCustomEventName } from "@calcom/lib/event";
@@ -63,11 +68,9 @@ export { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByVi
export type { EventType } from "@calcom/lib/event-types/getEventTypeById";
export type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
export type { UpdateEventTypeReturn } from "@calcom/trpc/server/routers/viewer/eventTypes/update.handler";
export { eventTypeBookingFields, eventTypeLocations } from "@calcom/prisma/zod-utils";
export { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
export { getBulkEventTypes };
export type PublicEventType = Awaited<ReturnType<typeof getPublicEvent>>;
export { bulkUpdateEventsToDefaultLocation } from "@calcom/lib/bulkUpdateEventsToDefaultLocation";
export { bulkUpdateTeamEventsToDefaultLocation } from "@calcom/lib/bulkUpdateTeamEventsToDefaultLocation";
export { getPublicEvent };
+1 -1
View File
@@ -61,7 +61,7 @@ export { getAllUserBookings };
export { getBookingInfo };
export { handleCancelBooking };
export { userMetadata, bookingMetadataSchema } from "@calcom/prisma/zod-utils";
export { userMetadata, bookingMetadataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
export { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
+1
View File
@@ -375,6 +375,7 @@ export enum BillingPeriod {
export const teamMetadataSchema = z
.object({
defaultConferencingApp: schemaDefaultConferencingApp.optional(),
requestedSlug: z.string().or(z.null()),
paymentId: z.string(),
subscriptionId: z.string().nullable(),
@@ -1,4 +1,4 @@
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
import { getBulkUserEventTypes } from "@calcom/lib/event-types/getBulkEventTypes";
import type { TrpcSessionUser } from "../../../types";
@@ -9,5 +9,5 @@ type BulkEventFetchOptions = {
};
export const bulkEventFetchHandler = async ({ ctx }: BulkEventFetchOptions) => {
return getBulkEventTypes(ctx.user.id);
return getBulkUserEventTypes(ctx.user.id);
};
+5 -5
View File
@@ -2297,7 +2297,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.164"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.166"
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
@@ -3336,13 +3336,13 @@ __metadata:
languageName: node
linkType: hard
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.164":
version: 0.0.164
resolution: "@calcom/platform-libraries@npm:0.0.164"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.166":
version: 0.0.166
resolution: "@calcom/platform-libraries@npm:0.0.166"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: f612a3e2002950ed00d3479a121b92ee3245f9cb00b663824cac45bb5ab8f74182271377cd8866563a5d11983bc8af08072bfdb58bf459e1c7cd2ec025a50466
checksum: 4c00e7ea01b099503fcab4da5b5808ccdb905699880fac36981e96836b037be8ef54c3bca82fa3a23dfa2f1c7eca3f6db5d5def41059e324ec504182efe43164
languageName: node
linkType: hard