diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.spec.ts new file mode 100644 index 00000000000000..9153fef496197d --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.spec.ts @@ -0,0 +1,98 @@ +import { Test } from "@nestjs/testing"; +import type { TestingModule } from "@nestjs/testing"; +import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { EventTypesRepository_2024_06_14 } from "@/platform/event-types/event-types_2024_06_14/event-types.repository"; + +jest.mock( + "@calcom/platform-libraries", + () => ({ + dynamicEvent: { + id: -1, + slug: "dynamic", + length: 30, + teamId: null, + }, + }), + { virtual: true } +); + +describe("SlotsInputService_2024_09_04", () => { + let service: SlotsInputService_2024_09_04; + let eventTypeRepository: EventTypesRepository_2024_06_14; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SlotsInputService_2024_09_04, + { + provide: EventTypesRepository_2024_06_14, + useValue: { + getEventTypeById: jest.fn(), + }, + }, + { + provide: UsersRepository, + useValue: {}, + }, + { + provide: TeamsRepository, + useValue: {}, + }, + { + provide: TeamsEventTypesRepository, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(SlotsInputService_2024_09_04); + eventTypeRepository = module.get(EventTypesRepository_2024_06_14); + + jest.clearAllMocks(); + }); + + it("defaults v2 slot requests to UTC without disabling rolling-window bounds checks", async () => { + (eventTypeRepository.getEventTypeById as jest.Mock).mockResolvedValue({ + id: 123, + slug: "discovery-call", + teamId: null, + }); + + const transformedQuery = await service.transformGetSlotsQuery({ + type: "byEventTypeId", + eventTypeId: 123, + start: "2050-12-09", + end: "2050-12-10", + }); + + expect(transformedQuery).toMatchObject({ + startTime: "2050-12-09T00:00:00.000Z", + endTime: "2050-12-10T23:59:59.000Z", + timeZone: "UTC", + }); + expect(transformedQuery).not.toHaveProperty("disableRollingWindowAdjustment"); + }); + + it("preserves an explicitly requested time zone", async () => { + (eventTypeRepository.getEventTypeById as jest.Mock).mockResolvedValue({ + id: 123, + slug: "discovery-call", + teamId: null, + }); + + await expect( + service.transformGetSlotsQuery({ + type: "byEventTypeId", + eventTypeId: 123, + start: "2050-12-09", + end: "2050-12-10", + timeZone: "Europe/Rome", + }) + ).resolves.toMatchObject({ + timeZone: "Europe/Rome", + }); + }); +}); 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 d1ecdc676dc7ec..534dbcf59b7872 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 @@ -10,10 +10,10 @@ import { } from "@calcom/platform-types"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { DateTime } from "luxon"; -import { EventTypesRepository_2024_06_14 } from "@/platform/event-types/event-types_2024_06_14/event-types.repository"; import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersRepository } from "@/modules/users/users.repository"; +import { EventTypesRepository_2024_06_14 } from "@/platform/event-types/event-types_2024_06_14/event-types.repository"; export type InternalGetSlotsQuery = { isTeamEvent: boolean; @@ -57,7 +57,7 @@ export class SlotsInputService_2024_09_04 { const eventTypeId = eventType.id; const eventTypeSlug = eventType.slug; const usernameList = "usernames" in query ? query.usernames : []; - const timeZone = query.timeZone; + const timeZone = query.timeZone ?? "UTC"; const orgSlug = "organizationSlug" in query ? query.organizationSlug : null; const rescheduleUid = query.bookingUidToReschedule || null; diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 1112a02f51bd2a..46770544bf8b3f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from "vitest"; - +import dayjs from "@calcom/dayjs"; import { BookingDateInPastError, isTimeOutOfBounds } from "@calcom/lib/isOutOfBounds"; - import { TRPCError } from "@trpc/server"; +import { describe, expect, it } from "vitest"; +import { getStartTimeForRollingWindowComputation } from "./util"; describe("BookingDateInPastError handling", () => { it("should convert BookingDateInPastError to TRPCError with BAD_REQUEST code", () => { @@ -43,3 +43,25 @@ describe("BookingDateInPastError handling", () => { expect(() => testFilteringLogic()).toThrow("Attempting to book a meeting in the past."); }); }); + +describe("getStartTimeForRollingWindowComputation", () => { + const requestedStartTime = "2050-12-09T00:00:00.000Z"; + + it("moves rolling window requests back one month by default", () => { + expect( + getStartTimeForRollingWindowComputation({ + startTime: requestedStartTime, + isRollingWindowPeriodType: true, + }) + ).toBe(dayjs(requestedStartTime).subtract(1, "month").toISOString()); + }); + + it("keeps the requested start time for non-rolling window requests", () => { + expect( + getStartTimeForRollingWindowComputation({ + startTime: requestedStartTime, + isRollingWindowPeriodType: false, + }) + ).toBe(requestedStartTime); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 842f97fea3c5c4..aa4286c9ebe6be 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -86,6 +86,22 @@ export type GetAvailableSlotsResponse = Awaited< ReturnType<(typeof AvailableSlotsService)["prototype"]["_getAvailableSlots"]> >; +export function getStartTimeForRollingWindowComputation({ + startTime, + isRollingWindowPeriodType, +}: { + startTime: string; + isRollingWindowPeriodType: boolean; +}): string { + const isStartTimeInPast = dayjs(startTime).isBefore(dayjs().subtract(1, "day").startOf("day")); + + if (isStartTimeInPast || !isRollingWindowPeriodType) { + return startTime; + } + + return dayjs(startTime).subtract(1, "month").toISOString(); +} + export interface IAvailableSlotsService { oooRepo: PrismaOOORepository; scheduleRepo: ScheduleRepository; @@ -569,7 +585,10 @@ export class AvailableSlotsService { const selectedDuration = (duration || eventType.length) ?? 0; - const { title: durationTitle, source: durationSource } = LimitSources.eventDurationLimit({ limit, unit }); + const { title: durationTitle, source: durationSource } = LimitSources.eventDurationLimit({ + limit, + unit, + }); if (selectedDuration > limit) { limitManager.addBusyTime({ @@ -925,16 +944,14 @@ export class AvailableSlotsService { } const isRollingWindowPeriodType = eventType.periodType === PeriodType.ROLLING_WINDOW; - const startTimeAsIsoString = input.startTime; - const isStartTimeInPast = dayjs(startTimeAsIsoString).isBefore(dayjs().subtract(1, "day").startOf("day")); // If startTime is already sent in the past, we don't need to adjust it. // We assume that the client is already sending startTime as per their requirement. // Note: We could optimize it further to go back 1 month in past only for the 2nd month because that is what we are putting a hard limit at. - const startTimeAdjustedForRollingWindowComputation = - isStartTimeInPast || !isRollingWindowPeriodType - ? startTimeAsIsoString - : dayjs(startTimeAsIsoString).subtract(1, "month").toISOString(); + const startTimeAdjustedForRollingWindowComputation = getStartTimeForRollingWindowComputation({ + startTime: input.startTime, + isRollingWindowPeriodType, + }); const loggerWithEventDetails = logger.getSubLogger({ type: "json",