diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts index 29cc819304..0c164e32a2 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts @@ -1,6 +1,7 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.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"; @@ -31,6 +32,7 @@ import { RecurringBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13, GetSeatedBookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, } from "@calcom/platform-types"; import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; @@ -66,6 +68,8 @@ describe("Bookings Endpoints 2024-08-13", () => { const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`; const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`; + let phoneBasedBooking: BookingOutput_2024_08_13; + beforeAll(async () => { const moduleRef = await withApiAuth( teamUserEmail, @@ -408,12 +412,13 @@ describe("Bookings Endpoints 2024-08-13", () => { }); it("should create a phone based booking", async () => { + const phoneNumber = "+919876543210"; const body: CreateBookingInput_2024_08_13 = { start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(), eventTypeId: phoneOnlyEventTypeId, attendee: { name: "alice", - phoneNumber: "+919876543210", + phoneNumber, timeZone: "Europe/Madrid", language: "es", }, @@ -453,6 +458,8 @@ describe("Bookings Endpoints 2024-08-13", () => { }); expect(data.meetingUrl).toEqual(body.meetingUrl); expect(data.absentHost).toEqual(false); + expect(data.bookingFieldsResponses.attendeePhoneNumber).toEqual(phoneNumber); + phoneBasedBooking = data; } else { throw new Error( "Invalid response data - expected booking but received array of possibly recurring bookings" @@ -619,6 +626,59 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + describe("reschedule", () => { + it("should should reschedule phone based booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${phoneBasedBooking.uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 14, 15, 0)).toISOString()); + expect(data.duration).toEqual(15); + expect(data.eventTypeId).toEqual(phoneOnlyEventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: phoneBasedBooking.attendees[0].name, + email: phoneBasedBooking.attendees[0].email, + phoneNumber: phoneBasedBooking.attendees[0].phoneNumber, + timeZone: phoneBasedBooking.attendees[0].timeZone, + language: phoneBasedBooking.attendees[0].language, + absent: false, + }); + expect(data.meetingUrl).toEqual(phoneBasedBooking.meetingUrl); + expect(data.absentHost).toEqual(false); + expect(data.bookingFieldsResponses.attendeePhoneNumber).toEqual( + phoneBasedBooking.bookingFieldsResponses.attendeePhoneNumber + ); + phoneBasedBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + }); + async function createOAuthClient(organizationId: number) { const data = { logo: "logo-url", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts index ea3fdf00e1..2ed101f3b9 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts @@ -1949,6 +1949,95 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + describe("rescheduling booking with invalid booking responses", () => { + it("should reschedule", async () => { + const email = `user-bookings-attendee-${randomString(10)}@gmail.com`; + const username = `user-bookings-attendee-${randomString(10)}`; + const locale = "it"; + const timeZone = "Europe/Rome"; + const attendee = await userRepositoryFixture.create({ + email, + username, + locale, + timeZone, + }); + + const booking = await bookingsRepositoryFixture.create({ + uid: `booking-uid-${randomString(10)}`, + title: "booking title", + startTime: "2060-09-05T11:00:00.000Z", + endTime: "2060-09-05T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + status: "ACCEPTED", + metadata: {}, + responses: "null", + user: { + connect: { + id: user.id, + }, + }, + attendees: { + create: { + email: email, + name: username, + timeZone, + locale, + }, + }, + }); + + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2060, 0, 9, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${booking.uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.reschedulingReason).toEqual(body.reschedulingReason); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2060, 0, 9, 15, 0, 0)).toISOString()); + expect(data.rescheduledFromUid).toEqual(booking.uid); + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual("accepted"); + expect(data.eventTypeId).toEqual(booking.eventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: attendee.username, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: false, + }); + expect(data.bookingFieldsResponses).toEqual({ + name: attendee.username, + email: attendee.email, + rescheduledReason: body.reschedulingReason, + guests: [], + }); + await bookingsRepositoryFixture.deleteById(data.id); + await userRepositoryFixture.delete(attendee.id); + }); + }); + }); + function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { return ( !Array.isArray(data) && diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index abfa70376c..130e5d9026 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -7,6 +7,8 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings. import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service"; import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking"; +import { safeParse } from "@/lib/safe-parse/safe-parse"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; @@ -41,7 +43,7 @@ import { RescheduleSeatedBookingInput_2024_08_13, } from "@calcom/platform-types"; import { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input"; -import { EventType, PlatformOAuthClient } from "@calcom/prisma/client"; +import { EventType } from "@calcom/prisma/client"; type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams; @@ -429,6 +431,7 @@ export class InputBookingsService_2024_08_13 { ...(inputBooking.bookingFieldsResponses || {}), name: inputBooking.attendee.name, email: attendeeEmail, + attendeePhoneNumber: inputBooking.attendee.phoneNumber, guests, location, }, @@ -567,8 +570,19 @@ export class InputBookingsService_2024_08_13 { throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`); } - const bookingResponses = bookingResponsesSchema.parse(booking.responses); - const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email); + const bookingResponses = safeParse( + bookingResponsesSchema, + booking.responses, + defaultBookingResponses, + false + ); + const bookingResponsesMissing = + bookingResponses.name === defaultBookingResponses.name && + bookingResponses.email === defaultBookingResponses.email; + + const attendee = bookingResponsesMissing + ? booking.attendees[0] + : booking.attendees.find((attendee) => attendee.email === bookingResponses.email); if (!attendee) { throw new NotFoundException( @@ -576,6 +590,12 @@ export class InputBookingsService_2024_08_13 { ); } + if (bookingResponsesMissing) { + bookingResponses.name = attendee.name; + bookingResponses.email = attendee.email; + bookingResponses.attendeePhoneNumber = attendee.phoneNumber || undefined; + } + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone); const endTime = startTime.plus({ minutes: eventType.length }); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts index cf298326c9..cc1d1322c6 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts @@ -27,6 +27,7 @@ import { Booking, BookingSeat } from "@calcom/prisma/client"; export const bookingResponsesSchema = z .object({ email: z.string(), + attendeePhoneNumber: z.string().optional(), name: z.union([ z.string(), z.object({ @@ -45,6 +46,7 @@ export const seatedBookingDataSchema = z responses: z .object({ email: z.string(), + attendeePhoneNumber: z.string().optional(), name: z.union([ z.string(), z.object({ diff --git a/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts b/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts index fa06643b6b..6bf90b62c3 100644 --- a/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts +++ b/apps/api/v2/src/lib/safe-parse/default-responses-booking.ts @@ -2,7 +2,7 @@ export const defaultBookingResponses = { name: "unknown", email: "unknown", guests: [], - rescheduleReason: "unknown", + rescheduledReason: "unknown", }; export const defaultBookingMetadata = { videoCallUrl: "unknown" }; diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts index 8da3bc101c..df802b40d2 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts @@ -75,6 +75,9 @@ describe("Slots 2024-09-04 Endpoints", () => { const seatedEventTypeSlug = `slots-2024-09-04-seated-event-type-${randomString()}`; let seatedEventType: EventType; + const variableLengthEventTypeSlug = `slots-2024-09-04-variable-length-event-type-${randomString()}`; + let variableLengthEventType: EventType; + let reservedSlot: ReserveSlotOutputData_2024_09_04; beforeAll(async () => { @@ -168,6 +171,17 @@ describe("Slots 2024-09-04 Endpoints", () => { ); seatedEventType = seatedEvent; + const variableLengthEvent = await eventTypesRepositoryFixture.create( + { + title: "frisbee match", + slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`, + length: 15, + metadata: { multipleDuration: [15, 30, 45, 60] }, + }, + user.id + ); + variableLengthEventType = variableLengthEvent; + team = await teamRepositoryFixture.create({ name: `slots-2024-09-04-team-${randomString()}`, isOrganization: false, @@ -1246,6 +1260,90 @@ describe("Slots 2024-09-04 Endpoints", () => { await bookingsRepositoryFixture.deleteById(booking.id); }); + describe("variable length", () => { + it("should not be able to reserve a slot for variable length event type with invalid duration", async () => { + const slotStartTime = "2050-09-05T10:00:00.000Z"; + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId: variableLengthEventType.id, + slotStart: slotStartTime, + slotDuration: 1000, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(400); + + expect(reserveResponse.body.error.message).toEqual( + "Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60" + ); + }); + + it("should reserve a slot with slot duration for variable event type length", async () => { + // note(Lauris): mock current date to test slots release time + const now = "2049-09-05T12:00:00.000Z"; + const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate(); + advanceTo(newDate); + + const slotDuration = 60; + const slotStartTime = "2050-09-05T10:00:00.000Z"; + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId: variableLengthEventType.id, + slotStart: slotStartTime, + slotDuration, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body; + expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS); + const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data; + expect(responseReservedSlot.reservationUid).toBeDefined(); + expect(responseReservedSlot.eventTypeId).toEqual(variableLengthEventType.id); + expect(responseReservedSlot.slotStart).toEqual(slotStartTime); + expect(responseReservedSlot.slotDuration).toEqual(slotDuration); + expect(responseReservedSlot.slotEnd).toEqual( + DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO() + ); + expect(responseReservedSlot.reservationDuration).toEqual(5); + + if (!responseReservedSlot.reservationUid) { + throw new Error("Reserved slot uid is undefined"); + } + + const response = await request(app.getHttpServer()) + .get( + `/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-09&duration=60` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(200); + + const responseBody: GetSlotsOutput_2024_09_04 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots); + expect(days.length).toEqual(5); + + const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter( + (slot) => slot.start !== slotStartTime + ); + expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 }); + + const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid); + expect(dbSlot).toBeDefined(); + if (dbSlot) { + const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO(); + const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 5 }).toISO(); + expect(dbReleaseAt).toEqual(expectedReleaseAt); + expect(responseReservedSlot.reservationUntil).toEqual(expectedReleaseAt); + } + clear(); + }); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); await userRepositoryFixture.deleteByEmail(unrelatedUser.email); diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index e9290d4a6a..8967db206a 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -21,9 +21,11 @@ import { getAvailableSlots } from "@calcom/platform-libraries/slots"; import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types"; import { EventType } from "@calcom/prisma/client"; -const eventTypeMetadataSchema = z.object({ - multipleDuration: z.number().array().optional(), -}); +const eventTypeMetadataSchema = z + .object({ + multipleDuration: z.number().array().optional(), + }) + .nullable(); const DEFAULT_RESERVATION_DURATION = 5; @@ -89,17 +91,8 @@ export class SlotsService_2024_09_04 { throw new BadRequestException("Invalid start date"); } - const metadata = eventTypeMetadataSchema.parse(eventType); - if ( - input.slotDuration && - metadata.multipleDuration && - !metadata.multipleDuration.includes(input.slotDuration) - ) { - throw new BadRequestException( - `Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join( - ", " - )}` - ); + if (input.slotDuration) { + this.validateSlotDuration(eventType, input.slotDuration); } const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length }); @@ -160,6 +153,23 @@ export class SlotsService_2024_09_04 { return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration); } + validateSlotDuration(eventType: EventType, inputSlotDuration: number) { + const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata); + if (!eventTypeMetadata?.multipleDuration) { + throw new BadRequestException( + "You passed 'slotDuration' but this event type is not a variable length event type." + ); + } + + if (!eventTypeMetadata.multipleDuration.includes(inputSlotDuration)) { + throw new BadRequestException( + `Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${eventTypeMetadata.multipleDuration.join( + ", " + )}` + ); + } + } + async canSpecifyCustomReservationDuration(authUserId: number, eventType: EventType) { if (eventType.userId) { return await this.canSpecifyCustomReservationDurationIndividualEvent(authUserId, eventType.userId); @@ -214,17 +224,8 @@ export class SlotsService_2024_09_04 { throw new BadRequestException("Invalid start date"); } - const metadata = eventTypeMetadataSchema.parse(eventType); - if ( - input.slotDuration && - metadata.multipleDuration && - !metadata.multipleDuration.includes(input.slotDuration) - ) { - throw new BadRequestException( - `Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join( - ", " - )}` - ); + if (input.slotDuration) { + this.validateSlotDuration(eventType, input.slotDuration); } const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });