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:
+423
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user