From 3a9e353443a68db0bdb62e631007163aae965f0f Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Thu, 8 May 2025 14:39:33 +0200 Subject: [PATCH] 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> --- ...er-event-type-slots.controller.e2e-spec.ts | 109 ++++++++++++++++-- .../services/slots.service.ts | 18 +++ .../slots-2024-09-04/slots.repository.ts | 24 ++++ 3 files changed, 139 insertions(+), 12 deletions(-) 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 1918a8d7aa..9bd73e60c9 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 @@ -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", () => { 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 8967db206a..dccd6d9f4c 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 @@ -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(), 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 b0eecc3dad..e657546c2c 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 @@ -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,