feat: endpoint to update location for existing booking (#25367)

* feat: init endpoint to update booking location

* chore: update controller

* chore: DTOs for update location endpoint

* chore: add update booking method

* fix: refactor

* feat: service update booking location

* chore: update platform libraries

* chore: update exports

* chore: e2e tests for updating booking location endpoint

* chore: update e2e tests for updating booking location endpoint

* chore: update bookings module

* fixup

* chore: update configs

* fixup: revert changes

* chore: remove unused handlers

* fix: update inputs

* refactor: booking location service

* fixup

* revert: unnecessary changes

* chore: cleanup

* fix: e2e tests

* chore: implement cubic feedback

* chore: implement feedback

* fixup

* chore: more PR feedback
This commit is contained in:
Rajiv Sahal authored and GitHub committed 2025-12-22 09:25:00 +00:00
1 parent 6ebd401953
commit f9ff3248e5
11 files changed
+833 -17

No files matched your search

@@ -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 {}
@@ -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.
<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,
@Body() body: UpdateBookingLocationInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<UpdateBookingLocationOutput_2024_08_13> {
const booking = await this.bookingLocationService.updateBookingLocation(bookingUid, body, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -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>(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;
}
});
@@ -0,0 +1,3 @@
import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
export class UpdateBookingLocationOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
@@ -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 },
});
}
}
@@ -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<string, unknown>;
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;
}
}
@@ -1,4 +1,3 @@
/* eslint-disable no-undef */
// vite.config.ts
import react from "@vitejs/plugin-react";
import { resolve } from "path";
@@ -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";
@@ -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;
}
@@ -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`",
@@ -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 {}