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 d47a73c333..597758201a 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
@@ -1,9 +1,11 @@
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
+import { BookingLocationController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-location.controller";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
+import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.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";
@@ -100,8 +102,13 @@ import { Module } from "@nestjs/common";
CalVideoService,
CalVideoOutputService,
BookingPbacGuard,
+ BookingLocationService_2024_08_13,
+ ],
+ controllers: [
+ BookingsController_2024_08_13,
+ BookingGuestsController_2024_08_13,
+ BookingLocationController_2024_08_13,
],
- controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
})
export class BookingsModule_2024_08_13 {}
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
new file mode 100644
index 0000000000..34a08066ac
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-location.controller.ts
@@ -0,0 +1,57 @@
+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 { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
+
+import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
+import { UpdateBookingLocationInput_2024_08_13 } from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/bookings/:bookingUid/location",
+ version: VERSION_2024_08_13_VALUE,
+})
+@UseGuards(PermissionsGuard)
+@DocsTags("Bookings")
+@ApiHeader({
+ name: "cal-api-version",
+ description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
+ example: VERSION_2024_08_13,
+ 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)
+ @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.`,
+ })
+ async updateBookingLocation(
+ @Param("bookingUid") bookingUid: string,
+ @Body() body: UpdateBookingLocationInput_2024_08_13,
+ @GetUser() user: ApiAuthGuardUser
+ ): Promise {
+ const booking = await this.bookingLocationService.updateBookingLocation(bookingUid, body, user);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: booking,
+ };
+ }
+}
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
new file mode 100644
index 0000000000..e2f50e80e5
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/update-booking-location.e2e-spec.ts
@@ -0,0 +1,492 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output";
+import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
+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 { UsersModule } from "@/modules/users/users.module";
+import { INestApplication } from "@nestjs/common";
+import { NestExpressApplication } from "@nestjs/platform-express";
+import { Test } from "@nestjs/testing";
+import * as request from "supertest";
+import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.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 {
+ CAL_API_VERSION_HEADER,
+ ERROR_STATUS,
+ SUCCESS_STATUS,
+ VERSION_2024_08_13,
+} from "@calcom/platform-constants";
+import { UpdateBookingLocationInput_2024_08_13 } from "@calcom/platform-types";
+import type { BookingOutput_2024_08_13 } from "@calcom/platform-types";
+import { Booking } from "@calcom/prisma/client";
+import type { Team } from "@calcom/prisma/client";
+
+type TestUser = {
+ id: number;
+ accessToken: string;
+ refreshToken: string;
+ email: string;
+};
+
+type TestSetup = {
+ organizer: TestUser;
+ unrelatedUser: TestUser;
+ eventTypeId: number;
+};
+
+describe("Bookings Endpoints 2024-08-13 update booking location", () => {
+ let app: INestApplication;
+ let organization: Team;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let bookingsRepositoryFixture: BookingsRepositoryFixture;
+ let schedulesService: SchedulesService_2024_04_15;
+ let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
+ let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
+ let teamRepositoryFixture: TeamRepositoryFixture;
+ let tokensRepositoryFixture: TokensRepositoryFixture;
+
+ let testSetup: TestSetup;
+
+ beforeAll(async () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
+ })
+ .overrideGuard(PermissionsGuard)
+ .useValue({
+ canActivate: () => true,
+ })
+ .compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
+ eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
+ oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
+ teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ schedulesService = moduleRef.get(SchedulesService_2024_04_15);
+ tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
+
+ organization = await teamRepositoryFixture.create({
+ name: `update-booking-location-organization-${randomString()}`,
+ });
+
+ await setupTestData();
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ describe("PATCH /v2/bookings/:bookingUid/location", () => {
+ it("should return 401 when updating location without authentication", async () => {
+ const { bookingUid } = await createBooking();
+
+ await request(app.getHttpServer())
+ .patch(`/v2/bookings/${bookingUid}/location`)
+ .send({ location: "https://unauthenticated.example.com" })
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .expect(401);
+ });
+
+ describe("can update booking with different locations", () => {
+ const address = "123 Main St";
+ const link = "https://cal.com/join/123456";
+ const phone = "+37121999999";
+
+ let booking: Booking;
+ let bookingId: number;
+ let bookingUid: string;
+ let eventTypeWithAllLocationsId: number;
+
+ beforeAll(async () => {
+ const createdEventType = await eventTypesRepositoryFixture.create(
+ {
+ title: "book using any location",
+ slug: `book-using-any-location-${randomString()}`,
+ length: 15,
+ locations: [
+ { type: "integrations:daily" },
+ { type: "inPerson", address: "123 Main St", displayLocationPublicly: true },
+ { type: "link", link: "https://cal.com/join/123456", displayLocationPublicly: true },
+ { type: "userPhone", hostPhoneNumber: "+37121999999", displayLocationPublicly: true },
+ { type: "attendeeInPerson" },
+ { type: "phone" },
+ { type: "somewhereElse" },
+ ],
+ },
+ testSetup.organizer.id
+ );
+
+ eventTypeWithAllLocationsId = createdEventType.id;
+
+ booking = await bookingsRepositoryFixture.create({
+ uid: `booking-uid-${randomString(10)}`,
+ title: "booking title",
+ startTime: "2048-08-14T09:00:00.000Z",
+ endTime: "2048-08-14T10:00:00.000Z",
+ eventType: {
+ connect: {
+ id: eventTypeWithAllLocationsId,
+ },
+ },
+ status: "ACCEPTED",
+ metadata: {},
+ responses: "null",
+ user: {
+ connect: {
+ id: testSetup.organizer.id,
+ },
+ },
+ });
+ bookingUid = booking.uid;
+ bookingId = booking.id;
+ });
+
+ it("can update location to type address", async () => {
+ const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
+ location: {
+ type: "address",
+ address: address,
+ },
+ };
+
+ 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");
+ expect(updatedBooking.location).toEqual(address);
+ });
+
+ it("can update location to type link", async () => {
+ const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
+ location: {
+ type: "link",
+ link: link,
+ },
+ };
+
+ 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");
+ expect(updatedBooking.location).toEqual(link);
+ });
+
+ it("can update location to type phone", async () => {
+ const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
+ location: {
+ type: "phone",
+ phone: phone,
+ },
+ };
+
+ 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");
+ expect(updatedBooking.location).toEqual(phone);
+ });
+
+ it("can update location to type attendeeAddress", async () => {
+ const attendeeAddress = "123 Example St, City, Valhalla";
+ const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
+ location: {
+ type: "attendeeAddress",
+ address: attendeeAddress,
+ },
+ };
+
+ 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");
+ expect(updatedBooking.location).toEqual(attendeeAddress);
+ });
+
+ it("can update location to type attendeePhone", async () => {
+ const attendeePhone = "+37120993151";
+ const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
+ location: {
+ type: "attendeePhone",
+ phone: attendeePhone,
+ },
+ };
+
+ 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");
+ expect(updatedBooking.location).toEqual(attendeePhone);
+ });
+
+ it("can update location to type attendeeDefined", async () => {
+ const attendeeDefinedLocation = "namek 100";
+ const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
+ location: {
+ type: "attendeeDefined",
+ location: attendeeDefinedLocation,
+ },
+ };
+
+ 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");
+ expect(updatedBooking.location).toEqual(attendeeDefinedLocation);
+ });
+
+ afterAll(async () => {
+ if (responseDataIsBooking(booking)) {
+ await bookingsRepositoryFixture.deleteById(bookingId);
+ } else {
+ throw new Error("Unexpected response data type");
+ }
+ });
+ });
+
+ it("should return booking unchanged when no location payload is provided", async () => {
+ const initialLocation = `https://initial-${randomString()}.example.com`;
+ const { bookingUid } = await createBooking(initialLocation);
+
+ const response = await request(app.getHttpServer())
+ .patch(`/v2/bookings/${bookingUid}/location`)
+ .send({})
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
+ .expect(200);
+
+ const responseBody: UpdateBookingLocationOutput_2024_08_13 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ if (!responseDataIsBooking(responseBody.data)) {
+ throw new Error(
+ "Invalid response data - expected booking but received array of possibly recurring bookings"
+ );
+ }
+ expect(responseBody.data.location).toEqual(initialLocation);
+ });
+
+ it("should return 403 when unrelated user attempts to update booking location", async () => {
+ const { bookingUid } = await createBooking();
+
+ await request(app.getHttpServer())
+ .patch(`/v2/bookings/${bookingUid}/location`)
+ .send({ location: { type: "link", link: "https://non-organizer.example.com" } })
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
+ .expect(403);
+ });
+
+ it("should return 400 when invalid location payload is provided", async () => {
+ const { bookingUid } = await createBooking();
+
+ const response = await request(app.getHttpServer())
+ .patch(`/v2/bookings/${bookingUid}/location`)
+ .send({ location: { type: "invalid-location-type" } })
+ .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 () => {
+ await teamRepositoryFixture.delete(organization.id);
+
+ await userRepositoryFixture.deleteByEmail(testSetup.organizer.email);
+ await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.email);
+
+ await bookingsRepositoryFixture.deleteAllBookings(testSetup.organizer.id, testSetup.organizer.email);
+
+ await app.close();
+ });
+
+ async function setupTestData() {
+ const oAuthClient = await createOAuthClient(organization.id);
+
+ const organizerUser = await userRepositoryFixture.create({
+ email: `update-booking-location-organizer-${randomString()}@api.com`,
+ platformOAuthClients: {
+ connect: { id: oAuthClient.id },
+ },
+ });
+
+ const unrelatedUser = await userRepositoryFixture.create({
+ email: `update-booking-location-unrelated-${randomString()}@api.com`,
+ platformOAuthClients: {
+ connect: { id: oAuthClient.id },
+ },
+ });
+
+ const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
+ const unrelatedTokens = await tokensRepositoryFixture.createTokens(unrelatedUser.id, oAuthClient.id);
+
+ const userSchedule: CreateScheduleInput_2024_04_15 = {
+ name: `update-booking-location-schedule-${randomString()}`,
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ };
+ await schedulesService.createUserSchedule(organizerUser.id, userSchedule);
+
+ const eventType = await eventTypesRepositoryFixture.create(
+ {
+ title: `update-booking-location-event-type-${randomString()}`,
+ slug: `update-booking-location-event-type-${randomString()}`,
+ length: 45,
+ },
+ organizerUser.id
+ );
+
+ testSetup = {
+ organizer: {
+ id: organizerUser.id,
+ email: organizerUser.email,
+ accessToken: organizerTokens.accessToken,
+ refreshToken: organizerTokens.refreshToken,
+ },
+ unrelatedUser: {
+ id: unrelatedUser.id,
+ email: unrelatedUser.email,
+ accessToken: unrelatedTokens.accessToken,
+ refreshToken: unrelatedTokens.refreshToken,
+ },
+ eventTypeId: eventType.id,
+ };
+ }
+
+ async function createOAuthClient(organizationId: number) {
+ const data = {
+ logo: "logo-url",
+ name: "name",
+ redirectUris: ["http://localhost:4444"],
+ permissions: 32,
+ areEmailsEnabled: true,
+ };
+ const secret = "secret";
+
+ return oauthClientRepositoryFixture.create(organizationId, data, secret);
+ }
+
+ async function createBooking(location?: string) {
+ const bookingLocation = location ?? `https://initial-${randomString()}.example.com`;
+ const bookingUid = `booking-uid-${randomString(10)}`;
+
+ await bookingsRepositoryFixture.create({
+ uid: bookingUid,
+ title: "booking title",
+ startTime: new Date(Date.UTC(2035, 0, 9, 13, 0, 0)).toISOString(),
+ endTime: new Date(Date.UTC(2035, 0, 9, 13, 45, 0)).toISOString(),
+ eventType: {
+ connect: {
+ id: testSetup.eventTypeId,
+ },
+ },
+ status: "ACCEPTED",
+ metadata: {},
+ responses: "null",
+ location: bookingLocation,
+ user: {
+ connect: {
+ id: testSetup.organizer.id,
+ },
+ },
+ });
+
+ return {
+ bookingUid,
+ location: bookingLocation,
+ };
+ }
+
+ function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
+ return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
+ }
+});
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/update-location.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/update-location.output.ts
new file mode 100644
index 0000000000..2406dd0aa1
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/update-location.output.ts
@@ -0,0 +1,3 @@
+import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
+
+export class UpdateBookingLocationOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
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 248a5edbed..cf3bde4b06 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
@@ -206,4 +206,14 @@ export class BookingsRepository_2024_08_13 {
},
});
}
+
+ async updateBooking(bookingUid: string, body: Prisma.BookingUpdateInput) {
+ return this.dbWrite.prisma.booking.update({
+ where: {
+ uid: bookingUid,
+ },
+ data: body,
+ select: { uid: true },
+ });
+ }
}
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
new file mode 100644
index 0000000000..844164cd60
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts
@@ -0,0 +1,118 @@
+import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
+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 { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
+import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
+import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { Injectable, Logger, NotFoundException, ForbiddenException } from "@nestjs/common";
+
+import type {
+ UpdateBookingLocationInput_2024_08_13,
+ BookingInputLocation_2024_08_13,
+ UpdateBookingInputLocation_2024_08_13,
+} from "@calcom/platform-types";
+import { Booking } from "@calcom/prisma/client";
+
+@Injectable()
+export class BookingLocationService_2024_08_13 {
+ private readonly logger = new Logger("BookingLocationService_2024_08_13");
+
+ constructor(
+ private readonly bookingsRepository: BookingsRepository_2024_08_13,
+ private readonly bookingsService: BookingsService_2024_08_13,
+ private readonly usersRepository: UsersRepository,
+ private readonly inputService: InputBookingsService_2024_08_13,
+ private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
+ private readonly eventTypeAccessService: EventTypeAccessService
+ ) {}
+
+ async updateBookingLocation(
+ bookingUid: string,
+ input: UpdateBookingLocationInput_2024_08_13,
+ user: ApiAuthGuardUser
+ ) {
+ const existingBooking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
+ if (!existingBooking) {
+ throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
+ }
+
+ if (existingBooking.eventTypeId && existingBooking.eventType) {
+ const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
+ existingBooking.eventTypeId
+ );
+ if (eventType) {
+ const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(user, eventType);
+ if (!isAllowed) {
+ throw new ForbiddenException(
+ "User is not authorized to update this booking location. User must be the event type owner, host, team admin or owner, or org admin or owner."
+ );
+ }
+ }
+ }
+
+ const { location } = input;
+
+ if (location) {
+ return await this.updateLocation(existingBooking, location, user);
+ }
+
+ return this.bookingsService.getBooking(existingBooking.uid, user);
+ }
+
+ private async updateLocation(
+ existingBooking: Booking,
+ inputLocation: UpdateBookingInputLocation_2024_08_13,
+ user: ApiAuthGuardUser
+ ) {
+ const bookingUid = existingBooking.uid;
+ const bookingLocation = this.getLocationValue(inputLocation) ?? existingBooking.location;
+
+ if (!existingBooking.userId) {
+ throw new NotFoundException(`No user found for booking with uid=${bookingUid}`);
+ }
+
+ if (!existingBooking.eventTypeId) {
+ throw new NotFoundException(`No event type found for booking with uid=${bookingUid}`);
+ }
+
+ const existingBookingHost = await this.usersRepository.findById(existingBooking.userId);
+
+ if (!existingBookingHost) {
+ throw new NotFoundException(`No user found for booking with uid=${bookingUid}`);
+ }
+
+ const bookingFieldsLocation = this.inputService.transformLocation(
+ inputLocation as BookingInputLocation_2024_08_13
+ );
+
+ const responses = (existingBooking.responses || {}) as Record;
+ const { location: _existingLocation, ...rest } = responses;
+
+ const updatedBookingResponses = { ...rest, location: bookingFieldsLocation };
+
+ const updatedBooking = await this.bookingsRepository.updateBooking(bookingUid, {
+ location: bookingLocation,
+ responses: updatedBookingResponses,
+ });
+
+ return this.bookingsService.getBooking(updatedBooking.uid, user);
+ }
+
+ private getLocationValue(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;
+ if (loc.type === "attendeeAddress") return loc.address;
+ if (loc.type === "attendeePhone") return loc.phone;
+ 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.`
+ );
+
+ return undefined;
+ }
+}
diff --git a/packages/platform/libraries/vite.config.js b/packages/platform/libraries/vite.config.js
index a422d26c48..92e93cfd05 100644
--- a/packages/platform/libraries/vite.config.js
+++ b/packages/platform/libraries/vite.config.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-undef */
// vite.config.ts
import react from "@vitejs/plugin-react";
import { resolve } from "path";
diff --git a/packages/platform/types/bookings/2024-08-13/inputs/index.ts b/packages/platform/types/bookings/2024-08-13/inputs/index.ts
index 2bc8acf9df..59995ecae9 100644
--- a/packages/platform/types/bookings/2024-08-13/inputs/index.ts
+++ b/packages/platform/types/bookings/2024-08-13/inputs/index.ts
@@ -10,3 +10,5 @@ export * from "./cancel-booking-input.pipe";
export * from "./decline-booking.input";
export * from "./validators/validate-metadata";
export * from "./add-guests.input";
+export * from "./update-location.input";
+export * from "./location.input";
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
new file mode 100644
index 0000000000..e3c5b284df
--- /dev/null
+++ b/packages/platform/types/bookings/2024-08-13/inputs/update-location.input.ts
@@ -0,0 +1,128 @@
+import { ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
+import { ApiProperty as DocsProperty } from "@nestjs/swagger";
+import { plainToInstance } from "class-transformer";
+import { IsOptional } from "class-validator";
+import { IsString, MinLength, IsUrl, IsPhoneNumber, IsDefined } from "class-validator";
+import type { ValidationOptions, ValidatorConstraintInterface } from "class-validator";
+import { registerDecorator, validate, ValidatorConstraint } from "class-validator";
+
+import {
+ BookingInputAddressLocation_2024_08_13,
+ BookingInputAttendeeAddressLocation_2024_08_13,
+ BookingInputAttendeeDefinedLocation_2024_08_13,
+ BookingInputAttendeePhoneLocation_2024_08_13,
+ BookingInputLinkLocation_2024_08_13,
+ BookingInputPhoneLocation_2024_08_13,
+} from "./location.input";
+
+export class UpdateInputAddressLocation_2024_08_13 extends BookingInputAddressLocation_2024_08_13 {
+ @IsDefined()
+ @IsString()
+ @MinLength(1)
+ @DocsProperty({ example: "123 Example St, City, Country" })
+ address!: string;
+}
+
+export class UpdateBookingInputAttendeeAddressLocation_2024_08_13 extends BookingInputAttendeeAddressLocation_2024_08_13 {}
+
+export class UpdateBookingInputAttendeeDefinedLocation_2024_08_13 extends BookingInputAttendeeDefinedLocation_2024_08_13 {}
+
+export class UpdateBookingInputAttendeePhoneLocation_2024_08_13 extends BookingInputAttendeePhoneLocation_2024_08_13 {}
+
+export class UpdateBookingInputLinkLocation_2024_08_13 extends BookingInputLinkLocation_2024_08_13 {
+ @IsDefined()
+ @IsUrl()
+ @DocsProperty({ example: "https://meet.google.com/txi-fein-xyz" })
+ link!: string;
+}
+
+export class UpdateBookingInputPhoneLocation_2024_08_13 extends BookingInputPhoneLocation_2024_08_13 {
+ @IsDefined()
+ @IsPhoneNumber()
+ @DocsProperty({ example: "+37120993151" })
+ phone!: string;
+}
+
+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;
+
+@ValidatorConstraint({ async: true })
+class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstraintInterface {
+ private validationMessage = "";
+
+ private classTypeMap: { [key: string]: new () => UpdateBookingInputLocation_2024_08_13 } = {
+ address: UpdateInputAddressLocation_2024_08_13,
+ attendeeAddress: UpdateBookingInputAttendeeAddressLocation_2024_08_13,
+ attendeeDefined: UpdateBookingInputAttendeeDefinedLocation_2024_08_13,
+ attendeePhone: UpdateBookingInputAttendeePhoneLocation_2024_08_13,
+ link: UpdateBookingInputLinkLocation_2024_08_13,
+ phone: UpdateBookingInputPhoneLocation_2024_08_13,
+ };
+
+ async validate(location: { type: string }) {
+ const { type } = location;
+ if (!type) {
+ this.validationMessage = `UpdateBookingInputLocationValidator_2024_08_13 - Booking 'location' must have a 'type' property.`;
+ return false;
+ }
+
+ 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.`;
+ return false;
+ }
+
+ const instance = plainToInstance(ClassType, location);
+ const errors = await validate(instance, { skipMissingProperties: false });
+ if (errors.length > 0) {
+ const message = errors.flatMap((error) => Object.values(error.constraints || {})).join(", ");
+ this.validationMessage = `UpdateBookingInputLocationValidator_2024_08_13 - Validation failed for ${type} location: ${message}`;
+ return false;
+ }
+
+ return true;
+ }
+
+ defaultMessage() {
+ return (
+ this.validationMessage ||
+ `UpdateBookingInputLocationValidator_2024_08_13 - Validation failed for one or more location entries.`
+ );
+ }
+}
+
+export function ValidateUpdateBookingLocation_2024_08_13(validationOptions?: ValidationOptions) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (object: any, propertyName: string) {
+ registerDecorator({
+ name: "ValidateUpdateLocation",
+ target: object.constructor,
+ propertyName: propertyName,
+ options: validationOptions,
+ validator: new UpdateBookingInputLocationValidator_2024_08_13(),
+ });
+ };
+}
+
+export class UpdateBookingLocationInput_2024_08_13 {
+ @IsOptional()
+ @ValidateUpdateBookingLocation_2024_08_13()
+ @ApiPropertyOptional({
+ description:
+ "One of the event type locations. If instead of passing one of the location objects as required by schema you are still passing a string please use an object.",
+ oneOf: [
+ { $ref: getSchemaPath(UpdateInputAddressLocation_2024_08_13) },
+ { $ref: getSchemaPath(UpdateBookingInputAttendeeAddressLocation_2024_08_13) },
+ { $ref: getSchemaPath(UpdateBookingInputAttendeeDefinedLocation_2024_08_13) },
+ { $ref: getSchemaPath(UpdateBookingInputAttendeePhoneLocation_2024_08_13) },
+ { $ref: getSchemaPath(UpdateBookingInputLinkLocation_2024_08_13) },
+ { $ref: getSchemaPath(UpdateBookingInputPhoneLocation_2024_08_13) },
+ ],
+ })
+ location?: UpdateBookingInputLocation_2024_08_13;
+}
diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/locations.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/locations.input.ts
index e5c9075404..63278dcc94 100644
--- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/locations.input.ts
+++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/locations.input.ts
@@ -5,7 +5,7 @@ import { IsString, IsUrl, IsIn, IsPhoneNumber, IsBoolean, MinLength } from "clas
import type { ValidationOptions, ValidatorConstraintInterface } from "class-validator";
import { registerDecorator, validate, ValidatorConstraint } from "class-validator";
-export const inputLocations = [
+export const eventTypeInputLocations = [
"address",
"link",
"integration",
@@ -17,7 +17,7 @@ export const inputLocations = [
] as const;
export class InputAddressLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({ example: "address", description: "only allowed value for type is `address`" })
type!: "address";
@@ -32,7 +32,7 @@ export class InputAddressLocation_2024_06_14 {
}
export class InputOrganizersDefaultApp_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({
example: "organizersDefaultApp",
description: "only allowed value for type is `organizersDefaultApp`",
@@ -41,7 +41,7 @@ export class InputOrganizersDefaultApp_2024_06_14 {
}
export class InputLinkLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({ example: "link", description: "only allowed value for type is `link`" })
type!: "link";
@@ -54,7 +54,7 @@ export class InputLinkLocation_2024_06_14 {
public!: boolean;
}
-export const supportedIntegrations = [
+export const eventTypeSupportedIntegrations = [
"cal-video",
"google-meet",
"zoom",
@@ -85,20 +85,20 @@ export const supportedIntegrations = [
"demodesk-video",
"campfire-video",
] as const;
-export type Integration_2024_06_14 = (typeof supportedIntegrations)[number];
+export type Integration_2024_06_14 = (typeof eventTypeSupportedIntegrations)[number];
export class InputIntegrationLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({ example: "integration", description: "only allowed value for type is `integration`" })
type!: "integration";
- @IsIn(supportedIntegrations)
- @DocsProperty({ example: supportedIntegrations[0], enum: supportedIntegrations })
+ @IsIn(eventTypeSupportedIntegrations)
+ @DocsProperty({ example: eventTypeSupportedIntegrations[0], enum: eventTypeSupportedIntegrations })
integration!: Integration_2024_06_14;
}
export class InputPhoneLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({ example: "phone", description: "only allowed value for type is `phone`" })
type!: "phone";
@@ -112,7 +112,7 @@ export class InputPhoneLocation_2024_06_14 {
}
export class InputAttendeeAddressLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({
example: "attendeeAddress",
description: "only allowed value for type is `attendeeAddress`",
@@ -120,13 +120,13 @@ export class InputAttendeeAddressLocation_2024_06_14 {
type!: "attendeeAddress";
}
export class InputAttendeePhoneLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({ example: "attendeePhone", description: "only allowed value for type is `attendeePhone`" })
type!: "attendeePhone";
}
export class InputAttendeeDefinedLocation_2024_06_14 {
- @IsIn(inputLocations)
+ @IsIn(eventTypeInputLocations)
@DocsProperty({
example: "attendeeDefined",
description: "only allowed value for type is `attendeeDefined`",
diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/locations.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/locations.output.ts
index c1741a600f..ebbd8c4732 100644
--- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/locations.output.ts
+++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/locations.output.ts
@@ -11,11 +11,11 @@ import {
InputAttendeeDefinedLocation_2024_06_14,
InputAttendeePhoneLocation_2024_06_14,
InputLinkLocation_2024_06_14,
- inputLocations,
+ eventTypeInputLocations,
InputPhoneLocation_2024_06_14,
} from "../inputs";
-const outputLocations = [...inputLocations, "conferencing", "unknown"] as const;
+const outputLocations = [...eventTypeInputLocations, "conferencing", "unknown"] as const;
export class OutputAddressLocation_2024_06_14 extends InputAddressLocation_2024_06_14 {}
export class OutputLinkLocation_2024_06_14 extends InputLinkLocation_2024_06_14 {}