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 {}