Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>(SlotsInputService_2024_09_04);
eventTypeRepository = module.get<EventTypesRepository_2024_06_14>(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,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +27,7 @@ export type InternalGetSlotsQuery = {
orgSlug: string | null | undefined;
rescheduleUid: string | null;
rrHostSubsetIds?: number[];
disableRollingWindowAdjustment?: boolean;
};

export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & {
Expand Down Expand Up @@ -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;

Expand All @@ -73,6 +74,7 @@ export class SlotsInputService_2024_09_04 {
orgSlug,
rescheduleUid,
rrHostSubsetIds: query.rrHostSubsetIds,
disableRollingWindowAdjustment: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep rolling-window bounds checks enabled for v2 slots

Setting disableRollingWindowAdjustment: true on every v2 request causes rolling-window period checks to use only the caller’s requested range, not the near-term dates that calculatePeriodLimits depends on to compute the true window end. For rolling-window event types, a request that starts in the future (for example, a 14-day window queried from day 20 onward) can now return slots outside the configured booking window because missing early dates are treated as non-bookable and push the computed end date forward. Previously, the one-month lookback mitigated this by including near-term dates in the computation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by removing disableRollingWindowAdjustment from the v2 slots path and shared slots input schema. The rolling-window one-month lookback is still enabled for bounds calculation, while v2 requests still default timeZone to UTC, so the existing final requested-range filter trims the response to the caller’s start/end range.

};
}

Expand Down
1 change: 1 addition & 0 deletions packages/trpc/server/routers/viewer/slots/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
38 changes: 35 additions & 3 deletions packages/trpc/server/routers/viewer/slots/util.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
34 changes: 27 additions & 7 deletions packages/trpc/server/routers/viewer/slots/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down
Loading