From 089a39f59f39f4cd613ab0064dc23117b080c538 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Tue, 17 Mar 2026 15:15:17 +0530 Subject: [PATCH] feat: add integration options for API v2 update booking location endpoint (#26363) * init: improvements for update location endpoint * chore: init function to update calendar event * fix: bad imports * chore: update calendar event when updating location * chore: update platform libraries * fix: update calendar event * chore: update platform libraries * chore: cleanup * feat: add logic for video conferecing integrations * chore: update platform libraries * feat: add sms and email notifications * chore: update e2e tests * chore: update openapi spec * chore: implement cubic feedback * chore: update openapi spec * fix: add Jest mock for Daily.co video adapter in e2e test Co-Authored-By: rajiv@cal.com * fix: mock createMeeting directly to bypass database check in e2e test Co-Authored-By: rajiv@cal.com * chore: implement PR feedback * chore: implement feedback * fix: mock throttler guard to prevent rate limiting in e2e tests Co-Authored-By: rajiv@cal.com * fix: merge conflicts * chore: update platform libraries * chore: implement feedback part 1 * chore: implement feedback part 2 * chore: remove unnecessary type casting * chore: implement cubic feedback * chore: implement devin feedback * chore: implement PR feedback * fix: type error * chore: update openapi spec * test: add mocks and tests for Google Meet and Microsoft Teams integration location updates Co-Authored-By: rajiv@cal.com * refactor: simplify service code - extract shared helpers, remove duplication Co-Authored-By: rajiv@cal.com * feat: implement fixtures for bookings references --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ee/bookings/2024-08-13/bookings.module.ts | 8 + .../booking-location.controller.ts | 43 +- .../e2e/update-booking-location.e2e-spec.ts | 278 +++++++++++++ .../repositories/bookings.repository.ts | 20 +- .../booking-location-calendar-sync.service.ts | 124 ++++++ .../booking-location-credential.service.ts | 53 +++ .../booking-location-integration.service.ts | 382 ++++++++++++++++++ .../services/booking-location.service.ts | 113 +++++- .../services/booking-video.service.ts | 124 ++++++ .../2024-08-13/services/bookings.service.ts | 3 + .../2024-08-13/services/input.service.ts | 3 +- .../booking-reference.repository.fixture.ts | 34 ++ docs/api-reference/v2/openapi.json | 51 ++- packages/platform/libraries/bookings.ts | 8 +- packages/platform/libraries/conferencing.ts | 5 + packages/platform/libraries/index.ts | 29 +- .../inputs/update-location.input.ts | 13 +- packages/prisma/selects/booking.ts | 103 +++++ 18 files changed, 1341 insertions(+), 53 deletions(-) create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service.ts create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-credential.service.ts create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-integration.service.ts create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/services/booking-video.service.ts create mode 100644 apps/api/v2/test/fixtures/repository/booking-reference.repository.fixture.ts diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts index 905668cf1c..90c24560b1 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -8,7 +8,11 @@ import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13 import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service"; import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service"; +import { BookingLocationCalendarSyncService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service"; +import { BookingLocationCredentialService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-credential.service"; +import { BookingLocationIntegrationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-integration.service"; import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service"; +import { BookingVideoService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-video.service"; import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service"; import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service"; @@ -108,7 +112,11 @@ import { UsersModule } from "@/modules/users/users.module"; CalVideoService, CalVideoOutputService, BookingPbacGuard, + BookingLocationCalendarSyncService_2024_08_13, + BookingLocationCredentialService_2024_08_13, + BookingLocationIntegrationService_2024_08_13, BookingLocationService_2024_08_13, + BookingVideoService_2024_08_13, PrismaFeaturesRepository, ], controllers: [ diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-location.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-location.controller.ts index 629402a717..a83bfd26c5 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-location.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-location.controller.ts @@ -1,26 +1,26 @@ -import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; -import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output"; -import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service"; -import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions"; -import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; -import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; -import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; -import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; -import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; -import { Controller, Patch, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common"; -import { ApiExtraModels, ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger"; - import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { - UpdateBookingLocationInput_2024_08_13, - UpdateInputAddressLocation_2024_08_13, UpdateBookingInputAttendeeAddressLocation_2024_08_13, UpdateBookingInputAttendeeDefinedLocation_2024_08_13, UpdateBookingInputAttendeePhoneLocation_2024_08_13, UpdateBookingInputLinkLocation_2024_08_13, UpdateBookingInputPhoneLocation_2024_08_13, + UpdateBookingLocationInput_2024_08_13, + UpdateInputAddressLocation_2024_08_13, } from "@calcom/platform-types"; +import { Body, Controller, HttpCode, HttpStatus, Param, Patch, UseGuards } from "@nestjs/common"; +import { ApiExtraModels, ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; +import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output"; +import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service"; +import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions"; +import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; +import { Throttle } from "@/lib/endpoint-throttler-decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; @Controller({ path: "/v2/bookings/:bookingUid/location", @@ -43,20 +43,23 @@ import { required: true, }) export class BookingLocationController_2024_08_13 { - private readonly logger = new Logger("BookingLocationController_2024_08_13"); - constructor(private readonly bookingLocationService: BookingLocationService_2024_08_13) {} @Patch("/") @HttpCode(HttpStatus.OK) + @Throttle({ name: "booking_location_update", limit: 5, ttl: 60000, blockDuration: 60000 }) @Permissions([BOOKING_WRITE]) @UseGuards(ApiAuthGuard, BookingUidGuard) @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) @ApiOperation({ summary: "Update booking location for an existing booking", - description: `**Current Limitation:** Updating a booking location will update the location in Cal.com, but the corresponding Calendar event will not be updated automatically. The old location will persist in the Calendar event. This is a known limitation that will be addressed in a future update. - - The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.`, + description: `Updates the location for an existing booking. + +**Integration Fallback Behavior:** +- **Google Meet**: Requires Google Calendar to be connected. If the booking has no Google Calendar event, it will automatically fall back to Cal Video. +- **MS Teams**: If Office 365 Calendar is connected, generates the meeting link via calendar. Otherwise, uses the MS Teams video integration directly. + +The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.`, }) async updateBookingLocation( @Param("bookingUid") bookingUid: string, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/update-booking-location.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/update-booking-location.e2e-spec.ts index d7526a5dd3..5c6ce6a894 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/update-booking-location.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/update-booking-location.e2e-spec.ts @@ -12,13 +12,70 @@ import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import request from "supertest"; + +const MOCK_GOOGLE_MEET_URL = "https://meet.google.com/mock-meet-id"; +const MOCK_MS_TEAMS_URL = "https://teams.microsoft.com/l/meetup-join/mock-teams-id"; + +jest.mock("@calcom/platform-libraries/conferencing", () => ({ + ...jest.requireActual("@calcom/platform-libraries/conferencing"), + createMeeting: jest.fn().mockResolvedValue({ + appName: "daily-video", + type: "daily_video", + uid: "MOCK_UID", + originalEvent: {}, + success: true, + createdEvent: { + type: "daily_video", + id: "MOCK_DAILY_ID", + password: "MOCK_DAILY_PASS", + url: "https://mock-daily.example.com/mock-meeting", + }, + credentialId: 0, + }), +})); + +jest.mock("@calcom/platform-libraries", () => { + const actual = jest.requireActual("@calcom/platform-libraries"); + return { + ...actual, + updateEvent: jest.fn().mockImplementation((_credential, evt) => { + const isGoogleMeet = evt.conferenceData?.createRequest; + return Promise.resolve({ + uid: "MOCK_CALENDAR_UID", + updatedEvent: { + id: "MOCK_UPDATED_EVENT_ID", + hangoutLink: isGoogleMeet ? MOCK_GOOGLE_MEET_URL : undefined, + url: isGoogleMeet ? undefined : MOCK_MS_TEAMS_URL, + }, + }); + }), + CredentialRepository: { + ...actual.CredentialRepository, + findCredentialForCalendarServiceById: jest.fn().mockResolvedValue({ + id: 99999, + type: "google_calendar", + userId: 1, + teamId: null, + key: {}, + appId: "google-calendar", + invalid: false, + delegationCredentialId: null, + }), + }, + sendLocationChangeEmailsAndSMS: jest.fn().mockResolvedValue(undefined), + }; +}); + +import { BookingReferenceRepositoryFixture } from "test/fixtures/repository/booking-reference.repository.fixture"; import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; +import { mockThrottlerGuard } from "test/utils/withNoThrottler"; import { AppModule } from "@/app.module"; import { bootstrap } from "@/bootstrap"; import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output"; @@ -27,6 +84,7 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/ import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { UsersModule } from "@/modules/users/users.module"; type TestUser = { @@ -48,15 +106,19 @@ describe("Bookings Endpoints 2024-08-13 update booking location", () => { let userRepositoryFixture: UserRepositoryFixture; let bookingsRepositoryFixture: BookingsRepositoryFixture; + let bookingReferenceRepositoryFixture: BookingReferenceRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; let schedulesService: SchedulesService_2024_04_15; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; let teamRepositoryFixture: TeamRepositoryFixture; let tokensRepositoryFixture: TokensRepositoryFixture; + let prismaWrite: PrismaWriteService; let testSetup: TestSetup; beforeAll(async () => { + mockThrottlerGuard(); const moduleRef = await Test.createTestingModule({ imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], }) @@ -68,11 +130,14 @@ describe("Bookings Endpoints 2024-08-13 update booking location", () => { userRepositoryFixture = new UserRepositoryFixture(moduleRef); bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + bookingReferenceRepositoryFixture = new BookingReferenceRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + prismaWrite = moduleRef.get(PrismaWriteService); organization = await teamRepositoryFixture.create({ name: `update-booking-location-organization-${randomString()}`, @@ -322,6 +387,219 @@ describe("Bookings Endpoints 2024-08-13 update booking location", () => { expect(updatedBooking.location).toEqual(attendeeDefinedLocation); }); + it("can update location to type integration (cal-video)", async () => { + const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = { + location: { + type: "integration", + integration: "cal-video", + }, + }; + + const updatedBookingResponse = await request(app.getHttpServer()) + .patch(`/v2/bookings/${bookingUid}/location`) + .send(updatedBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 = + updatedBookingResponse.body; + expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS); + if (!responseDataIsBooking(updatedBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13; + expect(updatedBooking).toHaveProperty("id"); + // cal-video location should be either a Daily video URL or the internal location string + expect( + updatedBooking.location?.startsWith("http") || updatedBooking.location === "integrations:daily" + ).toBe(true); + }); + + it("can update location to type integration (google-meet)", async () => { + const googleCredential = await credentialsRepositoryFixture.create( + "google_calendar", + { access_token: "mock", refresh_token: "mock", expiry_date: Date.now() + 3600000 }, + testSetup.organizer.id, + "google-calendar" + ); + + const googleBooking = await bookingsRepositoryFixture.create({ + uid: `google-meet-booking-${randomString(10)}`, + title: "google meet booking", + startTime: "2048-09-14T09:00:00.000Z", + endTime: "2048-09-14T10:00:00.000Z", + eventType: { connect: { id: eventTypeWithAllLocationsId } }, + status: "ACCEPTED", + metadata: {}, + responses: "null", + user: { connect: { id: testSetup.organizer.id } }, + }); + + await bookingReferenceRepositoryFixture.create({ + type: "google_calendar", + uid: "mock-google-calendar-event-uid", + booking: { connect: { id: googleBooking.id } }, + credential: { connect: { id: googleCredential.id } }, + externalCalendarId: "primary", + }); + + const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = { + location: { + type: "integration", + integration: "google-meet", + }, + }; + + const updatedBookingResponse = await request(app.getHttpServer()) + .patch(`/v2/bookings/${googleBooking.uid}/location`) + .send(updatedBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 = + updatedBookingResponse.body; + expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS); + if (!responseDataIsBooking(updatedBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13; + expect(updatedBooking).toHaveProperty("id"); + expect(updatedBooking.location).toEqual(MOCK_GOOGLE_MEET_URL); + + await bookingReferenceRepositoryFixture.deleteByBookingId(googleBooking.id); + await bookingsRepositoryFixture.deleteById(googleBooking.id); + await credentialsRepositoryFixture.delete(googleCredential.id); + }); + + it("should fall back to Cal Video when google-meet is requested but no Google Calendar credential exists", async () => { + const bookingWithoutGoogleCal = await bookingsRepositoryFixture.create({ + uid: `no-gcal-booking-${randomString(10)}`, + title: "no google cal booking", + startTime: "2048-12-14T09:00:00.000Z", + endTime: "2048-12-14T10:00:00.000Z", + eventType: { connect: { id: eventTypeWithAllLocationsId } }, + status: "ACCEPTED", + metadata: {}, + responses: "null", + user: { connect: { id: testSetup.organizer.id } }, + }); + + const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = { + location: { + type: "integration", + integration: "google-meet", + }, + }; + + const updatedBookingResponse = await request(app.getHttpServer()) + .patch(`/v2/bookings/${bookingWithoutGoogleCal.uid}/location`) + .send(updatedBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 = + updatedBookingResponse.body; + expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS); + if (!responseDataIsBooking(updatedBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13; + expect(updatedBooking).toHaveProperty("id"); + // No Google Calendar credential → fell back to Cal Video, not Google Meet + expect(updatedBooking.location).not.toContain("meet.google.com"); + expect(["integrations:daily", "https://mock-daily.example.com/mock-meeting"]).toContain( + updatedBooking.location + ); + + await bookingsRepositoryFixture.deleteById(bookingWithoutGoogleCal.id); + }); + + it("can update location to type integration (office365-video)", async () => { + const office365Credential = await credentialsRepositoryFixture.create( + "office365_calendar", + { access_token: "mock", refresh_token: "mock", expiry_date: Date.now() + 3600000 }, + testSetup.organizer.id, + "office365-calendar" + ); + + const msTeamsBooking = await bookingsRepositoryFixture.create({ + uid: `ms-teams-booking-${randomString(10)}`, + title: "ms teams booking", + startTime: "2048-10-14T09:00:00.000Z", + endTime: "2048-10-14T10:00:00.000Z", + eventType: { connect: { id: eventTypeWithAllLocationsId } }, + status: "ACCEPTED", + metadata: {}, + responses: "null", + user: { connect: { id: testSetup.organizer.id } }, + }); + + await bookingReferenceRepositoryFixture.create({ + type: "office365_calendar", + uid: "mock-office365-calendar-event-uid", + booking: { connect: { id: msTeamsBooking.id } }, + credential: { connect: { id: office365Credential.id } }, + externalCalendarId: "primary", + }); + + const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = { + location: { + type: "integration", + integration: "office365-video", + }, + }; + + const updatedBookingResponse = await request(app.getHttpServer()) + .patch(`/v2/bookings/${msTeamsBooking.uid}/location`) + .send(updatedBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 = + updatedBookingResponse.body; + expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS); + if (!responseDataIsBooking(updatedBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13; + expect(updatedBooking).toHaveProperty("id"); + expect(updatedBooking.location).toEqual(MOCK_MS_TEAMS_URL); + + await bookingReferenceRepositoryFixture.deleteByBookingId(msTeamsBooking.id); + await bookingsRepositoryFixture.deleteById(msTeamsBooking.id); + await credentialsRepositoryFixture.delete(office365Credential.id); + }); + + it("should return 400 when updating to unsupported integration", async () => { + const updatedBookingBody = { + location: { + type: "integration", + integration: "unsupported-video-integration", + }, + }; + + const response = await request(app.getHttpServer()) + .patch(`/v2/bookings/${bookingUid}/location`) + .send(updatedBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(400); + + expect(response.body.status).toEqual(ERROR_STATUS); + }); + afterAll(async () => { if (responseDataIsBooking(booking)) { await bookingsRepositoryFixture.deleteById(bookingId); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts index 88b6f5f3a2..ed468a40f7 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts @@ -1,8 +1,8 @@ +import { bookingWithUserAndEventDetailsSelect } from "@calcom/platform-libraries/bookings"; +import type { Prisma } from "@calcom/prisma/client"; +import { Injectable } from "@nestjs/common"; 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 "@calcom/prisma/client"; @Injectable() export class BookingsRepository_2024_08_13 { @@ -185,6 +185,20 @@ export class BookingsRepository_2024_08_13 { }); } + async getBookingByUidWithUserAndEventDetails(uid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { uid }, + select: bookingWithUserAndEventDetailsSelect, + }); + } + + async getBookingByIdWithUserAndEventDetails(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { id }, + select: bookingWithUserAndEventDetailsSelect, + }); + } + async getRecurringByUid(uid: string) { return this.dbRead.prisma.booking.findMany({ where: { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service.ts new file mode 100644 index 0000000000..bff22d8dbc --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service.ts @@ -0,0 +1,124 @@ +import type { BookingWithUserAndEventDetails, CalendarEvent } from "@calcom/platform-libraries"; +import { buildCalEventFromBooking, sendLocationChangeEmailsAndSMS, updateEvent } from "@calcom/platform-libraries"; +import { Injectable, Logger } from "@nestjs/common"; +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; +import { BookingLocationCredentialService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-credential.service"; + +@Injectable() +export class BookingLocationCalendarSyncService_2024_08_13 { + private readonly logger = new Logger("BookingLocationCalendarSyncService_2024_08_13"); + + constructor( + private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly credentialService: BookingLocationCredentialService_2024_08_13 + ) {} + + async buildCalEventFromBookingData( + booking: BookingWithUserAndEventDetails, + location: string, + conferenceCredentialId: number | null + ): Promise { + return buildCalEventFromBooking({ + booking: { + title: booking.title, + description: booking.description, + startTime: booking.startTime, + endTime: booking.endTime, + userPrimaryEmail: booking.userPrimaryEmail, + uid: booking.uid, + destinationCalendar: booking.destinationCalendar, + user: booking.user + ? { + destinationCalendar: booking.user.destinationCalendar, + } + : null, + attendees: booking.attendees.map((attendee: BookingWithUserAndEventDetails["attendees"][number]) => ({ + email: attendee.email, + name: attendee.name, + timeZone: attendee.timeZone, + locale: attendee.locale, + })), + eventType: booking.eventType + ? { + title: booking.eventType.title, + recurringEvent: booking.eventType.recurringEvent, + seatsPerTimeSlot: booking.eventType.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType.seatsShowAttendees, + hideOrganizerEmail: booking.eventType.hideOrganizerEmail, + customReplyToEmail: booking.eventType.customReplyToEmail, + } + : null, + iCalUID: booking.iCalUID, + iCalSequence: booking.iCalSequence, + }, + organizer: { + email: booking.user?.email || "", + name: booking.user?.name ?? null, + timeZone: booking.user?.timeZone || "UTC", + locale: booking.user?.locale ?? null, + }, + location, + conferenceCredentialId, + organizationId: booking.user?.profiles?.[0]?.organizationId ?? null, + }); + } + + async sendLocationChangeNotifications( + evt: CalendarEvent, + bookingUid: string, + newLocation: string, + eventTypeMetadata?: Record + ): Promise { + try { + await sendLocationChangeEmailsAndSMS({ ...evt, location: newLocation }, eventTypeMetadata); + } catch (error) { + this.logger.error( + `Failed to send location change emails for booking uid=${bookingUid}`, + error instanceof Error ? error.message : String(error) + ); + } + } + + async syncCalendarEvent(bookingId: number, newLocation: string): Promise { + const booking = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(bookingId); + + if (!booking || !booking.user) { + this.logger.log(`syncCalendarEvent - No booking or user found for id=${bookingId}`); + return; + } + + const calendarReferences = booking.references.filter( + (ref) => ref.type.includes("_calendar") && !ref.deleted + ); + + if (calendarReferences.length === 0) { + this.logger.log(`syncCalendarEvent - No calendar references for booking id=${bookingId}`); + return; + } + + const evt = await this.buildCalEventFromBookingData(booking, newLocation, null); + + for (const reference of calendarReferences) { + const credential = await this.credentialService.getCredentialForReference(reference, booking.user.credentials); + + if (!credential) { + this.logger.warn( + `syncCalendarEvent - No credential found for reference id=${reference.id}, credentialId=${reference.credentialId}` + ); + continue; + } + + try { + await updateEvent(credential, evt, reference.uid, reference.externalCalendarId); + this.logger.log( + `syncCalendarEvent - Successfully updated calendar event for reference id=${reference.id}` + ); + } catch (error) { + this.logger.error( + `syncCalendarEvent - Failed to update calendar for reference id=${reference.id}`, + error instanceof Error ? error.message : String(error) + ); + } + } + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-credential.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-credential.service.ts new file mode 100644 index 0000000000..cd504bada4 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-credential.service.ts @@ -0,0 +1,53 @@ +import type { CredentialForCalendarService } from "@calcom/platform-libraries"; +import { CredentialRepository } from "@calcom/platform-libraries"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class BookingLocationCredentialService_2024_08_13 { + async getCredentialForReference( + reference: { + credentialId: number | null; + delegationCredentialId: string | null; + type: string; + }, + userCredentials: Array<{ + id: number; + delegationCredentialId: string | null; + type: string; + }> + ): Promise { + const credentialId = this.resolveCredentialId(reference, userCredentials); + if (!credentialId) { + return null; + } + return CredentialRepository.findCredentialForCalendarServiceById({ id: credentialId }); + } + + private resolveCredentialId( + reference: { + credentialId: number | null; + delegationCredentialId: string | null; + type: string; + }, + userCredentials: Array<{ + id: number; + delegationCredentialId: string | null; + type: string; + }> + ): number | null { + if (reference.delegationCredentialId) { + const delegationCred = userCredentials.find( + (cred) => cred.delegationCredentialId === reference.delegationCredentialId + ); + if (delegationCred) return delegationCred.id; + } + + if (reference.credentialId && reference.credentialId > 0) { + const localCred = userCredentials.find((cred) => cred.id === reference.credentialId); + return localCred?.id ?? reference.credentialId; + } + + const typeCred = userCredentials.find((cred) => cred.type === reference.type); + return typeCred?.id ?? null; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-integration.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-integration.service.ts new file mode 100644 index 0000000000..9aca9d68de --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location-integration.service.ts @@ -0,0 +1,382 @@ +import type { BookingWithUserAndEventDetails, CalendarEvent } from "@calcom/platform-libraries"; +import { BookingReferenceRepository, updateEvent } from "@calcom/platform-libraries"; +import { makeUserActor } from "@calcom/platform-libraries/bookings"; +import { createMeeting, FAKE_DAILY_CREDENTIAL } from "@calcom/platform-libraries/conferencing"; +import type { Integration_2024_08_13 } from "@calcom/platform-types"; +import type { + BookingOutput_2024_08_13, + GetRecurringSeatedBookingOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types/bookings/2024-08-13/outputs/booking.output"; +import type { Booking, Prisma } from "@calcom/prisma/client"; +import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; +import { BookingLocationCalendarSyncService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service"; +import { BookingLocationCredentialService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-credential.service"; +import { BookingVideoService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-video.service"; +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service"; +import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/locations"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; +import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; + +export type BookingLocationResponse = + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13[] + | GetSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13 + | GetRecurringSeatedBookingOutput_2024_08_13[]; + +export type BookingForLocationUpdate = Pick< + Booking, + "id" | "uid" | "userId" | "eventTypeId" | "location" | "responses" | "metadata" +>; + +type BookingWithRequiredUser = Omit & { + user: NonNullable; +}; + +function bookingHasUser(booking: BookingWithUserAndEventDetails | null): booking is BookingWithRequiredUser { + return booking !== null && booking.user !== null; +} + +export type IntegrationHandlerContext = { + existingBooking: BookingForLocationUpdate; + booking: BookingWithRequiredUser; + integrationSlug: string; + internalLocation: string; + user: ApiAuthGuardUser; + existingBookingHost: { organizationId: number | null } | null; + inputLocation: { type: "integration"; integration: Integration_2024_08_13 }; +}; + +@Injectable() +export class BookingLocationIntegrationService_2024_08_13 { + private readonly logger = new Logger("BookingLocationIntegrationService_2024_08_13"); + + constructor( + private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly bookingsService: BookingsService_2024_08_13, + private readonly inputService: InputBookingsService_2024_08_13, + private readonly bookingVideoService: BookingVideoService_2024_08_13, + private readonly bookingEventHandlerService: BookingEventHandlerService, + private readonly featuresRepository: PrismaFeaturesRepository, + private readonly calendarSyncService: BookingLocationCalendarSyncService_2024_08_13, + private readonly credentialService: BookingLocationCredentialService_2024_08_13 + ) {} + + async handleIntegrationLocationUpdate( + existingBooking: BookingForLocationUpdate, + inputLocation: { type: "integration"; integration: Integration_2024_08_13 }, + user: ApiAuthGuardUser, + existingBookingHost: { organizationId: number | null } | null + ): Promise { + if (!existingBookingHost) { + throw new NotFoundException(`No user found for booking with uid=${existingBooking.uid}`); + } + + const integrationSlug = inputLocation.integration; + const internalLocation = + apiToInternalintegrationsMapping[integrationSlug as keyof typeof apiToInternalintegrationsMapping]; + + if (!internalLocation) { + throw new BadRequestException(`Unsupported integration: ${integrationSlug}`); + } + + const booking = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(existingBooking.id); + if (!bookingHasUser(booking)) { + throw new NotFoundException(`Could not load booking details for uid=${existingBooking.uid}`); + } + + const ctx: IntegrationHandlerContext = { + existingBooking, + booking, + integrationSlug, + internalLocation, + user, + existingBookingHost, + inputLocation, + }; + + switch (integrationSlug) { + case "google-meet": + return this.handleGoogleMeetLocation(ctx); + case "office365-video": + return this.handleMSTeamsLocation(ctx); + case "cal-video": + return this.handleCalVideoLocation(ctx); + default: + // all other integrations (Zoom, Webex, etc.) use VideoApiAdapter + return this.handleVideoApiIntegration(ctx); + } + } + + private async handleGoogleMeetLocation(ctx: IntegrationHandlerContext): Promise { + const hasGoogleCalendar = ctx.booking.references.some( + (ref) => ref.type.includes("google_calendar") && !ref.deleted + ); + + if (!hasGoogleCalendar) { + this.logger.log(`Google Meet requested but no Google Calendar found. Falling back to Cal Video.`); + return this.handleCalVideoLocation({ + ...ctx, + integrationSlug: "cal-video", + internalLocation: "integrations:daily", + }); + } + + return this.handleCalendarBasedIntegration(ctx, "google_calendar"); + } + + private async handleMSTeamsLocation(ctx: IntegrationHandlerContext): Promise { + const hasOffice365Calendar = ctx.booking.references.some( + (ref) => ref.type.includes("office365_calendar") && !ref.deleted + ); + + if (hasOffice365Calendar) { + return this.handleCalendarBasedIntegration(ctx, "office365_calendar"); + } + + return this.handleVideoApiIntegration(ctx); + } + + private async handleCalVideoLocation(ctx: IntegrationHandlerContext): Promise { + const credential = { ...FAKE_DAILY_CREDENTIAL }; + + await this.bookingVideoService.deleteOldVideoMeetingIfNeeded(ctx.existingBooking.id); + + const evt = await this.calendarSyncService.buildCalEventFromBookingData( + ctx.booking, + ctx.internalLocation, + credential.id + ); + const meetingResult = await createMeeting(credential, evt); + + if (!meetingResult.createdEvent) { + this.logger.error( + `Failed to create Cal Video meeting`, + JSON.stringify({ success: meetingResult.success, type: meetingResult.type }) + ); + throw new BadRequestException( + `Failed to create Cal Video meeting. Please ensure DAILY_API_KEY is set and the daily-video app is enabled.` + ); + } + + const createdEvent = meetingResult.createdEvent; + const videoCallUrl = createdEvent.url; + const bookingLocation = videoCallUrl || ctx.internalLocation; + + // FAKE_DAILY_CREDENTIAL has id: 0, so we don't include credentialId + const newReference = { + type: credential.type, + uid: createdEvent.id?.toString() || "", + meetingId: createdEvent.id?.toString(), + meetingPassword: createdEvent.password, + meetingUrl: createdEvent.url, + }; + + await BookingReferenceRepository.replaceBookingReferences({ + bookingId: ctx.existingBooking.id, + newReferencesToCreate: [newReference], + }); + + if (videoCallUrl) { + await this.calendarSyncService.syncCalendarEvent(ctx.existingBooking.id, bookingLocation); + } + + return this.updateBookingWithVideoLocation(ctx, videoCallUrl, bookingLocation, evt); + } + + private async handleVideoApiIntegration(ctx: IntegrationHandlerContext): Promise { + const credential = await this.bookingVideoService.findVideoCredentialForIntegration( + ctx.integrationSlug, + ctx.booking.user?.credentials || [] + ); + + if (!credential) { + throw new BadRequestException( + `Video integration "${ctx.integrationSlug}" is not connected. Please connect the integration in your settings first.` + ); + } + + await this.bookingVideoService.deleteOldVideoMeetingIfNeeded(ctx.existingBooking.id); + + const evt = await this.calendarSyncService.buildCalEventFromBookingData( + ctx.booking, + ctx.internalLocation, + credential.id + ); + const meetingResult = await createMeeting(credential, evt); + + if (!meetingResult.createdEvent) { + this.logger.error( + `Failed to create video meeting with ${ctx.integrationSlug}`, + JSON.stringify({ success: meetingResult.success, type: meetingResult.type }) + ); + throw new BadRequestException( + `Failed to create video meeting with ${ctx.integrationSlug}. Please ensure the integration is properly configured.` + ); + } + + const createdEvent = meetingResult.createdEvent; + const videoCallUrl = createdEvent.url; + const bookingLocation = videoCallUrl || ctx.internalLocation; + + const newReference = { + type: credential.type, + uid: createdEvent.id?.toString() || "", + meetingId: createdEvent.id?.toString(), + meetingPassword: createdEvent.password, + meetingUrl: createdEvent.url, + // only include credentialId if it's a valid ID (not 0) + ...(credential.id > 0 ? { credentialId: credential.id } : {}), + }; + + await BookingReferenceRepository.replaceBookingReferences({ + bookingId: ctx.existingBooking.id, + newReferencesToCreate: [newReference], + }); + + if (videoCallUrl) { + await this.calendarSyncService.syncCalendarEvent(ctx.existingBooking.id, bookingLocation); + } + + return this.updateBookingWithVideoLocation(ctx, videoCallUrl, bookingLocation, evt); + } + + private async handleCalendarBasedIntegration( + ctx: IntegrationHandlerContext, + requiredCalendarType: string + ): Promise { + const calendarReference = ctx.booking.references.find( + (ref) => ref.type.includes(requiredCalendarType) && !ref.deleted + ); + + if (!calendarReference) { + throw new BadRequestException( + `No ${requiredCalendarType.replace("_", " ")} event found for this booking. ${ctx.integrationSlug} requires a ${requiredCalendarType.replace("_", " ")} event to generate the meeting link.` + ); + } + + const calendarCredential = await this.credentialService.getCredentialForReference( + calendarReference, + ctx.booking.user?.credentials || [] + ); + + if (!calendarCredential) { + throw new BadRequestException( + `Could not find calendar credentials for ${ctx.integrationSlug}. Please reconnect your calendar.` + ); + } + + const evt = await this.calendarSyncService.buildCalEventFromBookingData( + ctx.booking, + ctx.internalLocation, + null + ); + + if (ctx.integrationSlug === "google-meet") { + evt.conferenceData = { + createRequest: { + requestId: `${ctx.booking.uid}-meet`, + }, + }; + } + + const updateResult = await updateEvent( + calendarCredential, + evt, + calendarReference.uid, + calendarReference.externalCalendarId + ); + + let meetingUrl: string | undefined; + if (updateResult.updatedEvent) { + const updatedEvent = Array.isArray(updateResult.updatedEvent) + ? updateResult.updatedEvent[0] + : updateResult.updatedEvent; + meetingUrl = updatedEvent?.hangoutLink || updatedEvent?.url; + } + + const bookingLocation = meetingUrl || ctx.internalLocation; + + const updatedMetadata = this.buildUpdatedMetadata(ctx.existingBooking.metadata, meetingUrl); + + const updatedBooking = await this.bookingsRepository.updateBooking(ctx.existingBooking.uid, { + location: bookingLocation, + metadata: updatedMetadata as Prisma.InputJsonValue, + }); + + await this.emitLocationChangeEvents(ctx, bookingLocation, evt); + + return this.bookingsService.getBooking(updatedBooking.uid, ctx.user); + } + + private async updateBookingWithVideoLocation( + ctx: IntegrationHandlerContext, + videoCallUrl: string | undefined, + bookingLocation: string, + evt: CalendarEvent + ): Promise { + const updatedMetadata = this.buildUpdatedMetadata(ctx.existingBooking.metadata, videoCallUrl); + + const bookingFieldsLocation = this.inputService.transformLocation(ctx.inputLocation); + const responses = (ctx.existingBooking.responses || {}) as Record; + const { location: _existingLocation, ...rest } = responses; + + const updatedBooking = await this.bookingsRepository.updateBooking(ctx.existingBooking.uid, { + location: bookingLocation, + responses: { ...rest, location: bookingFieldsLocation }, + metadata: updatedMetadata as Prisma.InputJsonValue, + }); + + await this.emitLocationChangeEvents(ctx, bookingLocation, evt); + + return this.bookingsService.getBooking(updatedBooking.uid, ctx.user); + } + + private buildUpdatedMetadata( + existingMetadata: unknown, + videoCallUrl: string | undefined + ): Record { + return { + ...((existingMetadata || {}) as Record), + videoCallUrl, + }; + } + + private async emitLocationChangeEvents( + ctx: IntegrationHandlerContext, + bookingLocation: string, + evt: CalendarEvent + ): Promise { + const organizationId = ctx.existingBookingHost?.organizationId ?? null; + const isBookingAuditEnabled = organizationId + ? await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit") + : false; + + await this.bookingEventHandlerService.onLocationChanged({ + bookingUid: ctx.existingBooking.uid, + actor: makeUserActor(ctx.user.uuid), + organizationId, + source: "API_V2", + auditData: { + location: { + old: ctx.existingBooking.location, + new: bookingLocation, + }, + }, + isBookingAuditEnabled, + }); + + await this.calendarSyncService.sendLocationChangeNotifications( + evt, + ctx.existingBooking.uid, + bookingLocation, + ctx.booking.eventType?.metadata as Record | undefined + ); + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts index a9eff5ebb8..72cbd482e0 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts @@ -1,18 +1,30 @@ import { makeUserActor } from "@calcom/platform-libraries/bookings"; import type { - BookingInputLocation_2024_08_13, UpdateBookingInputLocation_2024_08_13, UpdateBookingLocationInput_2024_08_13, } from "@calcom/platform-types"; -import { Booking } from "@calcom/prisma/client"; -import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import type { Prisma } from "@calcom/prisma/client"; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; +import { BookingLocationCalendarSyncService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service"; +import { + type BookingForLocationUpdate, + BookingLocationIntegrationService_2024_08_13, + type BookingLocationResponse, +} from "@/ee/bookings/2024-08-13/services/booking-location-integration.service"; +import { BookingVideoService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-video.service"; import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service"; import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; -import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service"; import { UsersRepository } from "@/modules/users/users.repository"; @@ -28,15 +40,18 @@ export class BookingLocationService_2024_08_13 { private readonly eventTypesRepository: EventTypesRepository_2024_06_14, private readonly eventTypeAccessService: EventTypeAccessService, private readonly bookingEventHandlerService: BookingEventHandlerService, - private readonly featuresRepository: PrismaFeaturesRepository + private readonly bookingVideoService: BookingVideoService_2024_08_13, + private readonly featuresRepository: PrismaFeaturesRepository, + private readonly integrationService: BookingLocationIntegrationService_2024_08_13, + private readonly calendarSyncService: BookingLocationCalendarSyncService_2024_08_13 ) {} async updateBookingLocation( bookingUid: string, input: UpdateBookingLocationInput_2024_08_13, user: ApiAuthGuardUser - ) { - const existingBooking = await this.bookingsRepository.getByUidWithEventType(bookingUid); + ): Promise { + const existingBooking = await this.bookingsRepository.getBookingByUidWithUserAndEventDetails(bookingUid); if (!existingBooking) { throw new NotFoundException(`Booking with uid=${bookingUid} not found`); } @@ -58,6 +73,12 @@ export class BookingLocationService_2024_08_13 { const { location } = input; if (location) { + if (location.type !== "integration") { + const locationValue = this.getNonIntegrationLocationValue(location); + if (locationValue) { + await this.calendarSyncService.syncCalendarEvent(existingBooking.id, locationValue); + } + } return await this.updateLocation(existingBooking, location, user); } @@ -65,13 +86,12 @@ export class BookingLocationService_2024_08_13 { } private async updateLocation( - existingBooking: Booking, + existingBooking: BookingForLocationUpdate, inputLocation: UpdateBookingInputLocation_2024_08_13, user: ApiAuthGuardUser - ) { + ): Promise { const bookingUid = existingBooking.uid; const oldLocation = existingBooking.location; - const bookingLocation = this.getLocationValue(inputLocation) ?? existingBooking.location; if (!existingBooking.userId) { throw new NotFoundException(`No user found for booking with uid=${bookingUid}`); @@ -87,18 +107,38 @@ export class BookingLocationService_2024_08_13 { throw new NotFoundException(`No user found for booking with uid=${bookingUid}`); } - const bookingFieldsLocation = this.inputService.transformLocation( - inputLocation as BookingInputLocation_2024_08_13 - ); + if (inputLocation.type === "integration") { + return this.integrationService.handleIntegrationLocationUpdate( + existingBooking, + inputLocation, + user, + existingBookingHost + ); + } + + const bookingLocation = this.getNonIntegrationLocationValue(inputLocation); + if (!bookingLocation) { + throw new BadRequestException(`Missing or invalid location value for type: ${inputLocation.type}`); + } + + const bookingFieldsLocation = this.inputService.transformLocation(inputLocation); const responses = (existingBooking.responses || {}) as Record; const { location: _existingLocation, ...rest } = responses; - const updatedBookingResponses = { ...rest, location: bookingFieldsLocation }; + const updatedBookingResponses = { + ...rest, + location: bookingFieldsLocation, + }; + + const metadataWithoutVideoUrl = this.getMetadataWithoutVideoCallUrl(existingBooking.metadata); + + await this.bookingVideoService.deleteOldVideoMeetingIfNeeded(existingBooking.id); const updatedBooking = await this.bookingsRepository.updateBooking(bookingUid, { location: bookingLocation, responses: updatedBookingResponses, + metadata: metadataWithoutVideoUrl as Prisma.InputJsonValue, }); const organizationId = existingBookingHost.organizationId ?? null; @@ -120,10 +160,41 @@ export class BookingLocationService_2024_08_13 { isBookingAuditEnabled, }); + if (bookingLocation) { + await this.sendLocationChangeNotifications(existingBooking.id, existingBooking.uid, bookingLocation); + } + return this.bookingsService.getBooking(updatedBooking.uid, user); } - private getLocationValue(loc: UpdateBookingInputLocation_2024_08_13): string | undefined { + private async sendLocationChangeNotifications( + bookingId: number, + bookingUid: string, + bookingLocation: string + ): Promise { + const bookingWithDetails = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(bookingId); + + if (!bookingWithDetails || !bookingWithDetails.user) { + this.logger.warn( + `Unable to send location change notifications: ${!bookingWithDetails ? "booking details" : "user"} not found for bookingId=${bookingId}` + ); + return; + } + + const evt = await this.calendarSyncService.buildCalEventFromBookingData( + bookingWithDetails, + bookingLocation, + null + ); + await this.calendarSyncService.sendLocationChangeNotifications( + evt, + bookingUid, + bookingLocation, + bookingWithDetails.eventType?.metadata as Record | undefined + ); + } + + private getNonIntegrationLocationValue(loc: UpdateBookingInputLocation_2024_08_13): string | undefined { if (loc.type === "address") return loc.address; if (loc.type === "link") return loc.link; if (loc.type === "phone") return loc.phone; @@ -132,11 +203,17 @@ export class BookingLocationService_2024_08_13 { if (loc.type === "attendeeDefined") return loc.location; this.logger.log( - `Booking location service getLocationValue - loc ${JSON.stringify( - loc - )} was passed but the type is not supported.` + `Booking location service getNonIntegrationLocationValue - unsupported type: ${loc.type}` ); return undefined; } + + // in case of video integrations we need to clear the videoCallUrl from metadata when explicitly setting a new non-integration location + // this ensures the frontend shows the new location instead of the old integration URL + private getMetadataWithoutVideoCallUrl(metadata: unknown): Record { + const existingMetadata = (metadata || {}) as Record; + const { videoCallUrl: _removedVideoUrl, ...metadataWithoutVideoUrl } = existingMetadata; + return metadataWithoutVideoUrl; + } } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-video.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-video.service.ts new file mode 100644 index 0000000000..960511c37e --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-video.service.ts @@ -0,0 +1,124 @@ +import type { CredentialForCalendarService } from "@calcom/platform-libraries"; +import { CredentialRepository } from "@calcom/platform-libraries"; +import { deleteMeeting, FAKE_DAILY_CREDENTIAL } from "@calcom/platform-libraries/conferencing"; +import { Injectable, Logger } from "@nestjs/common"; +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; + +@Injectable() +export class BookingVideoService_2024_08_13 { + private readonly logger = new Logger("BookingVideoService_2024_08_13"); + + constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {} + + async deleteOldVideoMeetingIfNeeded(bookingId: number): Promise { + const booking = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(bookingId); + if (!booking || !booking.user) { + return; + } + + const videoReferences = booking.references.filter( + (ref) => (ref.type.endsWith("_video") || ref.type.endsWith("_conferencing")) && !ref.deleted && ref.uid + ); + + for (const reference of videoReferences) { + const credential = await this.findCredentialForVideoReference(reference, booking.user.credentials); + + if (credential && reference.uid) { + try { + await deleteMeeting(credential, reference.uid); + this.logger.log( + `deleteOldVideoMeetingIfNeeded - Deleted video meeting for reference id=${reference.id}, type=${reference.type}` + ); + } catch (error) { + this.logger.warn( + `deleteOldVideoMeetingIfNeeded - Failed to delete video meeting for reference id=${reference.id}`, + error instanceof Error ? error.message : String(error) + ); + } + } + } + } + + async findCredentialForVideoReference( + reference: { + credentialId: number | null; + delegationCredentialId: string | null; + type: string; + }, + userCredentials: Array<{ + id: number; + delegationCredentialId: string | null; + type: string; + }> + ): Promise { + if (reference.credentialId && reference.credentialId > 0) { + const localCred = userCredentials.find( + (cred) => + cred.id === reference.credentialId && + (cred.type.endsWith("_video") || cred.type.endsWith("_conferencing")) + ); + if (localCred) { + return CredentialRepository.findCredentialForCalendarServiceById({ + id: localCred.id, + }); + } + + return CredentialRepository.findCredentialForCalendarServiceById({ + id: reference.credentialId, + }); + } + + const typeCred = userCredentials.find((cred) => cred.type === reference.type); + if (typeCred) { + return CredentialRepository.findCredentialForCalendarServiceById({ + id: typeCred.id, + }); + } + + return null; + } + + async findVideoCredentialForIntegration( + integrationSlug: string, + userCredentials: Array<{ + id: number; + type: string; + delegationCredentialId: string | null; + }> + ): Promise { + if (integrationSlug === "cal-video") { + return { ...FAKE_DAILY_CREDENTIAL }; + } + + const integrationToCredentialTypeMap = { + zoom: "zoom_video", + "whereby-video": "whereby_video", + "webex-video": "webex_video", + tandem: "tandem_video", + jitsi: "jitsi_video", + huddle: "huddle01_video", + "office365-video": "office365_video", + } as const; + + const credentialType = + integrationSlug in integrationToCredentialTypeMap + ? integrationToCredentialTypeMap[integrationSlug as keyof typeof integrationToCredentialTypeMap] + : undefined; + + const matchingCred = userCredentials.find((cred) => { + if (credentialType) { + return cred.type === credentialType; + } + const normalizedSlug = integrationSlug.replace(/-/g, "_"); + return cred.type.includes(normalizedSlug) || cred.type.includes(normalizedSlug.replace("_video", "")); + }); + + if (!matchingCred) { + return null; + } + + return CredentialRepository.findCredentialForCalendarServiceById({ + id: matchingCred.id, + }); + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index a47ed5826e..532120f76a 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -1,5 +1,6 @@ import { confirmBookingHandler, + distributedTracing, getAllUserBookings, getCalendarLinks, getTranslation, @@ -1240,6 +1241,7 @@ export class BookingsService_2024_08_13 { ...requestUser, destinationCalendar: userCalendars?.destinationCalendar ?? null, }, + traceContext: distributedTracing.createTrace("api_v2_confirm_booking"), }, input: { bookingId: booking.id, @@ -1275,6 +1277,7 @@ export class BookingsService_2024_08_13 { ...requestUser, destinationCalendar: userCalendars?.destinationCalendar ?? null, }, + traceContext: distributedTracing.createTrace("api_v2_decline_booking"), }, input: { bookingId: booking.id, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index 3cea8fafcc..fe7c9051f3 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -49,6 +49,7 @@ import type { RescheduleSeatedBookingInput_2024_08_13, } from "@calcom/platform-types"; import type { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input"; +import type { UpdateBookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/update-location.input"; import type { EventType } from "@calcom/prisma/client"; type BookingRequest = NextApiRequest & { @@ -306,7 +307,7 @@ export class InputBookingsService_2024_08_13 { } as unknown as BookingRequest; } - transformLocation(location: string | BookingInputLocation_2024_08_13): { + transformLocation(location: string | BookingInputLocation_2024_08_13 | UpdateBookingInputLocation_2024_08_13): { value: string; optionValue: string; } { diff --git a/apps/api/v2/test/fixtures/repository/booking-reference.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/booking-reference.repository.fixture.ts new file mode 100644 index 0000000000..f440c9b1c1 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/booking-reference.repository.fixture.ts @@ -0,0 +1,34 @@ +import type { Booking, BookingReference, Prisma } from "@calcom/prisma/client"; +import { TestingModule } from "@nestjs/testing"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; + +export class BookingReferenceRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getById(id: BookingReference["id"]) { + return this.prismaReadClient.bookingReference.findFirst({ where: { id } }); + } + + async getByBookingId(bookingId: Booking["id"]) { + return this.prismaReadClient.bookingReference.findMany({ where: { bookingId } }); + } + + async create(bookingReference: Prisma.BookingReferenceCreateInput) { + return this.prismaWriteClient.bookingReference.create({ data: bookingReference }); + } + + async deleteById(id: BookingReference["id"]) { + return this.prismaWriteClient.bookingReference.deleteMany({ where: { id } }); + } + + async deleteByBookingId(bookingId: Booking["id"]) { + return this.prismaWriteClient.bookingReference.deleteMany({ where: { bookingId } }); + } +} diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 0733d3fea8..bcfa3f66d0 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -10541,7 +10541,7 @@ "patch": { "operationId": "BookingLocationController_2024_08_13_updateBookingLocation", "summary": "Update booking location for an existing booking", - "description": "**Current Limitation:** Updating a booking location will update the location in Cal.com, but the corresponding Calendar event will not be updated automatically. The old location will persist in the Calendar event. This is a known limitation that will be addressed in a future update.\n \n The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.", + "description": "**Current Limitation:** Updating a booking location will update the location in Cal.com, but the corresponding Calendar event will not be updated automatically. The old location will persist in the Calendar event. This is a known limitation that will be addressed in a future update.\n\n The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.", "parameters": [ { "name": "cal-api-version", @@ -32891,6 +32891,52 @@ }, "required": ["type", "phone"] }, + "UpdateBookingInputIntegrationLocation_2024_08_13": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integration", + "description": "only allowed value for type is `integration`" + }, + "integration": { + "type": "string", + "example": "cal-video", + "enum": [ + "cal-video", + "google-meet", + "zoom", + "whereby-video", + "whatsapp-video", + "webex-video", + "telegram-video", + "tandem", + "sylaps-video", + "skype-video", + "sirius-video", + "signal-video", + "shimmer-video", + "salesroom-video", + "roam-video", + "riverside-video", + "ping-video", + "office365-video", + "mirotalk-video", + "jitsi", + "jelly-video", + "jelly-conferencing", + "huddle", + "facetime-video", + "element-call-video", + "eightxeight-video", + "discord-video", + "demodesk-video", + "campfire-video" + ] + } + }, + "required": ["type", "integration"] + }, "UpdateBookingLocationInput_2024_08_13": { "type": "object", "properties": { @@ -32914,6 +32960,9 @@ }, { "$ref": "#/components/schemas/UpdateBookingInputPhoneLocation_2024_08_13" + }, + { + "$ref": "#/components/schemas/UpdateBookingInputIntegrationLocation_2024_08_13" } ] } diff --git a/packages/platform/libraries/bookings.ts b/packages/platform/libraries/bookings.ts index 54bba2d419..d58800dd71 100644 --- a/packages/platform/libraries/bookings.ts +++ b/packages/platform/libraries/bookings.ts @@ -19,7 +19,11 @@ export { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/B export { BookingEmailAndSmsTaskService } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTaskService"; export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTriggerTasker"; export { BookingAttendeesService } from "@calcom/features/bookings/services/BookingAttendeesService"; +export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook"; export { PrismaOrgMembershipRepository } from "@calcom/features/membership/repositories/PrismaOrgMembershipRepository"; -export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; export type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService"; -export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook"; \ No newline at end of file +export { + type BookingWithUserAndEventDetails, + bookingWithUserAndEventDetailsSelect, +} from "@calcom/prisma/selects/booking"; +export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; diff --git a/packages/platform/libraries/conferencing.ts b/packages/platform/libraries/conferencing.ts index 0da2313491..e008d94663 100644 --- a/packages/platform/libraries/conferencing.ts +++ b/packages/platform/libraries/conferencing.ts @@ -3,6 +3,11 @@ export { getDownloadLinkOfCalVideoByRecordingId, getAllTranscriptsAccessLinkFromRoomName, getCalVideoMeetingSessionsByRoomName, + createMeeting, + updateMeeting, + deleteMeeting, } from "@calcom/features/conferencing/lib/videoClient"; +export { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; + export type { CalMeetingParticipant, CalMeetingSession } from "@calcom/app-store/dailyvideo/zod"; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 96e9dd0c3a..8e59b1133f 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -1,4 +1,3 @@ -import { getTranslation } from "@calcom/i18n/server"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import getAllUserBookings from "@calcom/features/bookings/lib/getAllUserBookings"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; @@ -13,6 +12,7 @@ import { import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall"; import handleMarkNoShow from "@calcom/features/handleMarkNoShow"; import { getRoutedUrl } from "@calcom/features/routing-forms/lib/getRoutedUrl"; +import { getTranslation } from "@calcom/i18n/server"; import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto"; import type { Prisma } from "@calcom/prisma/client"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -23,13 +23,13 @@ export { slugify } from "@calcom/lib/slugify"; export { slugifyLenient } from "@calcom/lib/slugify-lenient"; export { getBookingForReschedule }; +export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook"; export { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; export { DEFAULT_WEBHOOK_VERSION, WebhookVersion, } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; export type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService"; -export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook"; export { AttributeType, CreationSource, @@ -42,7 +42,7 @@ export { WorkflowTemplates, WorkflowTriggerEvents, } from "@calcom/prisma/enums"; -export type { EventBusyDate } from "@calcom/types/Calendar"; +export type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar"; export { handleMarkNoShow }; export { handleCreatePhoneCall }; @@ -52,7 +52,10 @@ export type { BookingResponse, } from "@calcom/features/bookings/types"; export type { ConnectedCalendar } from "@calcom/features/calendars/lib/CalendarManager"; -export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager"; +export { + getBusyCalendarTimes, + updateEvent, +} from "@calcom/features/calendars/lib/CalendarManager"; export type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler"; @@ -68,7 +71,11 @@ export { handleCancelBooking }; export { dynamicEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; export { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; export { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; -export { bookingMetadataSchema, teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; +export { + bookingMetadataSchema, + teamMetadataSchema, + userMetadata, +} from "@calcom/prisma/zod-utils"; export { symmetricEncrypt, symmetricDecrypt }; @@ -117,9 +124,12 @@ export type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/ export { verifyPhoneNumber, sendVerificationCode }; +export { sendLocationChangeEmailsAndSMS } from "@calcom/emails/email-manager"; export { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeUnAuthenticated"; export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail"; +export { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository"; export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; +export { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; export type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; export { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService"; export { TeamService } from "@calcom/features/ee/teams/services/teamService"; @@ -129,7 +139,16 @@ export { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; export { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; export type { Tasker } from "@calcom/features/tasker/tasker"; export { getTasker } from "@calcom/features/tasker/tasker-factory"; +export { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking"; +export { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; export { verifyCodeChallenge } from "@calcom/lib/pkce"; export { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection"; +export type { TraceContext } from "@calcom/lib/tracing"; +export { distributedTracing } from "@calcom/lib/tracing/factory"; +export { + type BookingWithUserAndEventDetails, + bookingWithUserAndEventDetailsSelect, +} from "@calcom/prisma/selects/booking"; export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publicViewer/checkIfUserEmailVerificationRequired.handler"; export { verifyCode as verifyCodeAuthenticated } from "@calcom/trpc/server/routers/viewer/organizations/verifyCode.handler"; +export type { CredentialForCalendarService } from "@calcom/types/Credential"; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/update-location.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/update-location.input.ts index 368c2eedc6..ab74f6612f 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/update-location.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/update-location.input.ts @@ -13,6 +13,7 @@ import { BookingInputAttendeePhoneLocation_2024_08_13, BookingInputLinkLocation_2024_08_13, BookingInputPhoneLocation_2024_08_13, + BookingInputIntegrationLocation_2024_08_13, } from "./location.input"; export class UpdateInputAddressLocation_2024_08_13 extends BookingInputAddressLocation_2024_08_13 { @@ -43,13 +44,16 @@ export class UpdateBookingInputPhoneLocation_2024_08_13 extends BookingInputPhon phone!: string; } +export class UpdateBookingInputIntegrationLocation_2024_08_13 extends BookingInputIntegrationLocation_2024_08_13 {} + export type UpdateBookingInputLocation_2024_08_13 = | UpdateInputAddressLocation_2024_08_13 | UpdateBookingInputAttendeeAddressLocation_2024_08_13 | UpdateBookingInputAttendeeDefinedLocation_2024_08_13 | UpdateBookingInputAttendeePhoneLocation_2024_08_13 | UpdateBookingInputLinkLocation_2024_08_13 - | UpdateBookingInputPhoneLocation_2024_08_13; + | UpdateBookingInputPhoneLocation_2024_08_13 + | UpdateBookingInputIntegrationLocation_2024_08_13; @ValidatorConstraint({ async: true }) class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstraintInterface { @@ -62,6 +66,7 @@ class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstra attendeePhone: UpdateBookingInputAttendeePhoneLocation_2024_08_13, link: UpdateBookingInputLinkLocation_2024_08_13, phone: UpdateBookingInputPhoneLocation_2024_08_13, + integration: UpdateBookingInputIntegrationLocation_2024_08_13, }; async validate(location: { type: string }) { @@ -73,7 +78,7 @@ class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstra const ClassType = this.classTypeMap[type]; if (!ClassType) { - this.validationMessage = `UpdateBookingInputLocationValidator_2024_08_13 - Unsupported booking location type '${type}'. Valid types are address, link, phone, attendeePhone, attendeeAddress, and attendeeDefined.`; + this.validationMessage = `UpdateBookingInputLocationValidator_2024_08_13 - Unsupported booking location type '${type}'. Valid types are address, link, phone, attendeePhone, attendeeAddress, attendeeDefined, and integration.`; return false; } @@ -115,7 +120,8 @@ export function ValidateUpdateBookingLocation_2024_08_13(validationOptions?: Val UpdateBookingInputAttendeeDefinedLocation_2024_08_13, UpdateBookingInputAttendeePhoneLocation_2024_08_13, UpdateBookingInputLinkLocation_2024_08_13, - UpdateBookingInputPhoneLocation_2024_08_13 + UpdateBookingInputPhoneLocation_2024_08_13, + UpdateBookingInputIntegrationLocation_2024_08_13 ) export class UpdateBookingLocationInput_2024_08_13 { @IsOptional() @@ -130,6 +136,7 @@ export class UpdateBookingLocationInput_2024_08_13 { { $ref: getSchemaPath(UpdateBookingInputAttendeePhoneLocation_2024_08_13) }, { $ref: getSchemaPath(UpdateBookingInputLinkLocation_2024_08_13) }, { $ref: getSchemaPath(UpdateBookingInputPhoneLocation_2024_08_13) }, + { $ref: getSchemaPath(UpdateBookingInputIntegrationLocation_2024_08_13) }, ], }) location?: UpdateBookingInputLocation_2024_08_13; diff --git a/packages/prisma/selects/booking.ts b/packages/prisma/selects/booking.ts index 1b7d58e563..edc2e930bf 100644 --- a/packages/prisma/selects/booking.ts +++ b/packages/prisma/selects/booking.ts @@ -27,3 +27,106 @@ export const bookingDetailsSelect = { }, }, } satisfies Prisma.BookingSelect; + +export const bookingWithUserAndEventDetailsSelect = { + title: true, + description: true, + startTime: true, + endTime: true, + userPrimaryEmail: true, + uid: true, + iCalUID: true, + iCalSequence: true, + eventTypeId: true, + id: true, + userId: true, + location: true, + responses: true, + metadata: true, + destinationCalendar: { + select: { + id: true, + integration: true, + externalId: true, + primaryEmail: true, + userId: true, + eventTypeId: true, + credentialId: true, + delegationCredentialId: true, + domainWideDelegationCredentialId: true, + createdAt: true, + updatedAt: true, + customCalendarReminder: true, + }, + }, + attendees: { + select: { + email: true, + name: true, + timeZone: true, + locale: true, + }, + }, + references: { + select: { + id: true, + type: true, + uid: true, + deleted: true, + credentialId: true, + delegationCredentialId: true, + externalCalendarId: true, + }, + }, + user: { + select: { + email: true, + name: true, + timeZone: true, + locale: true, + credentials: { + select: { + id: true, + type: true, + delegationCredentialId: true, + }, + }, + destinationCalendar: { + select: { + id: true, + integration: true, + externalId: true, + primaryEmail: true, + userId: true, + eventTypeId: true, + credentialId: true, + delegationCredentialId: true, + domainWideDelegationCredentialId: true, + createdAt: true, + updatedAt: true, + customCalendarReminder: true, + }, + }, + profiles: { + select: { + organizationId: true, + }, + }, + }, + }, + eventType: { + select: { + title: true, + metadata: true, + recurringEvent: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + hideOrganizerEmail: true, + customReplyToEmail: true, + }, + }, +} satisfies Prisma.BookingSelect; + +export type BookingWithUserAndEventDetails = Prisma.BookingGetPayload<{ + select: typeof bookingWithUserAndEventDetailsSelect; +}>;