From 48c9bbcb1ff3da82ec6456fef9a817015da61872 Mon Sep 17 00:00:00 2001 From: gfernandez-me Date: Mon, 20 Apr 2026 05:52:55 -0400 Subject: [PATCH 1/2] feat(slots): implement disableRollingWindowAdjustment feature and add unit tests - Added `disableRollingWindowAdjustment` option to `SlotsInputService_2024_09_04` to control rolling window adjustments. - Updated `transformGetSlotsQuery` method to respect the new option. - Created unit tests for `SlotsInputService_2024_09_04` to verify behavior with and without rolling window adjustments. - Enhanced `getStartTimeForRollingWindowComputation` utility to handle the new option. - Updated relevant types in `trpc` to include the new option. --- .../services/slots-input.service.spec.ts | 99 +++++++++++++++++++ .../services/slots-input.service.ts | 6 +- .../trpc/server/routers/viewer/slots/types.ts | 1 + .../server/routers/viewer/slots/util.test.ts | 38 ++++++- .../trpc/server/routers/viewer/slots/util.ts | 34 +++++-- 5 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.spec.ts 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..31068b2c7b12a0 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.spec.ts @@ -0,0 +1,99 @@ +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 and disables rolling window start adjustment", 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", + }) + ).resolves.toMatchObject({ + startTime: "2050-12-09T00:00:00.000Z", + endTime: "2050-12-10T23:59:59.000Z", + timeZone: "UTC", + disableRollingWindowAdjustment: true, + }); + }); + + 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", + disableRollingWindowAdjustment: true, + }); + }); +}); 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..27d3972614cdc9 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; @@ -27,6 +27,7 @@ export type InternalGetSlotsQuery = { orgSlug: string | null | undefined; rescheduleUid: string | null; rrHostSubsetIds?: number[]; + disableRollingWindowAdjustment?: boolean; }; export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & { @@ -57,7 +58,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; @@ -73,6 +74,7 @@ export class SlotsInputService_2024_09_04 { orgSlug, rescheduleUid, rrHostSubsetIds: query.rrHostSubsetIds, + disableRollingWindowAdjustment: true, }; } diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 58631316ea16c3..d71117b970dd91 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -36,6 +36,7 @@ export const getScheduleSchemaObject = z.object({ _enableTroubleshooter: z.boolean().optional(), _bypassCalendarBusyTimes: z.boolean().optional(), _silentCalendarFailures: z.boolean().optional(), + disableRollingWindowAdjustment: z.boolean().optional(), queuedFormResponseId: z.string().nullish(), email: z.string().nullish(), }); diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 1112a02f51bd2a..9ca780da37c89e 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,35 @@ 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 when rolling window adjustment is disabled", () => { + expect( + getStartTimeForRollingWindowComputation({ + startTime: requestedStartTime, + isRollingWindowPeriodType: true, + disableRollingWindowAdjustment: true, + }) + ).toBe(requestedStartTime); + }); + + 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..2e128be92e60c2 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -86,6 +86,24 @@ export type GetAvailableSlotsResponse = Awaited< ReturnType<(typeof AvailableSlotsService)["prototype"]["_getAvailableSlots"]> >; +export function getStartTimeForRollingWindowComputation({ + startTime, + isRollingWindowPeriodType, + disableRollingWindowAdjustment, +}: { + startTime: string; + isRollingWindowPeriodType: boolean; + disableRollingWindowAdjustment?: boolean; +}): string { + const isStartTimeInPast = dayjs(startTime).isBefore(dayjs().subtract(1, "day").startOf("day")); + + if (isStartTimeInPast || !isRollingWindowPeriodType || disableRollingWindowAdjustment) { + return startTime; + } + + return dayjs(startTime).subtract(1, "month").toISOString(); +} + export interface IAvailableSlotsService { oooRepo: PrismaOOORepository; scheduleRepo: ScheduleRepository; @@ -569,7 +587,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 +946,15 @@ 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, + disableRollingWindowAdjustment: input.disableRollingWindowAdjustment, + }); const loggerWithEventDetails = logger.getSubLogger({ type: "json", From 623c965d26dafa6b932b6a8006535eb76702f4db Mon Sep 17 00:00:00 2001 From: gfernandez-me Date: Mon, 20 Apr 2026 06:24:22 -0400 Subject: [PATCH 2/2] refactor(slots): remove disableRollingWindowAdjustment from slots input service - Updated `SlotsInputService_2024_09_04` to no longer set `disableRollingWindowAdjustment` by default. - Adjusted unit tests to reflect the change, ensuring that rolling window bounds checks are preserved. - Removed references to `disableRollingWindowAdjustment` from relevant types and utility functions. --- .../services/slots-input.service.spec.ts | 21 +++++++++---------- .../services/slots-input.service.ts | 2 -- .../trpc/server/routers/viewer/slots/types.ts | 1 - .../server/routers/viewer/slots/util.test.ts | 10 --------- .../trpc/server/routers/viewer/slots/util.ts | 5 +---- 5 files changed, 11 insertions(+), 28 deletions(-) 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 index 31068b2c7b12a0..9153fef496197d 100644 --- 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 @@ -54,26 +54,26 @@ describe("SlotsInputService_2024_09_04", () => { jest.clearAllMocks(); }); - it("defaults v2 slot requests to UTC and disables rolling window start adjustment", async () => { + 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, }); - await expect( - service.transformGetSlotsQuery({ - type: "byEventTypeId", - eventTypeId: 123, - start: "2050-12-09", - end: "2050-12-10", - }) - ).resolves.toMatchObject({ + 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", - disableRollingWindowAdjustment: true, }); + expect(transformedQuery).not.toHaveProperty("disableRollingWindowAdjustment"); }); it("preserves an explicitly requested time zone", async () => { @@ -93,7 +93,6 @@ describe("SlotsInputService_2024_09_04", () => { }) ).resolves.toMatchObject({ timeZone: "Europe/Rome", - disableRollingWindowAdjustment: true, }); }); }); 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 27d3972614cdc9..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 @@ -27,7 +27,6 @@ export type InternalGetSlotsQuery = { orgSlug: string | null | undefined; rescheduleUid: string | null; rrHostSubsetIds?: number[]; - disableRollingWindowAdjustment?: boolean; }; export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & { @@ -74,7 +73,6 @@ export class SlotsInputService_2024_09_04 { orgSlug, rescheduleUid, rrHostSubsetIds: query.rrHostSubsetIds, - disableRollingWindowAdjustment: true, }; } diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index d71117b970dd91..58631316ea16c3 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -36,7 +36,6 @@ export const getScheduleSchemaObject = z.object({ _enableTroubleshooter: z.boolean().optional(), _bypassCalendarBusyTimes: z.boolean().optional(), _silentCalendarFailures: z.boolean().optional(), - disableRollingWindowAdjustment: z.boolean().optional(), queuedFormResponseId: z.string().nullish(), email: z.string().nullish(), }); diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 9ca780da37c89e..46770544bf8b3f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -56,16 +56,6 @@ describe("getStartTimeForRollingWindowComputation", () => { ).toBe(dayjs(requestedStartTime).subtract(1, "month").toISOString()); }); - it("keeps the requested start time when rolling window adjustment is disabled", () => { - expect( - getStartTimeForRollingWindowComputation({ - startTime: requestedStartTime, - isRollingWindowPeriodType: true, - disableRollingWindowAdjustment: true, - }) - ).toBe(requestedStartTime); - }); - it("keeps the requested start time for non-rolling window requests", () => { expect( getStartTimeForRollingWindowComputation({ diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 2e128be92e60c2..aa4286c9ebe6be 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -89,15 +89,13 @@ export type GetAvailableSlotsResponse = Awaited< export function getStartTimeForRollingWindowComputation({ startTime, isRollingWindowPeriodType, - disableRollingWindowAdjustment, }: { startTime: string; isRollingWindowPeriodType: boolean; - disableRollingWindowAdjustment?: boolean; }): string { const isStartTimeInPast = dayjs(startTime).isBefore(dayjs().subtract(1, "day").startOf("day")); - if (isStartTimeInPast || !isRollingWindowPeriodType || disableRollingWindowAdjustment) { + if (isStartTimeInPast || !isRollingWindowPeriodType) { return startTime; } @@ -953,7 +951,6 @@ export class AvailableSlotsService { const startTimeAdjustedForRollingWindowComputation = getStartTimeForRollingWindowComputation({ startTime: input.startTime, isRollingWindowPeriodType, - disableRollingWindowAdjustment: input.disableRollingWindowAdjustment, }); const loggerWithEventDetails = logger.getSubLogger({