diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index b656de72d7..d2ecc5c251 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts index c8c1d9aea5..fe0144d079 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts @@ -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) {} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/calendar-links.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/calendar-links.e2e-spec.ts index 1e8bdde13c..8785a04192 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/calendar-links.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/calendar-links.e2e-spec.ts @@ -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\//); diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts index 59e8ac0131..ebf5ce4315 100644 --- a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -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}`); } diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index c338754ef9..cf2da5568d 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -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, diff --git a/apps/api/v2/src/ee/calendars/services/gcal.service.ts b/apps/api/v2/src/ee/calendars/services/gcal.service.ts index 292c611fbd..2a683be6bf 100644 --- a/apps/api/v2/src/ee/calendars/services/gcal.service.ts +++ b/apps/api/v2/src/ee/calendars/services/gcal.service.ts @@ -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."); diff --git a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts index e1a9acc7a3..74ce98c8ba 100644 --- a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts +++ b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts @@ -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."); diff --git a/apps/api/v2/src/ee/calendars/services/outlook.service.ts b/apps/api/v2/src/ee/calendars/services/outlook.service.ts index 075f0018f7..3282975042 100644 --- a/apps/api/v2/src/ee/calendars/services/outlook.service.ts +++ b/apps/api/v2/src/ee/calendars/services/outlook.service.ts @@ -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 ); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts index b39cab94fb..d2df60fe7b 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -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}`) diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts index ed15c46f74..e901f56303 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts @@ -75,7 +75,7 @@ export class EventTypesController_2024_06_14 { @GetUser() user: UserWithProfile ): Promise { const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput( - user.id, + user, body ); @@ -150,7 +150,7 @@ export class EventTypesController_2024_06_14 { ): Promise { const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput( body, - user.id, + user, eventTypeId ); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts index 23b8cda8e3..772a243c83 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts @@ -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, diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts index 032de256d0..0ed17a4c98 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts @@ -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() diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts index 71388be1f8..14b90a6879 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts @@ -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); + } + } + }) ?? [] + ); + } } diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts index 1104de2358..817abec19f 100644 --- a/apps/api/v2/src/ee/gcal/gcal.controller.ts +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -94,7 +94,10 @@ export class GcalController { @Permissions([APPS_READ]) @ApiOperation({ summary: "Check a calendar connection status" }) async check(@GetUser("id") userId: number): Promise { - 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."); diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts index 5495abb49f..6cb737d1b5 100644 --- a/apps/api/v2/src/modules/apps/apps.repository.ts +++ b/apps/api/v2/src/modules/apps/apps.repository.ts @@ -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: { diff --git a/apps/api/v2/src/modules/atoms/atoms.module.ts b/apps/api/v2/src/modules/atoms/atoms.module.ts index 7b55b27910..c7aa268984 100644 --- a/apps/api/v2/src/modules/atoms/atoms.module.ts +++ b/apps/api/v2/src/modules/atoms/atoms.module.ts @@ -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], diff --git a/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts b/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts index 231f5ec61e..8cacecf683 100644 --- a/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts +++ b/apps/api/v2/src/modules/atoms/controllers/atoms.controller.ts @@ -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> { + 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> { - const eventType = await this.eventTypesService.getUserEventTypes(userId); + async listUserEventTypes(@GetUser("id") userId: number): Promise> { + 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> { + 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> { - const conferencingApps = await this.conferencingService.getConferencingApps(user); - - return { status: SUCCESS_STATUS, data: conferencingApps }; + async listUserInstalledConferencingApps( + @GetUser() user: UserWithProfile + ): Promise> { + const conferencingApps = await this.conferencingService.getUserConferencingApps(user); + return { + status: SUCCESS_STATUS, + data: conferencingApps, + }; } } diff --git a/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts index 2938b37dab..2f4845fe76 100644 --- a/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/conferencing-atom.service.ts @@ -12,7 +12,7 @@ export class ConferencingAtomsService { constructor(private readonly dbWrite: PrismaWriteService) {} - async getConferencingApps(user: UserWithProfile): Promise { + async getUserConferencingApps(user: UserWithProfile): Promise { 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 { + return getConnectedApps({ + user, + input: { + variant: "conferencing", + onlyInstalled: true, + teamId, + includeTeamInstalledApps: true, + }, + prisma: this.dbWrite.prisma as unknown as PrismaClient, + }); + } } diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index 118a333e46..5c767ef193 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -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, + }); + } } diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts index eea576c84e..d78b41404b 100644 --- a/apps/api/v2/src/modules/auth/auth.module.ts +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts index 7231092297..61faa42521 100644 --- a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts @@ -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}.` diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts index e93bf1dd20..9789522c4b 100644 --- a/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts @@ -18,22 +18,34 @@ export class IsAdminAPIEnabledGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - let canAccess = false; - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); 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 }; } } diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts index 02484fcdba..08a25dca3d 100644 --- a/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts @@ -18,7 +18,6 @@ export class IsOrgGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - let canAccess = false; const request = context.switchToHttp().getRequest(); 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 }; } } diff --git a/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts index 8756281ff9..0ecdb35f41 100644 --- a/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts @@ -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 }; } } diff --git a/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts index 88cc7d90ee..b4f2832626 100644 --- a/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts @@ -15,7 +15,7 @@ export class IsTeamInOrg implements CanActivate { constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {} async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); 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 }; } } diff --git a/apps/api/v2/src/modules/conferencing/conferencing.module.ts b/apps/api/v2/src/modules/conferencing/conferencing.module.ts index 0972f7b9e9..982572c630 100644 --- a/apps/api/v2/src/modules/conferencing/conferencing.module.ts +++ b/apps/api/v2/src/modules/conferencing/conferencing.module.ts @@ -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 {} diff --git a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts index 2d0710810b..faec243dd7 100644 --- a/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts +++ b/apps/api/v2/src/modules/conferencing/controllers/conferencing.controller.ts @@ -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 { - 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 { - 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 { - 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 { - const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId); + async getDefault(@GetUser() user: UserWithProfile): Promise { + const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(user.id); return { status: SUCCESS_STATUS, data: defaultconferencingApp }; } diff --git a/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts b/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts index 546a5df73e..a8c6d9840b 100644 --- a/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts +++ b/apps/api/v2/src/modules/conferencing/repositories/conferencing.repository.ts @@ -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 }, + }); + } } diff --git a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts index 4df2c00161..5e4f18cd09 100644 --- a/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/conferencing.service.ts @@ -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(", ") + ); + } + } } diff --git a/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts b/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts index 0083c0b27e..41ebf38982 100644 --- a/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/google-meet.service.ts @@ -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; } } diff --git a/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts b/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts index a6edd50d28..73ae3f6a1f 100644 --- a/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/office365-video.service.ts @@ -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) => @@ -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 ?? "" }; } diff --git a/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts index 1058e58736..761876540c 100644 --- a/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts +++ b/apps/api/v2/src/modules/conferencing/services/zoom-video.service.ts @@ -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 ?? "" }; } diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts index 5242944338..eb4fbb827c 100644 --- a/apps/api/v2/src/modules/credentials/credentials.repository.ts +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -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 } }); } diff --git a/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts new file mode 100644 index 0000000000..0f5e6ef419 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/conferencing/organizations-conferencing.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.organizationsConferencingService.disconnectConferencingApp({ + teamId, + user, + app, + }); + + return { status: SUCCESS_STATUS }; + } +} diff --git a/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts b/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts new file mode 100644 index 0000000000..1a24a20124 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/conferencing/services/organizations-conferencing.service.ts @@ -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 { + const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId); + if (!canAccess) { + throw new ForbiddenException("Organization validation failed."); + } + } + + private async ensureAdminAPIEnabled(orgId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts index f3287cab05..e4bb11abdd 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts @@ -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 { diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 86f1ca89cd..1800978502 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -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 {} diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts index e814797464..b87145621f 100644 --- a/apps/api/v2/src/modules/stripe/stripe.service.ts +++ b/apps/api/v2/src/modules/stripe/stripe.service.ts @@ -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); } diff --git a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts index 6cc8149493..1e9a1943bb 100644 --- a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts @@ -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"] = [ { diff --git a/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts b/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts index f6fcac1211..06c8e33758 100644 --- a/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts +++ b/apps/api/v2/src/modules/teams/event-types/teams-event-types.module.ts @@ -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], diff --git a/apps/api/v2/src/modules/teams/teams/teams.repository.ts b/apps/api/v2/src/modules/teams/teams/teams.repository.ts index e131a6c053..f92bbec334 100644 --- a/apps/api/v2/src/modules/teams/teams/teams.repository.ts +++ b/apps/api/v2/src/modules/teams/teams/teams.repository.ts @@ -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 }, + }); + } } diff --git a/apps/web/playwright/fixtures/apps.ts b/apps/web/playwright/fixtures/apps.ts index 955474a4f7..71a50ad513 100644 --- a/apps/web/playwright/fixtures/apps.ts +++ b/apps/web/playwright/fixtures/apps.ts @@ -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}"]`); } diff --git a/packages/app-store/_utils/setDefaultConferencingApp.ts b/packages/app-store/_utils/setDefaultConferencingApp.ts index 555e2949a9..deeac5f2b9 100644 --- a/packages/app-store/_utils/setDefaultConferencingApp.ts +++ b/packages/app-store/_utils/setDefaultConferencingApp.ts @@ -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; diff --git a/packages/features/apps/components/AppList.tsx b/packages/features/apps/components/AppList.tsx index 64e6de43b8..92f00ca0a8 100644 --- a/packages/features/apps/components/AppList.tsx +++ b/packages/features/apps/components/AppList.tsx @@ -99,7 +99,7 @@ export const AppList = ({