fix: v2 slots reservation (#21623)

* feat: return team event type hosts username

* docs: team event types hosts username in return

* fix: ignore cancelled and rejected bookings from slot reservation

* fix: dont allow slot to overlap existing active booking

* refactor: remove log
This commit is contained in:
Lauris Skraucis
2025-05-29 16:25:33 +00:00
committed by GitHub
parent 3040e14624
commit c3add4bae8
3 changed files with 446 additions and 8 deletions
@@ -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);
@@ -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) {
@@ -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 },
});
}