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 9bd73e60c9..43cae7d6ed 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 @@ -1501,6 +1501,429 @@ describe("Slots 2024-09-04 Endpoints", () => { }); }); + describe("booking status", () => { + const startTime = "2050-09-12T11:00:00.000Z"; + const testBookingUid = `booking-uid-${eventTypeId}-booking-status-test`; + + describe("cant reserve", () => { + it("should not be able to reserve a slot if booking is accepted during that time", async () => { + const booking = await bookingsRepositoryFixture.create({ + status: "ACCEPTED", + uid: testBookingUid, + title: "booking title", + startTime, + endTime: "2050-09-12T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId, + slotStart: startTime, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual( + `Can't reserve a slot if the event is already booked.` + ); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should not be able to reserve a slot if booking is pending during that time", async () => { + const booking = await bookingsRepositoryFixture.create({ + status: "PENDING", + uid: testBookingUid, + title: "booking title", + startTime, + endTime: "2050-09-12T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId, + slotStart: startTime, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual( + `Can't reserve a slot if the event is already booked.` + ); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should not be able to reserve a slot if booking is awaiting host during that time", async () => { + const booking = await bookingsRepositoryFixture.create({ + status: "AWAITING_HOST", + uid: testBookingUid, + title: "booking title", + startTime, + endTime: "2050-09-12T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId, + slotStart: startTime, + }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual( + `Can't reserve a slot if the event is already booked.` + ); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + }); + + describe("can reserve", () => { + it("should be able to reserve a slot if booking is cancelled during that time", async () => { + const booking = await bookingsRepositoryFixture.create({ + status: "CANCELLED", + uid: testBookingUid, + title: "booking title", + startTime, + endTime: "2050-09-12T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId, + slotStart: startTime, + }) + .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(eventTypeId); + expect(responseReservedSlot.slotStart).toEqual(startTime); + expect(responseReservedSlot.slotDuration).toEqual(eventTypeLength); + expect(responseReservedSlot.slotEnd).toEqual( + DateTime.fromISO(startTime, { zone: "UTC" }).plus({ minutes: eventTypeLength }).toISO() + ); + expect(responseReservedSlot.reservationDuration).toEqual(5); + await bookingsRepositoryFixture.deleteById(booking.id); + await selectedSlotsRepositoryFixture.deleteByUId(responseReservedSlot.reservationUid); + }); + + it("should be able to reserve a slot if booking is rejected during that time", async () => { + const booking = await bookingsRepositoryFixture.create({ + status: "REJECTED", + uid: testBookingUid, + title: "booking title", + startTime, + endTime: "2050-09-12T12:00:00.000Z", + eventType: { + connect: { + id: eventTypeId, + }, + }, + metadata: {}, + responses: { + name: "tester", + email: "tester@example.com", + guests: [], + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ + eventTypeId, + slotStart: startTime, + }) + .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(eventTypeId); + expect(responseReservedSlot.slotStart).toEqual(startTime); + expect(responseReservedSlot.slotDuration).toEqual(eventTypeLength); + expect(responseReservedSlot.slotEnd).toEqual( + DateTime.fromISO(startTime, { zone: "UTC" }).plus({ minutes: eventTypeLength }).toISO() + ); + expect(responseReservedSlot.reservationDuration).toEqual(5); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + }); + }); + + describe("booking overlap", () => { + const T0901 = DateTime.fromISO("2055-01-01T10:00:00.000Z").minus({ minutes: 59 }).toISO(); + const T0930 = "2055-01-01T09:30:00.000Z"; + const T1000 = "2055-01-01T10:00:00.000Z"; + const T1015 = "2055-01-01T10:15:00.000Z"; + const T1029 = DateTime.fromISO("2055-01-01T10:30:00.000Z").minus({ minutes: 1 }).toISO(); + const T1030 = "2055-01-01T10:30:00.000Z"; + const T1045 = "2055-01-01T10:45:00.000Z"; + const T1100 = "2055-01-01T11:00:00.000Z"; + const T1130 = "2055-01-01T11:30:00.000Z"; + const T1200 = "2055-01-01T12:00:00.000Z"; + + const getBookingPayload = (startTime: string, endTime: string) => ({ + status: "ACCEPTED" as const, + uid: `test-booking-overlap-${randomString()}`, + title: "Overlap Test Booking", + startTime, + endTime, + eventType: { connect: { id: eventTypeId } }, + metadata: {}, + responses: { name: "Tester", email: `tester-overlap-${randomString()}@example.com`, guests: [] }, + user: { connect: { id: user.id } }, + }); + + const expectedErrorMessage = "Can't reserve a slot if the event is already booked."; + describe("slot overlaps booking", () => { + it("should fail if slot reservation (10:00-11:00) starts during an existing booking (09:30-10:30)", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1030)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1000 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (10:00-11:00) ends during an existing booking (10:30-11:30)", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1030, T1130)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1000 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (10:15-11:15) is entirely contained within an existing booking (10:00-11:30)", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1130)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1015 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (10:00-11:00) entirely contains an existing booking (10:15-10:45)", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1015, T1045)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1000 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (10:00-11:00) starts at the same time as an existing booking (10:00-11:00)", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1000 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (10:00-11:00) ends at the same time as an existing booking (09:30-11:00)", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1100)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1000 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (10:29-11:29) starts just before existing booking (09:30-10:30) ends", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1030)); + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: T1029 }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + + it("should fail if slot reservation (09:01-10:01) ends just after existing booking (10:00-11:00) starts", async () => { + const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100)); + const slotStartForEarlyOverlap = T0901; + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: slotStartForEarlyOverlap }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(422); + + expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage); + await bookingsRepositoryFixture.deleteById(booking.id); + }); + }); + + describe("slot does not overlap booking", () => { + it("should succeed if slot reservation (09:00-10:00) is immediately before an existing booking (10:00-11:00)", async () => { + const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100)); + const slotStartForNoOverlap = "2055-01-01T09:00:00.000Z"; + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: slotStartForNoOverlap }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + expect(reserveResponse.body.data.reservationUid).toBeDefined(); + + await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid); + await bookingsRepositoryFixture.deleteById(existingBooking.id); + }); + + it("should succeed if slot reservation (08:00-09:00) is before an existing booking (10:00-11:00)", async () => { + const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100)); + const slotStartForNoOverlap = "2055-01-01T08:00:00.000Z"; + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: slotStartForNoOverlap }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + expect(reserveResponse.body.data.reservationUid).toBeDefined(); + + await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid); + await bookingsRepositoryFixture.deleteById(existingBooking.id); + }); + + it("should succeed if slot reservation (11:00-12:00) is immediately after an existing booking (10:00-11:00)", async () => { + const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100)); + const slotStartForNoOverlap = T1100; + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: slotStartForNoOverlap }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + expect(reserveResponse.body.data.reservationUid).toBeDefined(); + await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid); + await bookingsRepositoryFixture.deleteById(existingBooking.id); + }); + + it("should succeed if slot reservation (12:00-13:00) is after an existing booking (10:00-11:00)", async () => { + const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100)); + const slotStartForNoOverlap = T1200; + + const reserveResponse = await request(app.getHttpServer()) + .post(`/v2/slots/reservations`) + .send({ eventTypeId, slotStart: slotStartForNoOverlap }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(201); + + expect(reserveResponse.body.data.reservationUid).toBeDefined(); + await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid); + await bookingsRepositoryFixture.deleteById(existingBooking.id); + }); + }); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); await userRepositoryFixture.deleteByEmail(oooTestUserEmail); 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 46c231303d..2f908ca934 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 @@ -19,7 +19,7 @@ import { z } from "zod"; 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"; +import { Booking, EventType } from "@calcom/prisma/client"; const eventTypeMetadataSchema = z .object({ @@ -109,9 +109,10 @@ export class SlotsService_2024_09_04 { throw new BadRequestException("Invalid end date"); } - const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart( + const booking = await this.slotsRepository.findActiveOverlappingBooking( input.eventTypeId, - startDate.toJSDate() + startDate.toJSDate(), + endDate.toJSDate() ); if (eventType.seatsPerTimeSlot) { @@ -258,9 +259,10 @@ export class SlotsService_2024_09_04 { throw new BadRequestException("Invalid end date"); } - const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart( + const booking = await this.slotsRepository.findActiveOverlappingBooking( input.eventTypeId, - startDate.toJSDate() + startDate.toJSDate(), + endDate.toJSDate() ); if (eventType.seatsPerTimeSlot) { diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/slots.repository.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/slots.repository.ts index e657546c2c..cc44b697e1 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/slots.repository.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/slots.repository.ts @@ -12,10 +12,23 @@ export class SlotsRepository_2024_09_04 { return this.dbRead.prisma.selectedSlots.findFirst({ where: { uid } }); } - async getBookingWithAttendeesByEventTypeIdAndStart(eventTypeId: number, startTime: Date) { + async findActiveOverlappingBooking(eventTypeId: number, slotStartTime: Date, slotEndTime: Date) { return this.dbRead.prisma.booking.findFirst({ - where: { eventTypeId, startTime }, - select: { attendees: true }, + where: { + eventTypeId, + startTime: { + // note(Lauris): booking starts before the potential slot reservation ends. + lt: slotEndTime, + }, + endTime: { + // note(Lauris): booking ends after the potential slot reservation begins. + gt: slotStartTime, + }, + status: { + in: ["ACCEPTED", "PENDING", "AWAITING_HOST"], + }, + }, + select: { attendees: true, status: true }, }); }