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 <sahalrajiv6900@gmail.com>

* fix: mock createMeeting directly to bypass database check in e2e test

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.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 <sahalrajiv6900@gmail.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 <sahalrajiv6900@gmail.com>

* refactor: simplify service code - extract shared helpers, remove duplication

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* feat: implement fixtures for bookings references

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2026-03-17 15:15:17 +05:30
committed by GitHub
co-authored by rajiv@cal.com Devin AI
parent 4291a59b2c
commit 089a39f59f
18 changed files with 1341 additions and 53 deletions
@@ -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: [
@@ -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.
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>`,
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.
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>`,
})
async updateBookingLocation(
@Param("bookingUid") bookingUid: string,
@@ -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>(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);
@@ -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: {
@@ -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<CalendarEvent> {
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<string, unknown>
): Promise<void> {
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<void> {
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)
);
}
}
}
}
@@ -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<CredentialForCalendarService | null> {
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;
}
}
@@ -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<BookingWithUserAndEventDetails, "user"> & {
user: NonNullable<BookingWithUserAndEventDetails["user"]>;
};
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<BookingLocationResponse> {
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<BookingLocationResponse> {
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<BookingLocationResponse> {
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<BookingLocationResponse> {
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<BookingLocationResponse> {
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<BookingLocationResponse> {
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<BookingLocationResponse> {
const updatedMetadata = this.buildUpdatedMetadata(ctx.existingBooking.metadata, videoCallUrl);
const bookingFieldsLocation = this.inputService.transformLocation(ctx.inputLocation);
const responses = (ctx.existingBooking.responses || {}) as Record<string, unknown>;
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<string, unknown> {
return {
...((existingMetadata || {}) as Record<string, unknown>),
videoCallUrl,
};
}
private async emitLocationChangeEvents(
ctx: IntegrationHandlerContext,
bookingLocation: string,
evt: CalendarEvent
): Promise<void> {
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<string, unknown> | undefined
);
}
}
@@ -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<BookingLocationResponse> {
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<BookingLocationResponse> {
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<string, unknown>;
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<void> {
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<string, unknown> | 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<string, unknown> {
const existingMetadata = (metadata || {}) as Record<string, unknown>;
const { videoCallUrl: _removedVideoUrl, ...metadataWithoutVideoUrl } = existingMetadata;
return metadataWithoutVideoUrl;
}
}
@@ -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<void> {
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<CredentialForCalendarService | null> {
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<CredentialForCalendarService | null> {
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,
});
}
}
@@ -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,
@@ -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;
} {
@@ -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 } });
}
}
+50 -1
View File
@@ -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 <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>",
"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 <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>",
"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"
}
]
}
+6 -2
View File
@@ -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";
export {
type BookingWithUserAndEventDetails,
bookingWithUserAndEventDetailsSelect,
} from "@calcom/prisma/selects/booking";
export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
@@ -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";
+24 -5
View File
@@ -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";
@@ -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;
+103
View File
@@ -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;
}>;