fix: don't allow double reserving already reserved slot (#21139)

* feat: prevent double reserving slots

* tests

* remove log

* logs

* refactor: releaseAt utc check

* fix: check overlap when updating slots

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2025-05-08 14:39:33 +02:00
committed by GitHub
co-authored by Morgan
parent b73a3aeb0c
commit 3a9e353443
3 changed files with 139 additions and 12 deletions
@@ -181,7 +181,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
title: "frisbee match",
slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`,
length: 15,
metadata: { multipleDuration: [15, 30, 45, 60] },
metadata: { multipleDuration: [15, 30, 45, 60, 180] },
},
user.id
);
@@ -506,6 +506,65 @@ describe("Slots 2024-09-04 Endpoints", () => {
expect(reserveResponseBody.data).toEqual(rest);
});
describe("overlapping slot reservations", () => {
it("start of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 10:15-11:15 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("end of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 9:45-10:45 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).minus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("request slot is inside already existing reserved slot", async () => {
// Try to reserve 10:10-11:10 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 10 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
it("should update a reserved slot and it should not appear in available slots", async () => {
// note(Lauris): mock current date to test slots release time
const now = "2049-09-05T14:00:00.000Z";
@@ -1266,6 +1325,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
describe("variable length", () => {
let responseReservedVariableSlot: ReserveSlotOutputData_2024_09_04;
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())
@@ -1279,7 +1339,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
.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"
"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, 180"
);
});
@@ -1303,17 +1363,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
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(
responseReservedVariableSlot = reserveResponseBody.data;
expect(responseReservedVariableSlot.reservationUid).toBeDefined();
expect(responseReservedVariableSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedVariableSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedVariableSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedVariableSlot.slotEnd).toEqual(
DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
expect(responseReservedVariableSlot.reservationDuration).toEqual(5);
if (!responseReservedSlot.reservationUid) {
if (!responseReservedVariableSlot.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
@@ -1337,16 +1397,41 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(
responseReservedVariableSlot.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);
expect(responseReservedVariableSlot.reservationUntil).toEqual(expectedReleaseAt);
}
clear();
});
it("request slot contains already existing reserved slot", async () => {
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
.minus({ minutes: 15 })
.toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: variableLengthEventType.id,
slotStart: newSlotStart,
slotDuration: 180,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.set("Authorization", `Bearer cal_test_${apiKeyString}`)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
describe("out of office", () => {
@@ -124,6 +124,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
eventType.userId,
@@ -153,6 +155,20 @@ export class SlotsService_2024_09_04 {
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
private async checkSlotOverlap(eventTypeId: number, startDate: string, endDate: string) {
const overlappingReservation = await this.slotsRepository.getOverlappingSlotReservation(
eventTypeId,
startDate,
endDate
);
if (overlappingReservation) {
throw new UnprocessableEntityException(
`This time slot is already reserved by another user. Please choose a different time.`
);
}
}
validateSlotDuration(eventType: EventType, inputSlotDuration: number) {
const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata);
if (!eventTypeMetadata?.multipleDuration) {
@@ -257,6 +273,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const slot = await this.slotsRepository.updateSlot(
eventType.id,
startDate.toISO(),
@@ -19,6 +19,30 @@ export class SlotsRepository_2024_09_04 {
});
}
async getOverlappingSlotReservation(eventTypeId: number, startDate: string, endDate: string) {
return this.dbRead.prisma.selectedSlots.findFirst({
where: {
eventTypeId,
AND: [
{
OR: [
// Case 1: New slot starts during an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gt: startDate } },
// Case 2: New slot ends during an existing slot
{ slotUtcStartDate: { lt: endDate }, slotUtcEndDate: { gte: endDate } },
// Case 3: New slot is completely inside an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gte: endDate } },
// Case 4: New slot completely overlaps an existing slot
{ slotUtcStartDate: { gte: startDate }, slotUtcEndDate: { lte: endDate } },
],
},
// Only consider non-expired reservations
{ releaseAt: { gt: DateTime.utc().toJSDate() } },
],
},
});
}
async createSlot(
userId: number,
eventTypeId: number,