From 0965fad72a035800d21bd669704bdddb48e4ff95 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Wed, 2 Apr 2025 16:53:52 +0200 Subject: [PATCH] fix: v2 filter out ooo days out of slots (#20303) * refactor: slots input service rely on eventTypeId and set start hours if none set * fix: filter out ooo slots * test: ooo days filtered out of slots * revert changes --- ...er-event-type-slots.controller.e2e-spec.ts | 73 +++++++++ .../services/slots-input.service.ts | 16 +- .../services/slots-output.service.ts | 146 +++++++++--------- .../repository/ooo.repository.fixture.ts | 32 ++++ 4 files changed, 196 insertions(+), 71 deletions(-) create mode 100644 apps/api/v2/test/fixtures/repository/ooo.repository.fixture.ts 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 df802b40d2..1918a8d7aa 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 @@ -29,6 +29,7 @@ import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-s import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OOORepositoryFixture } from "test/fixtures/repository/ooo.repository.fixture"; import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -56,6 +57,7 @@ describe("Slots 2024-09-04 Endpoints", () => { let apiKeyString: string; let membershipsRepositoryFixture: MembershipRepositoryFixture; let teamRepositoryFixture: TeamRepositoryFixture; + let oooRepositoryFixture: OOORepositoryFixture; const userEmail = `slots-2024-09-04-user-${randomString()}@example.com`; let user: User; @@ -80,6 +82,8 @@ describe("Slots 2024-09-04 Endpoints", () => { let reservedSlot: ReserveSlotOutputData_2024_09_04; + const oooTestUserEmail = `oooTestUser-${randomString()}@cal.com`; + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -107,6 +111,7 @@ describe("Slots 2024-09-04 Endpoints", () => { apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + oooRepositoryFixture = new OOORepositoryFixture(moduleRef); user = await userRepositoryFixture.create({ email: userEmail, @@ -1344,8 +1349,76 @@ describe("Slots 2024-09-04 Endpoints", () => { }); }); + describe("out of office", () => { + let oooTestUser: User; + let oooTestUserEventType: EventType; + + let oooEntryId: number; + + beforeAll(async () => { + oooTestUser = await userRepositoryFixture.create({ + email: oooTestUserEmail, + name: oooTestUserEmail, + username: oooTestUserEmail, + }); + + const oooUserSchedule: CreateScheduleInput_2024_06_11 = { + name: `slots-2024-09-04-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + // note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone + await schedulesService.createUserSchedule(oooTestUser.id, oooUserSchedule); + + const event = await eventTypesRepositoryFixture.create( + { title: "frisbee match", slug: `slots-2024-09-04-event-type-${randomString()}`, length: 60 }, + oooTestUser.id + ); + oooTestUserEventType = event; + }); + + it("should not returns slots for ooo days", async () => { + const oooStart = new Date("2050-09-06T00:00:00.000Z"); + const oooEnd = new Date("2050-09-09T23:59:59.999Z"); + + const oooEntry = await oooRepositoryFixture.create({ + uuid: randomString(), + start: oooStart, + end: oooEnd, + user: { connect: { id: oooTestUser.id } }, + toUser: { connect: { id: oooTestUser.id } }, + createdAt: new Date(), + reason: { + connect: { + id: 1, + }, + }, + }); + oooEntryId = oooEntry.id; + + const response = await request(app.getHttpServer()) + .get(`/v2/slots?eventTypeId=${oooTestUserEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(200); + + const responseBody: GetSlotsOutput_2024_09_04 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots); + expect(days.length).toBe(1); + expect(slots).toEqual({ + "2050-09-05": expectedSlotsUTC["2050-09-05"], + }); + + await oooRepositoryFixture.delete(oooEntryId); + }); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(oooTestUserEmail); await userRepositoryFixture.deleteByEmail(unrelatedUser.email); await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid); await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts index 6bfa47d322..b54dc4e172 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts @@ -24,7 +24,7 @@ export class SlotsInputService_2024_09_04 { } const isTeamEvent = !!eventType?.teamId; - const startTime = query.start; + const startTime = this.adjustStartTime(query.start); const endTime = this.adjustEndTime(query.end); const duration = query.duration; const eventTypeId = eventType.id; @@ -82,6 +82,20 @@ export class SlotsInputService_2024_09_04 { } } + private adjustStartTime(startTime: string) { + let dateTime = DateTime.fromISO(startTime, { zone: "utc" }); + if (dateTime.hour === 0 && dateTime.minute === 0 && dateTime.second === 0) { + dateTime = dateTime.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + } + + const ISOStartTime = dateTime.toISO(); + if (ISOStartTime === null) { + throw new BadRequestException("Invalid start date"); + } + + return ISOStartTime; + } + private adjustEndTime(endTime: string) { let dateTime = DateTime.fromISO(endTime, { zone: "utc" }); if (dateTime.hour === 0 && dateTime.minute === 0 && dateTime.second === 0) { diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-output.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-output.service.ts index 9c5cc8b57c..cf0fdbb500 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-output.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-output.service.ts @@ -16,7 +16,7 @@ import { import { SelectedSlots } from "@calcom/prisma/client"; type GetAvailableSlots = { - slots: Record; + slots: Record; }; @Injectable() @@ -46,34 +46,37 @@ export class SlotsOutputService_2024_09_04 { const slots: { [key: string]: (Slot_2024_09_04 | SeatedSlot_2024_09_04)[] } = {}; for (const date in availableSlots.slots) { - slots[date] = availableSlots.slots[date].map((slot) => { - if (!timeZone) { + const availableTimeSlots = availableSlots.slots[date].filter((slot) => !slot.away); + if (availableTimeSlots.length > 0) { + slots[date] = availableTimeSlots.map((slot) => { + if (!timeZone) { + if (!eventType?.seatsPerTimeSlot) { + return this.getAvailableTimeSlot(slot.time); + } + return this.getAvailableTimeSlotSeated( + slot.time, + slot.attendees || 0, + eventType.seatsPerTimeSlot || 0, + slot.bookingUid + ); + } + const slotTimezoneAdjusted = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO(); + if (!slotTimezoneAdjusted) { + throw new BadRequestException( + `Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}` + ); + } if (!eventType?.seatsPerTimeSlot) { - return this.getAvailableTimeSlot(slot.time); + return this.getAvailableTimeSlot(slotTimezoneAdjusted); } return this.getAvailableTimeSlotSeated( - slot.time, + slotTimezoneAdjusted, slot.attendees || 0, eventType.seatsPerTimeSlot || 0, slot.bookingUid ); - } - const slotTimezoneAdjusted = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO(); - if (!slotTimezoneAdjusted) { - throw new BadRequestException( - `Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}` - ); - } - if (!eventType?.seatsPerTimeSlot) { - return this.getAvailableTimeSlot(slotTimezoneAdjusted); - } - return this.getAvailableTimeSlotSeated( - slotTimezoneAdjusted, - slot.attendees || 0, - eventType.seatsPerTimeSlot || 0, - slot.bookingUid - ); - }); + }); + } } return slots; @@ -113,56 +116,59 @@ export class SlotsOutputService_2024_09_04 { const slots = Object.entries(availableSlots.slots).reduce< Record >((acc, [date, slots]) => { - acc[date] = slots.map((slot) => { - if (timeZone) { - const start = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO(); - if (!start) { - throw new BadRequestException( - `Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}` + const availableTimeSlots = slots.filter((slot) => !slot.away); + if (availableTimeSlots.length > 0) { + acc[date] = availableTimeSlots.map((slot) => { + if (timeZone) { + const start = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO(); + if (!start) { + throw new BadRequestException( + `Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}` + ); + } + + const end = DateTime.fromISO(slot.time, { zone: "utc" }) + .plus({ minutes: slotDuration }) + .setZone(timeZone) + .toISO(); + + if (!end) { + throw new BadRequestException( + `Could not adjust timezone for slot end time ${slot.time} with timezone ${timeZone}` + ); + } + + if (!eventType?.seatsPerTimeSlot) { + return this.getAvailableRangeSlot(start, end); + } + return this.getAvailableRangeSlotSeated( + start, + end, + slot.attendees || 0, + eventType.seatsPerTimeSlot ?? undefined, + slot.bookingUid + ); + } else { + const start = DateTime.fromISO(slot.time, { zone: "utc" }).toISO(); + const end = DateTime.fromISO(slot.time, { zone: "utc" }).plus({ minutes: slotDuration }).toISO(); + + if (!start || !end) { + throw new BadRequestException(`Could not create UTC time for slot ${slot.time}`); + } + + if (!eventType?.seatsPerTimeSlot) { + return this.getAvailableRangeSlot(start, end); + } + return this.getAvailableRangeSlotSeated( + start, + end, + slot.attendees || 0, + eventType.seatsPerTimeSlot ?? undefined, + slot.bookingUid ); } - - const end = DateTime.fromISO(slot.time, { zone: "utc" }) - .plus({ minutes: slotDuration }) - .setZone(timeZone) - .toISO(); - - if (!end) { - throw new BadRequestException( - `Could not adjust timezone for slot end time ${slot.time} with timezone ${timeZone}` - ); - } - - if (!eventType?.seatsPerTimeSlot) { - return this.getAvailableRangeSlot(start, end); - } - return this.getAvailableRangeSlotSeated( - start, - end, - slot.attendees || 0, - eventType.seatsPerTimeSlot ?? undefined, - slot.bookingUid - ); - } else { - const start = DateTime.fromISO(slot.time, { zone: "utc" }).toISO(); - const end = DateTime.fromISO(slot.time, { zone: "utc" }).plus({ minutes: slotDuration }).toISO(); - - if (!start || !end) { - throw new BadRequestException(`Could not create UTC time for slot ${slot.time}`); - } - - if (!eventType?.seatsPerTimeSlot) { - return this.getAvailableRangeSlot(start, end); - } - return this.getAvailableRangeSlotSeated( - start, - end, - slot.attendees || 0, - eventType.seatsPerTimeSlot ?? undefined, - slot.bookingUid - ); - } - }); + }); + } return acc; }, {}); diff --git a/apps/api/v2/test/fixtures/repository/ooo.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/ooo.repository.fixture.ts new file mode 100644 index 0000000000..f560466186 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/ooo.repository.fixture.ts @@ -0,0 +1,32 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Prisma } from "@prisma/client"; + +import { CreateOAuthClientInput } from "@calcom/platform-types"; + +export class OOORepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.OutOfOfficeEntryCreateInput) { + return this.prismaWriteClient.outOfOfficeEntry.create({ + data: { + ...data, + }, + }); + } + + async delete(id: number) { + return this.prismaWriteClient.outOfOfficeEntry.delete({ + where: { + id, + }, + }); + } +}