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;
+}>;