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:
co-authored by
Morgan
parent
b73a3aeb0c
commit
3a9e353443
+97
-12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user