feat: Future Limit - Toggle to exclude unavailable days (#14915)

* wip

* wip

* wip

* Add tests

* Update getSchedule.test.ts

* Update types.ts

* Self review fixes

* Update text

* PR feedback from Carina

* Use utcoffset for range as well

* Remove logging from the function that runs on every timeslot

* Add one more test

* Show x days instead of x+1 days for both ROLLING and ROLLING_WINDOW

* refactor tests

* Move bookingScenario imports to the top as they import prismock mock that has to be imported very early

* More reordering of imports

* Fix accidental min update at wrong place

* Handle legacy value of zero for periodDays

* Range fix

* Fix isDateOutOfBound not being checked properly during booking. Also added a test for the case

* Fix duplicate element, how the hell it reached there

* Use today plus x days for ROLLING periodType

* Add disabled for Checkbox as well

* Revert logger.ts

* Formatting

Signed-off-by: zomars <zomars@me.com>

* Fix ordering of imports that is causing tests failure

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2024-05-20 13:41:07 +01:00
committed by GitHub
co-authored by Alex van Andel Omar López Udit Takkar
parent b67d37fac7
commit 56022a1ba8
34 changed files with 1618 additions and 428 deletions
+219 -111
View File
@@ -3,7 +3,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import type { Key } from "react";
import React, { useEffect, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import type { SingleValue } from "react-select";
@@ -11,15 +11,193 @@ import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hook
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "@calcom/lib/constants";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
import findDurationType from "@calcom/lib/findDurationType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import type { PeriodType } from "@calcom/prisma/enums";
import { PeriodType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
import CheckboxField from "@components/ui/form/CheckboxField";
type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType];
/**
* We technically have a ROLLING_WINDOW future limit option that isn't shown as a Radio Option. Because UX is better by providing it as a toggle with ROLLING Limit radio option.
* Also, ROLLING_WINDOW reuses the same `periodDays` field and `periodCountCalendarDays` fields
*
* So we consider `periodType=ROLLING && rollingExcludeUnavailableDays=true` to be the ROLLING_WINDOW option
* We can't set `periodType=ROLLING_WINDOW` directly because it is not a valid Radio Option in UI
* So, here we can convert from periodType to uiValue any time.
*/
const getUiValueFromPeriodType = (periodType: PeriodType) => {
if (periodType === PeriodType.ROLLING_WINDOW) {
return {
value: PeriodType.ROLLING,
rollingExcludeUnavailableDays: true,
};
}
if (periodType === PeriodType.ROLLING) {
return {
value: PeriodType.ROLLING,
rollingExcludeUnavailableDays: false,
};
}
return {
value: periodType,
rollingExcludeUnavailableDays: null,
};
};
/**
* It compliments `getUiValueFromPeriodType`
*/
const getPeriodTypeFromUiValue = (uiValue: { value: PeriodType; rollingExcludeUnavailableDays: boolean }) => {
if (uiValue.value === PeriodType.ROLLING && uiValue.rollingExcludeUnavailableDays === true) {
return PeriodType.ROLLING_WINDOW;
}
return uiValue.value;
};
function RangeLimitRadioItem({
isDisabled,
formMethods,
radioValue,
}: {
radioValue: string;
isDisabled: boolean;
formMethods: UseFormReturn<FormValues>;
}) {
const { t } = useLocale();
return (
<div className={classNames("text-default mb-2 flex flex-wrap items-center text-sm")}>
{!isDisabled && (
<RadioGroup.Item
id={radioValue}
value={radioValue}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
<div>
<span>{t("within_date_range")}&nbsp;</span>
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
render={({ field: { onChange } }) => (
<DateRangePicker
dates={{
startDate: formMethods.getValues("periodDates").startDate,
endDate: formMethods.getValues("periodDates").endDate,
}}
disabled={isDisabled}
onDatesChange={({ startDate, endDate }) => {
onChange({
startDate,
endDate,
});
}}
/>
)}
/>
</div>
</div>
</div>
);
}
function RollingLimitRadioItem({
radioValue,
isDisabled,
formMethods,
onChange,
rollingExcludeUnavailableDays,
}: {
radioValue: IPeriodType;
isDisabled: boolean;
formMethods: UseFormReturn<FormValues>;
onChange: (opt: { value: number } | null) => void;
rollingExcludeUnavailableDays: boolean;
}) {
const { t } = useLocale();
const options = [
{ value: 0, label: t("business_days") },
{ value: 1, label: t("calendar_days") },
];
const getSelectedOption = () =>
options.find((opt) => opt.value === (formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0));
const periodDaysWatch = formMethods.watch("periodDays");
return (
<div className={classNames("text-default mb-2 flex flex-wrap items-baseline text-sm")}>
{!isDisabled && (
<RadioGroup.Item
id={radioValue}
value={radioValue}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
<div>
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={isDisabled}
min={0}
max={rollingExcludeUnavailableDays ? ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK : undefined}
{...formMethods.register("periodDays", { valueAsNumber: true })}
/>
<Select
options={options}
isSearchable={false}
isDisabled={isDisabled}
onChange={onChange}
name="periodCoundCalendarDays"
value={getSelectedOption()}
defaultValue={getSelectedOption()}
/>
<span className="me-2 ms-2">&nbsp;{t("into_the_future")}</span>
</div>
<div className="py-2">
<CheckboxField
checked={!!rollingExcludeUnavailableDays}
disabled={isDisabled}
description={t("always_show_x_days", {
x: periodDaysWatch,
})}
onChange={(e) => {
const isChecked = e.target.checked;
formMethods.setValue(
"periodDays",
Math.min(periodDaysWatch, ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK)
);
formMethods.setValue(
"periodType",
getPeriodTypeFromUiValue({
value: PeriodType.ROLLING,
rollingExcludeUnavailableDays: isChecked,
}),
{ shouldDirty: true }
);
}}
/>
</div>
</div>
</div>
);
}
const MinimumBookingNoticeInput = React.forwardRef<
HTMLInputElement,
Omit<UseFormRegisterReturn<"minimumBookingNotice">, "ref">
@@ -114,23 +292,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const { t, i18n } = useLocale();
const formMethods = useFormContext<FormValues>();
const PERIOD_TYPES = [
{
type: "ROLLING" as const,
suffix: t("into_the_future"),
},
{
type: "RANGE" as const,
prefix: t("within_date_range"),
},
{
type: "UNLIMITED" as const,
prefix: t("indefinitely_into_future"),
},
];
const watchPeriodType = formMethods.watch("periodType");
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager({
eventType,
translate: t,
@@ -143,11 +304,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 0, label: t("business_days") },
{ value: 1, label: t("calendar_days") },
];
const [offsetToggle, setOffsetToggle] = useState(formMethods.getValues("offsetStart") > 0);
// Preview how the offset will affect start times
@@ -389,6 +545,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
render={({ field: { onChange, value } }) => {
const isChecked = value && value !== "UNLIMITED";
const { value: watchPeriodTypeUiValue, rollingExcludeUnavailableDays } = getUiValueFromPeriodType(
formMethods.watch("periodType")
);
return (
<SettingsToggle
labelClassName="text-sm"
@@ -402,99 +562,47 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={isChecked}
onCheckedChange={(bool) => {
if (bool && !formMethods.getValues("periodDays")) {
onCheckedChange={(isEnabled) => {
if (isEnabled && !formMethods.getValues("periodDays")) {
formMethods.setValue("periodDays", 30, { shouldDirty: true });
}
return onChange(bool ? "ROLLING" : "UNLIMITED");
return onChange(isEnabled ? PeriodType.ROLLING : PeriodType.UNLIMITED);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
value={watchPeriodType}
onValueChange={(val) =>
formMethods.setValue("periodType", val as PeriodType, { shouldDirty: true })
}>
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
className={classNames(
"text-default mb-2 flex flex-wrap items-center text-sm",
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value === 1 ? true : false,
{ shouldDirty: true }
);
}}
name="periodCoundCalendarDays"
value={optionsPeriod.find((opt) => {
opt.value ===
(formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0);
})}
defaultValue={optionsPeriod.find(
(opt) =>
opt.value ===
(formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0)
)}
/>
</div>
)}
{period.type === "RANGE" && (
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
render={({ field: { onChange } }) => (
<DateRangePicker
dates={{
startDate: formMethods.getValues("periodDates").startDate,
endDate: formMethods.getValues("periodDates").endDate,
}}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
onChange({
startDate,
endDate,
});
}}
/>
)}
/>
</div>
)}
{period.suffix ? <span className="me-2 ms-2">&nbsp;{period.suffix}</span> : null}
</div>
value={watchPeriodTypeUiValue}
onValueChange={(val) => {
formMethods.setValue(
"periodType",
getPeriodTypeFromUiValue({
value: val as IPeriodType,
rollingExcludeUnavailableDays: formMethods.getValues("rollingExcludeUnavailableDays"),
}),
{
shouldDirty: true,
}
);
})}
}}>
{(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.ROLLING : true) && (
<RollingLimitRadioItem
rollingExcludeUnavailableDays={!!rollingExcludeUnavailableDays}
radioValue={PeriodType.ROLLING}
isDisabled={periodTypeLocked.disabled}
formMethods={formMethods}
onChange={(opt) => {
formMethods.setValue("periodCountCalendarDays", opt?.value === 1, {
shouldDirty: true,
});
}}
/>
)}
{(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.RANGE : true) && (
<RangeLimitRadioItem
radioValue={PeriodType.RANGE}
isDisabled={periodTypeLocked.disabled}
formMethods={formMethods}
/>
)}
</RadioGroup.Root>
</div>
</SettingsToggle>
@@ -2441,6 +2441,7 @@
"csat_score": "CSAT Score",
"lockedSMS": "Locked SMS",
"signing_up_terms": "By proceeding, you agree to our <1>Terms</1> and <2>Privacy Policy</2>.",
"always_show_x_days": "Always {{x}} days available",
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
"updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later",
"creating_oauth_client_error": "An error occurred while creating the OAuth client, please try again later",
@@ -1,12 +1,5 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import type { TRequestRescheduleInputSchema } from "@calcom/trpc/server/routers/viewer/bookings/requestReschedule.schema";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { getSampleUserInSession } from "../utils/bookingScenario/getSampleUserInSession";
import { setupAndTeardown } from "../utils/bookingScenario/setupAndTeardown";
import {
createBookingScenario,
getGoogleCalendarCredential,
@@ -19,8 +12,15 @@ import {
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { expectBookingRequestRescheduledEmails } from "@calcom/web/test/utils/bookingScenario/expects";
import { getSampleUserInSession } from "../utils/bookingScenario/getSampleUserInSession";
import { setupAndTeardown } from "../utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import type { TRequestRescheduleInputSchema } from "@calcom/trpc/server/routers/viewer/bookings/requestReschedule.schema";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
+34 -175
View File
@@ -1,13 +1,4 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { diff } from "jest-diff";
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
import dayjs from "@calcom/dayjs";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import {
getDate,
@@ -16,157 +7,28 @@ import {
createOrganization,
getOrganizer,
getScenarioData,
Timezones,
TestData,
} from "../utils/bookingScenario/bookingScenario";
import { describe, vi, test } from "vitest";
import dayjs from "@calcom/dayjs";
import type { BookingStatus } from "@calcom/prisma/enums";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { expect } from "./getSchedule/expects";
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
WEBAPP_URL: "http://localhost:3000",
RESERVED_SUBDOMAINS: ["auth", "docs"],
}));
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveTimeSlots(expectedSlots: string[], date: { dateString: string }): R;
}
}
}
expect.extend({
toHaveTimeSlots(
schedule: { slots: Record<string, Slot[]> },
expectedSlots: string[],
{ dateString }: { dateString: string }
) {
if (!schedule.slots[`${dateString}`]) {
return {
pass: false,
message: () => `has no timeslots for ${dateString}`,
};
}
if (
!schedule.slots[`${dateString}`]
.map((slot) => slot.time)
.every((actualSlotTime, index) => {
return `${dateString}T${expectedSlots[index]}` === actualSlotTime;
})
) {
return {
pass: false,
message: () =>
`has incorrect timeslots for ${dateString}.\n\r ${diff(
expectedSlots.map((expectedSlot) => `${dateString}T${expectedSlot}`),
schedule.slots[`${dateString}`].map((slot) => slot.time)
)}`,
};
}
return {
pass: true,
message: () => "has correct timeslots ",
};
},
});
const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
};
const TestData = {
selectedCalendars: {
google: {
integration: "google_calendar",
externalId: "john@example.com",
},
},
credentials: {
google: getGoogleCalendarCredential(),
},
schedules: {
IstWorkHours: {
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
],
timeZone: Timezones["+5:30"],
},
IstWorkHoursWithDateOverride: (dateString: string) => ({
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T14:00:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: dateString,
},
],
timeZone: Timezones["+5:30"],
}),
},
users: {
example: {
name: "Example",
username: "example",
defaultScheduleId: 1,
email: "example@example.com",
timeZone: Timezones["+5:30"],
},
},
apps: {
googleCalendar: {
slug: "google-calendar",
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
},
};
const cleanup = async () => {
await prismock.eventType.deleteMany();
await prismock.user.deleteMany();
await prismock.schedule.deleteMany();
await prismock.selectedCalendar.deleteMany();
await prismock.credential.deleteMany();
await prismock.booking.deleteMany();
await prismock.app.deleteMany();
};
beforeEach(async () => {
await cleanup();
});
afterEach(async () => {
await cleanup();
});
describe("getSchedule", () => {
setupAndTeardown();
describe("Calendar event", () => {
test("correctly identifies unavailable slots from calendar", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
@@ -201,7 +63,7 @@ describe("getSchedule", () => {
selectedCalendars: [TestData.selectedCalendars.google],
},
],
apps: [TestData.apps.googleCalendar],
apps: [TestData.apps["google-calendar"]],
};
// An event with one accepted booking
await createBookingScenario(scenarioData);
@@ -445,22 +307,12 @@ describe("getSchedule", () => {
);
});
// FIXME: Fix minimumBookingNotice is respected test
// eslint-disable-next-line playwright/no-skipped-test
test.skip("minimumBookingNotice is respected", async () => {
vi.useFakeTimers().setSystemTime(
(() => {
const today = new Date();
// Beginning of the day in current timezone of the system
return new Date(today.getFullYear(), today.getMonth(), today.getDate());
})()
);
test("minimumBookingNotice is respected", async () => {
await createBookingScenario({
eventTypes: [
{
id: 1,
length: 120,
length: 2 * 60,
minimumBookingNotice: 13 * 60, // Would take the minimum bookable time to be 18:30UTC+13 = 7:30AM UTC
users: [
{
@@ -470,7 +322,7 @@ describe("getSchedule", () => {
},
{
id: 2,
length: 120,
length: 2 * 60,
minimumBookingNotice: 10 * 60, // Would take the minimum bookable time to be 18:30UTC+10 = 4:30AM UTC
users: [
{
@@ -487,8 +339,13 @@ describe("getSchedule", () => {
},
],
});
const { dateString: todayDateString } = getDate();
const { dateString: minus1DateString } = getDate({ dateIncrement: -1 });
// Time Travel to the beginning of today after getting all the dates correctly.
timeTravelToTheBeginningOfToday({ utcOffsetInHours: 5.5 });
const scheduleForEventWithBookingNotice13Hrs = await getSchedule({
input: {
eventTypeId: 1,
@@ -499,9 +356,11 @@ describe("getSchedule", () => {
isTeamEvent: false,
},
});
expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots(
[
/*`04:00:00.000Z`, `06:00:00.000Z`, - Minimum time slot is 07:30 UTC*/ `08:00:00.000Z`,
/*`04:00:00.000Z`, `06:00:00.000Z`, - Minimum time slot is 07:30 UTC which is 13hrs from 18:30*/
`08:00:00.000Z`,
`10:00:00.000Z`,
`12:00:00.000Z`,
],
@@ -522,17 +381,15 @@ describe("getSchedule", () => {
});
expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots(
[
/*`04:00:00.000Z`, - Minimum bookable time slot is 04:30 UTC but next available is 06:00*/
`06:00:00.000Z`,
`08:00:00.000Z`,
`10:00:00.000Z`,
`12:00:00.000Z`,
/*`04:00:00.000Z`, - Minimum bookable time slot is 04:30 UTC which is 10hrs from 18:30 */
`05:00:00.000Z`,
`07:00:00.000Z`,
`09:00:00.000Z`,
],
{
dateString: todayDateString,
}
);
vi.useRealTimers();
});
test("afterBuffer and beforeBuffer tests - Non Cal Busy Time", async () => {
@@ -569,7 +426,7 @@ describe("getSchedule", () => {
selectedCalendars: [TestData.selectedCalendars.google],
},
],
apps: [TestData.apps.googleCalendar],
apps: [TestData.apps["google-calendar"]],
};
await createBookingScenario(scenarioData);
@@ -643,7 +500,7 @@ describe("getSchedule", () => {
status: "ACCEPTED" as BookingStatus,
},
],
apps: [TestData.apps.googleCalendar],
apps: [TestData.apps["google-calendar"]],
};
await createBookingScenario(scenarioData);
@@ -701,7 +558,7 @@ describe("getSchedule", () => {
selectedCalendars: [TestData.selectedCalendars.google],
},
],
apps: [TestData.apps.googleCalendar],
apps: [TestData.apps["google-calendar"]],
};
await createBookingScenario(scenarioData);
@@ -879,6 +736,7 @@ describe("getSchedule", () => {
}
);
});
test("test that booking limit is working correctly if user is all day available", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
@@ -938,6 +796,7 @@ describe("getSchedule", () => {
],
},
],
// One bookings for each(E1 and E2) on plus2Date
bookings: [
{
userId: 101,
+105
View File
@@ -0,0 +1,105 @@
import { diff } from "jest-diff";
import { expect } from "vitest";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
export const expectedSlotsForSchedule = {
IstWorkHours: {
interval: {
"1hr": {
allPossibleSlotsStartingAt430: [
"04:30:00.000Z",
"05:30:00.000Z",
"06:30:00.000Z",
"07:30:00.000Z",
"08:30:00.000Z",
"09:30:00.000Z",
"10:30:00.000Z",
"11:30:00.000Z",
],
allPossibleSlotsStartingAt4: [
"04:00:00.000Z",
"05:00:00.000Z",
"06:00:00.000Z",
"07:00:00.000Z",
"08:00:00.000Z",
"09:00:00.000Z",
"10:00:00.000Z",
"11:00:00.000Z",
],
},
},
},
};
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveTimeSlots(expectedSlots: string[], date: { dateString: string; doExactMatch?: boolean }): R;
toHaveNoTimeSlots(date: { dateString: string }): R;
}
}
}
expect.extend({
toHaveTimeSlots(
schedule: { slots: Record<string, Slot[]> },
expectedSlots: string[],
{ dateString, doExactMatch }: { dateString: string; doExactMatch: boolean }
) {
if (!schedule.slots[`${dateString}`]) {
return {
pass: false,
message: () => `has no timeslots for ${dateString}`,
};
}
if (
!schedule.slots[`${dateString}`]
.map((slot) => slot.time)
.every((actualSlotTime, index) => {
return `${dateString}T${expectedSlots[index]}` === actualSlotTime;
})
) {
return {
pass: false,
message: () =>
`has incorrect timeslots for ${dateString}.\n\r ${diff(
expectedSlots.map((expectedSlot) => `${dateString}T${expectedSlot}`),
schedule.slots[`${dateString}`].map((slot) => slot.time)
)}`,
};
}
if (doExactMatch) {
return {
pass: expectedSlots.length === schedule.slots[`${dateString}`].length,
message: () =>
`number of slots don't match for ${dateString}. Expected ${expectedSlots.length} but got ${
schedule.slots[`${dateString}`].length
}`,
};
}
return {
pass: true,
message: () => "has correct timeslots ",
};
},
toHaveNoTimeSlots(schedule: { slots: Record<string, Slot[]> }, { dateString }: { dateString: string }) {
if (!schedule.slots[`${dateString}`] || schedule.slots[`${dateString}`].length === 0) {
return {
pass: true,
message: () => `has no timeslots for ${dateString}`,
};
}
return {
pass: false,
message: () => `has timeslots for ${dateString}`,
};
},
});
export { expect } from "vitest";
@@ -0,0 +1,353 @@
import type { ScenarioData } from "../../utils/bookingScenario/bookingScenario";
import {
TestData,
Timezones,
getDate,
createBookingScenario,
} from "../../utils/bookingScenario/bookingScenario";
import { describe, expect, vi, test } from "vitest";
import { PeriodType } from "@calcom/prisma/enums";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { expectedSlotsForSchedule } from "./expects";
import { setupAndTeardown } from "./setupAndTeardown";
import { timeTravelToTheBeginningOfToday } from "./utils";
function getPeriodTypeData({
type,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
}: {
type: PeriodType;
periodDays?: number;
periodCountCalendarDays?: boolean;
periodStartDate?: Date;
periodEndDate?: Date;
}) {
if (type === PeriodType.ROLLING) {
if (!periodCountCalendarDays || !periodDays) {
throw new Error("periodCountCalendarDays and periodDays are required for ROLLING period type");
}
return {
periodType: PeriodType.ROLLING,
periodDays,
periodCountCalendarDays,
};
}
if (type === PeriodType.ROLLING_WINDOW) {
if (!periodCountCalendarDays || !periodDays) {
throw new Error("periodCountCalendarDays and periodDays are required for ROLLING period type");
}
return {
periodType: PeriodType.ROLLING_WINDOW,
periodDays,
periodCountCalendarDays,
};
}
if (type === PeriodType.RANGE) {
if (!periodStartDate || !periodEndDate) {
throw new Error("periodStartDate and periodEndDate are required for RANGE period type");
}
return {
periodType: PeriodType.RANGE,
periodStartDate,
periodEndDate,
};
}
}
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
WEBAPP_URL: "http://localhost:3000",
RESERVED_SUBDOMAINS: ["auth", "docs"],
ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK: 61,
}));
describe("getSchedule", () => {
setupAndTeardown();
describe("Future Limits", () => {
describe("PeriodType=ROLLING", () => {
test("Basic test", async () => {
const { dateString: yesterdayDateString } = getDate({ dateIncrement: -1 });
const { dateString: todayDateString } = getDate();
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const { dateString: plus4DateString } = getDate({ dateIncrement: 4 });
const { dateString: plus5DateString } = getDate({ dateIncrement: 5 });
timeTravelToTheBeginningOfToday({ utcOffsetInHours: 5.5 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
...getPeriodTypeData({
type: "ROLLING",
periodDays: 2,
periodCountCalendarDays: true,
}),
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
} satisfies ScenarioData;
await createBookingScenario(scenarioData);
const scheduleForEvent = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
usernameList: [],
// Because this time is in GMT, it will be 00:00 in IST with todayDateString
startTime: `${yesterdayDateString}T18:30:00.000Z`,
endTime: `${plus5DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
},
});
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: todayDateString,
doExactMatch: true,
}
);
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus1DateString,
doExactMatch: true,
}
);
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus2DateString,
doExactMatch: true,
}
);
// No Timeslots beyond plus2Date as that is beyond the rolling period
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus3DateString,
});
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus4DateString,
});
});
});
describe("PeriodType=ROLLING_WINDOW", () => {
test("Basic test", async () => {
const { dateString: yesterdayDateString } = getDate({ dateIncrement: -1 });
const { dateString: todayDateString } = getDate();
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const { dateString: plus4DateString } = getDate({ dateIncrement: 4 });
const { dateString: plus5DateString } = getDate({ dateIncrement: 5 });
timeTravelToTheBeginningOfToday({ utcOffsetInHours: 5.5 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
...getPeriodTypeData({
type: "ROLLING_WINDOW",
periodDays: 3,
periodCountCalendarDays: true,
}),
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
status: "ACCEPTED",
// Fully book plus2 Date
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:30:00.000Z`,
},
],
} satisfies ScenarioData;
await createBookingScenario(scenarioData);
const scheduleForEvent = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
usernameList: [],
// Because this time is in GMT, it will be 00:00 in IST with todayDateString
startTime: `${yesterdayDateString}T18:30:00.000Z`,
endTime: `${plus5DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
},
});
console.log({ scheduleForEvent });
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: todayDateString,
doExactMatch: true,
}
);
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus1DateString,
doExactMatch: true,
}
);
// plus2Date is fully booked. So, instead we will have timeslots one day later
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus2DateString,
});
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus3DateString,
doExactMatch: true,
}
);
// No Timeslots on plus4Date as beyond the rolling period
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus4DateString,
});
});
});
describe("PeriodType=RANGE", () => {
test("Basic test", async () => {
const { dateString: yesterdayDateString } = getDate({ dateIncrement: -1 });
const { dateString: todayDateString } = getDate();
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const { dateString: plus4DateString } = getDate({ dateIncrement: 4 });
const { dateString: plus5DateString } = getDate({ dateIncrement: 5 });
timeTravelToTheBeginningOfToday({ utcOffsetInHours: 5.5 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
// Makes today and tomorrow only available
...getPeriodTypeData({
type: "RANGE",
// dayPlus1InIst
periodStartDate: new Date(`${todayDateString}T18:30:00.000Z`),
// datePlus2InIst
periodEndDate: new Date(`${plus1DateString}T18:30:00.000Z`),
periodCountCalendarDays: true,
}),
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
} satisfies ScenarioData;
await createBookingScenario(scenarioData);
const scheduleForEvent = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
usernameList: [],
// Because this time is in GMT, it will be 00:00 in IST with todayDateString
startTime: `${yesterdayDateString}T18:30:00.000Z`,
endTime: `${plus5DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
},
});
console.log({ scheduleForEvent });
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: todayDateString,
});
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus1DateString,
doExactMatch: true,
}
);
expect(scheduleForEvent).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus2DateString,
doExactMatch: true,
}
);
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus3DateString,
});
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus4DateString,
});
expect(scheduleForEvent).toHaveNoTimeSlots({
dateString: plus4DateString,
});
});
});
});
});
@@ -0,0 +1,24 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import { vi, beforeEach, afterEach } from "vitest";
const cleanup = async () => {
await prismock.eventType.deleteMany();
await prismock.user.deleteMany();
await prismock.schedule.deleteMany();
await prismock.selectedCalendar.deleteMany();
await prismock.credential.deleteMany();
await prismock.booking.deleteMany();
await prismock.app.deleteMany();
vi.useRealTimers();
};
export function setupAndTeardown() {
beforeEach(async () => {
await cleanup();
});
afterEach(async () => {
await cleanup();
});
}
+16
View File
@@ -0,0 +1,16 @@
import { getDate } from "../../utils/bookingScenario/bookingScenario";
import { vi } from "vitest";
export function timeTravelToTheBeginningOfToday({ utcOffsetInHours = 0 }: { utcOffsetInHours: number }) {
const timeInTheUtcOffsetInHours = 24 - utcOffsetInHours;
const timeInTheUtcOffsetInMinutes = timeInTheUtcOffsetInHours * 60;
const hours = Math.floor(timeInTheUtcOffsetInMinutes / 60);
const hoursString = hours < 10 ? `0${hours}` : `${hours}`;
const minutes = timeInTheUtcOffsetInMinutes % 60;
const minutesString = minutes < 10 ? `0${minutes}` : `${minutes}`;
const { dateString: yesterdayDateString } = getDate({ dateIncrement: -1 });
console.log({ yesterdayDateString, hours, minutes });
vi.setSystemTime(`${yesterdayDateString}T${hoursString}:${minutesString}:00.000Z`);
}
@@ -6,7 +6,6 @@ import type { BookingReference, Attendee, Booking, Membership } from "@prisma/cl
import type { Prisma } from "@prisma/client";
import type { WebhookTriggerEvents } from "@prisma/client";
import type Stripe from "stripe";
import type { getMockRequestDataForBooking } from "test/utils/bookingScenario/getMockRequestDataForBooking";
import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import type { z } from "zod";
@@ -28,6 +27,7 @@ import type { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar";
import { getMockPaymentService } from "./MockPaymentService";
import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking";
logger.settings.minLevel = 0;
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
@@ -812,6 +812,7 @@ export const TestData = {
},
schedules: {
IstWorkHours: {
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
availability: [
{
@@ -1,5 +1,7 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { InputEventType, getOrganizer } from "./bookingScenario";
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
import { parse } from "node-html-parser";
import type { VEvent } from "node-ical";
@@ -16,7 +18,6 @@ import type { AppsStatus } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import type { InputEventType, getOrganizer } from "./bookingScenario";
import { DEFAULT_TIMEZONE_BOOKER } from "./getMockRequestDataForBooking";
// This is too complex at the moment, I really need to simplify this.
@@ -1,6 +1,7 @@
import type { SchedulingType } from "@calcom/prisma/client";
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import type { SchedulingType } from "@calcom/prisma/client";
export const DEFAULT_TIMEZONE_BOOKER = "Asia/Kolkata";
export function getBasicMockRequestDataForBooking() {
return {
@@ -1,10 +1,10 @@
import { beforeEach, afterEach } from "vitest";
import {
enableEmailFeature,
mockNoTranslations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { beforeEach, afterEach } from "vitest";
export function setupAndTeardown() {
beforeEach(() => {
// Required to able to generate token in email in some cases
+2 -1
View File
@@ -1,9 +1,10 @@
import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import type { TestFunction } from "vitest";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { test } from "@calcom/web/test/fixtures/fixtures";
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
const WEBSITE_PROTOCOL = new URL(WEBSITE_URL).protocol;
const _testWithAndWithoutOrg = (
+2
View File
@@ -11,6 +11,8 @@ module.exports = {
importOrder: [
// Mocks must be at the top as they contain vi.mock calls
"(.*)/__mocks__/(.*)",
// bookingScenario contains prismock that must be imported asap
"(.*)bookingScenario(.*)",
"<THIRD_PARTY_MODULES>",
"^@(calcom|ee)/(.*)$",
"^@lib/(.*)$",
@@ -64,6 +64,7 @@ import {
parseRecurringEvent,
} from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
@@ -1027,6 +1028,7 @@ async function handler(
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
utcOffset: getUTCOffsetByTimezone(reqBody.timeZone) ?? 0,
},
eventType.minimumBookingNotice
);
@@ -7,10 +7,6 @@
*/
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, vi } from "vitest";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
TestData,
createBookingScenario,
@@ -19,12 +15,22 @@ import {
getNextMonthNotStartingOnWeekStart,
getOrganizer,
getScenarioData,
getGoogleCalendarCredential,
BookingLocations,
mockSuccessfulVideoMeetingCreation,
mockCalendarToHaveNoBusySlots,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect, vi } from "vitest";
import { PeriodType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
@@ -599,4 +605,177 @@ describe("handleNewBooking", () => {
"no_available_users_found_error"
);
});
test(
`should fail booking if the start date is in the past`,
async ({}) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
eventTypeId: 1,
start: `${getDate({ dateIncrement: -1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: -1 }).dateString}T05:30:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const scenarioData = getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
workflows: [
{
userId: organizer.id,
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
},
],
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
});
mockCalendarToHaveNoBusySlots("googlecalendar", {});
await createBookingScenario(scenarioData);
await expect(() => handleNewBooking(req)).rejects.toThrowError("book a meeting in the past");
},
timeout
);
describe("Future Limits", () => {
test(
`should fail booking if periodType=ROLLING check fails`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizerOtherEmail = "organizer2@example.com";
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: organizerOtherEmail,
},
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
periodType: PeriodType.ROLLING,
// Today, plus1 and plus2
periodDays: 2,
periodCountCalendarDays: true,
length: 30,
useEventTypeDestinationCalendarEmail: true,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [],
})
);
const plus3DateString = getDate({ dateIncrement: 3 }).dateString;
const mockBookingData = getMockRequestDataForBooking({
data: {
user: organizer.username,
// plus2Date timeslot being booked
start: `${plus3DateString}T05:00:00.000Z`,
end: `${plus3DateString}T05:30:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
},
});
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
expect(() => handleNewBooking(req)).rejects.toThrowError("cannot be booked at this time");
},
timeout
);
});
});
@@ -1,11 +1,3 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getGoogleCalendarCredential,
@@ -28,6 +20,15 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export const Timezones = {
"-05:00": "America/New_York",
"00:00": "Europe/London",
@@ -1,11 +1,3 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getGoogleCalendarCredential,
@@ -28,6 +20,15 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export const Timezones = {
"-05:00": "America/New_York",
"00:00": "Europe/London",
@@ -1,7 +1,8 @@
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe } from "vitest";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
describe("handleNewBooking", () => {
setupAndTeardown();
@@ -7,16 +7,6 @@
*
* They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app.
*/
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getDate,
@@ -58,6 +48,17 @@ import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScen
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { testWithAndWithoutOrg } from "@calcom/web/test/utils/bookingScenario/test";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
@@ -1,7 +1,8 @@
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe } from "vitest";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
describe("handleNewBooking", () => {
setupAndTeardown();
@@ -1,11 +1,3 @@
import { v4 as uuidv4 } from "uuid";
import { describe, expect } from "vitest";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getBooker,
@@ -28,6 +20,15 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { v4 as uuidv4 } from "uuid";
import { describe, expect } from "vitest";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
const DAY_IN_MS = 1000 * 60 * 60 * 24;
function getPlusDayDate(date: string, days: number) {
@@ -1,12 +1,5 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getDate,
@@ -43,6 +36,14 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
@@ -1,14 +1,3 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
createBookingScenario,
@@ -37,6 +26,18 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
@@ -1,8 +1,3 @@
import { describe, beforeEach } from "vitest";
import { resetTestSMS } from "@calcom/lib/testSMS";
import { SMSLockState, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getGoogleCalendarCredential,
@@ -25,6 +20,12 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, beforeEach } from "vitest";
import { resetTestSMS } from "@calcom/lib/testSMS";
import { SMSLockState, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
@@ -1,10 +1,5 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { BookingStatus } from "@calcom/prisma/enums";
import {
getBooker,
TestData,
@@ -21,6 +16,12 @@ import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScen
import { getMockRequestDataForCancelBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForCancelBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { BookingStatus } from "@calcom/prisma/enums";
import * as handleSeatsModule from "../handleSeats";
describe("handleSeats", () => {
+12
View File
@@ -70,10 +70,22 @@ export type FormValues = {
};
customInputs: CustomInputParsed[];
schedule: number | null;
periodType: PeriodType;
/**
* Number of days(Applicable only for ROLLING period type)
*/
periodDays: number;
/**
* Should consider Calendar Days(and not Business Days)(Applicable only for ROLLING period type)
*/
periodCountCalendarDays: boolean;
/**
* Date Range(Applicable only for RANGE period type)
*/
periodDates: { startDate: Date; endDate: Date };
rollingExcludeUnavailableDays: boolean;
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
@@ -1,10 +1,11 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects";
import { describe, expect, beforeEach } from "vitest";
import dayjs from "@calcom/dayjs";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects";
import { handleWebhookScheduledTriggers } from "../handleWebhookScheduledTriggers";
+5
View File
@@ -152,3 +152,8 @@ export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY;
export const MINIMUM_NUMBER_OF_ORG_SEATS = 30;
export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1";
export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 2;
/**
* The maximum number of days we should check for if we don't find all required bookable days
* Counter start from current day and we would like to not go beyond 2 months(max days possible) from current day.
*/
export const ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK = 30 + 31;
+233
View File
@@ -0,0 +1,233 @@
import { describe, expect, it } from "vitest";
import dayjs from "@calcom/dayjs";
import { getRollingWindowEndDate } from "./isOutOfBounds";
const getDayJsTimeWithUtcOffset = ({
dateStringWithOffset,
utcOffset,
}: {
dateStringWithOffset: string;
utcOffset: number;
}) => {
if (!dateStringWithOffset.includes("+")) {
throw new Error(
"dateStringWithOffset should have a +. That specifies the offset. Format: YYYY-MM-DDTHH:mm:ss+HH:mm"
);
}
return dayjs(dateStringWithOffset).utcOffset(utcOffset);
};
describe("getRollingWindowEndDate", () => {
it("should return the startDate itself when that date is bookable and 0 days in future are needed", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 0,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: true },
"2024-05-03": { isBookable: false },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-02T23:59:59+11:00");
});
it("should return the last possible time of the date so that all the timeslots of the last day are considered within range ", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 2,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: true },
"2024-05-03": { isBookable: true },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-03T23:59:59+11:00");
});
it("Input startDate normalization - should return the startDate with 00:00 time when that date is bookable and only 1 day is needed", () => {
const endDay = getRollingWindowEndDate({
// startDate has a time other than 00:00
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-11T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 1,
allDatesWithBookabilityStatus: {
"2024-05-10": { isBookable: true },
"2024-05-11": { isBookable: true },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-11T23:59:59+11:00");
});
it("should return the first bookable date when only 1 day is needed and the startDate is unavailable", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 1,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: false },
"2024-05-03": { isBookable: false },
"2024-05-04": { isBookable: true },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-04T23:59:59+11:00");
});
it("can give endDay farther than daysNeeded if countNonBusinessDays=false", () => {
// 2024-05-02 is Thursday
// 2024-05-03 is Friday
// 2024-05-04 is Saturday(Non business Day)
// 2024-05-05 is Sunday(Non Business Day)
// 2024-05-06 is Monday
testWhenNonBusinessDaysAreBooked();
testWhenNonBusinessDaysAreNotBooked();
return;
function testWhenNonBusinessDaysAreBooked() {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T15:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 3,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: true },
"2024-05-03": { isBookable: true },
// Skipped because Saturday is non-business day
"2024-05-04": { isBookable: true },
// Skipped because Sunday is non-business day
"2024-05-05": { isBookable: true },
"2024-05-06": { isBookable: true },
"2024-05-07": { isBookable: true },
},
countNonBusinessDays: false,
});
// Instead of 4th, it gives 6th because 2 days in b/w are non-business days which aren't counted
expect(endDay?.format()).toEqual("2024-05-06T23:59:59+11:00");
}
function testWhenNonBusinessDaysAreNotBooked() {
const endDay2 = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T15:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 3,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: true },
"2024-05-03": { isBookable: true },
"2024-05-04": { isBookable: false },
"2024-05-05": { isBookable: false },
"2024-05-06": { isBookable: true },
},
countNonBusinessDays: false,
});
// Instead of 4th, it gives 6th because 2 days in b/w are non-business days which aren't counted
expect(endDay2?.format()).toEqual("2024-05-06T23:59:59+11:00");
}
});
it("should return the first `daysNeeded` bookable days", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 3,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: false },
"2024-05-03": { isBookable: false },
"2024-05-04": { isBookable: true },
"2024-05-05": { isBookable: true },
"2024-05-06": { isBookable: true },
"2024-05-07": { isBookable: true },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-06T23:59:59+11:00");
});
it("should return the last bookable day if enough `daysNeeded` bookable days aren't found", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 30,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: false },
"2024-05-03": { isBookable: false },
"2024-05-04": { isBookable: true },
"2024-05-05": { isBookable: true },
"2024-05-06": { isBookable: false },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-05T23:59:59+11:00");
});
it("should treat non existing dates in `allDatesWithBookabilityStatus` as having isBookable:false the first `daysNeeded` bookable days", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 3,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: true },
"2024-05-03": { isBookable: false },
"2024-05-04": { isBookable: true },
"2024-05-07": { isBookable: true },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-05-07T23:59:59+11:00");
});
it("should return the last day in maximum window(that would be ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK days ahead) if no bookable day is found at all", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 3,
allDatesWithBookabilityStatus: {},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-07-02T23:59:59+11:00");
});
it("should should consider the bookable day very close to ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK but not beyond it", () => {
const endDay = getRollingWindowEndDate({
startDate: getDayJsTimeWithUtcOffset({
dateStringWithOffset: "2024-05-02T05:09:46+11:00",
utcOffset: 11,
}),
daysNeeded: 3,
allDatesWithBookabilityStatus: {
"2024-05-02": { isBookable: true },
"2024-06-04": { isBookable: true },
"2024-07-01": { isBookable: true },
"2024-07-07": { isBookable: true },
},
countNonBusinessDays: true,
});
expect(endDay?.format()).toEqual("2024-07-01T23:59:59+11:00");
});
});
+245 -36
View File
@@ -3,6 +3,10 @@ import type { EventType } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { PeriodType } from "@calcom/prisma/enums";
import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "./constants";
import logger from "./logger";
import { safeStringify } from "./safeStringify";
export class BookingDateInPastError extends Error {
constructor(message = "Attempting to book a meeting in the past.") {
super(message);
@@ -17,25 +21,163 @@ function guardAgainstBookingInThePast(date: Date) {
throw new BookingDateInPastError();
}
function isOutOfBounds(
time: dayjs.ConfigType,
{
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
}: Pick<
EventType,
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
>,
minimumBookingNotice?: number
) {
const date = dayjs(time);
guardAgainstBookingInThePast(date.toDate());
/**
* Dates passed to this function are timezone neutral.
*/
export function calculatePeriodLimits({
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
/**
* These dates will be considered in the same utfcOffset as provided
*/
allDatesWithBookabilityStatus,
utcOffset,
/**
* This is temporary till we find a way to provide allDatesWithBookabilityStatus in handleNewBooking without re-computing availability.
* It is okay for handleNewBooking to pass it as true as the frontend won't allow selecting a timeslot that is out of bounds of ROLLING_WINDOW
* But for the booking that happen through API, we absolutely need to check the ROLLING_WINDOW limits.
*/
_skipRollingWindowCheck,
}: Pick<
EventType,
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
> & {
allDatesWithBookabilityStatus: Record<string, { isBookable: boolean }> | null;
utcOffset: number;
_skipRollingWindowCheck?: boolean;
}): PeriodLimits {
const currentTime = dayjs().utcOffset(utcOffset);
periodDays = periodDays || 0;
switch (periodType) {
case PeriodType.ROLLING: {
const rollingEndDay = periodCountCalendarDays
? currentTime.add(periodDays, "days").endOf("day")
: currentTime.businessDaysAdd(periodDays).endOf("day");
return { rollingEndDay, rangeStartDay: null, rangeEndDay: null };
}
case PeriodType.ROLLING_WINDOW: {
if (_skipRollingWindowCheck) {
return { rollingEndDay: null, rangeStartDay: null, rangeEndDay: null };
}
if (!allDatesWithBookabilityStatus) {
throw new Error("`allDatesWithBookabilityStatus` is required");
}
const rollingEndDay = getRollingWindowEndDate({
startDate: currentTime,
daysNeeded: periodDays,
allDatesWithBookabilityStatus,
countNonBusinessDays: periodCountCalendarDays,
});
return { rollingEndDay, rangeStartDay: null, rangeEndDay: null };
}
case PeriodType.RANGE: {
const rangeStartDay = dayjs(periodStartDate).utcOffset(utcOffset).startOf("day");
const rangeEndDay = dayjs(periodEndDate).utcOffset(utcOffset).endOf("day");
return {
rollingEndDay: null,
rangeStartDay,
rangeEndDay,
};
}
}
return {
rollingEndDay: null,
rangeStartDay: null,
rangeEndDay: null,
};
}
export function getRollingWindowEndDate({
startDate,
daysNeeded,
allDatesWithBookabilityStatus,
countNonBusinessDays,
}: {
/**
* It should be provided in the same utcOffset as the dates in `allDatesWithBookabilityStatus`
* This is because we do a lookup by day in `allDatesWithBookabilityStatus`
*/
startDate: dayjs.Dayjs;
daysNeeded: number;
allDatesWithBookabilityStatus: Record<string, { isBookable: boolean }>;
countNonBusinessDays: boolean | null;
}) {
const log = logger.getSubLogger({ prefix: ["getRollingWindowEndDate"] });
log.debug("called:", safeStringify({ startDay: startDate.format(), daysNeeded }));
let counter = 1;
let rollingEndDay;
let currentDate = startDate.startOf("day");
// It helps to break out of the loop if we don't find enough bookable days.
const maxDaysToCheck = ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK;
let bookableDaysCount = 0;
// Add periodDays to currentDate, skipping non-bookable days.
while (bookableDaysCount < daysNeeded) {
// What if we don't find any bookable days. We should break out of the loop after a certain number of days.
if (counter > maxDaysToCheck) {
break;
}
const isBookable = !!allDatesWithBookabilityStatus[currentDate.format("YYYY-MM-DD")]?.isBookable;
if (isBookable) {
bookableDaysCount++;
rollingEndDay = currentDate;
}
log.silly(
`Loop Iteration: ${counter}`,
safeStringify({
currentDate: currentDate.format(),
isBookable,
bookableDaysCount,
rollingEndDay: rollingEndDay?.format(),
})
);
currentDate = countNonBusinessDays ? currentDate.add(1, "days") : currentDate.businessDaysAdd(1);
counter++;
}
/**
* We can't just return null(if rollingEndDay couldn't be obtained) and allow days farther than ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK to be booked as that would mean there is no future limit
*/
const rollingEndDayOrLastPossibleDayAsPerLimit = rollingEndDay ?? currentDate;
log.debug("Returning rollingEndDay", rollingEndDayOrLastPossibleDayAsPerLimit.format());
// Return endOfDay so that any timeslot in the last day is considered within bounds
return rollingEndDayOrLastPossibleDayAsPerLimit.endOf("day");
}
/**
* To be used when we work on Timeslots(and not Dates) to check boundaries
* It ensures that the time isn't in the past and also checks if the time is within the minimum booking notice.
*/
export function isTimeOutOfBounds({
time,
minimumBookingNotice,
}: {
time: dayjs.ConfigType;
minimumBookingNotice?: number;
}) {
const date = dayjs(time);
guardAgainstBookingInThePast(date.toDate());
if (minimumBookingNotice) {
const minimumBookingStartDate = dayjs().add(minimumBookingNotice, "minutes");
if (date.isBefore(minimumBookingStartDate)) {
@@ -43,24 +185,91 @@ function isOutOfBounds(
}
}
switch (periodType) {
case PeriodType.ROLLING: {
const periodRollingEndDay = periodCountCalendarDays
? dayjs().utcOffset(date.utcOffset()).add(periodDays, "days").endOf("day")
: dayjs().utcOffset(date.utcOffset()).businessDaysAdd(periodDays).endOf("day");
return date.endOf("day").isAfter(periodRollingEndDay);
}
case PeriodType.RANGE: {
const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
}
case PeriodType.UNLIMITED:
default:
return false;
}
return false;
}
export default isOutOfBounds;
type PeriodLimits = {
rollingEndDay: dayjs.Dayjs | null;
rangeStartDay: dayjs.Dayjs | null;
rangeEndDay: dayjs.Dayjs | null;
};
/**
* To be used when we work on just Dates(and not specific timeslots) to check boundaries
* e.g. It checks for Future Limits which operate on dates and not times.
*/
export function isDateOutOfBounds({
dateString,
periodLimits,
}: {
dateString: dayjs.ConfigType;
periodLimits: PeriodLimits;
_skipRollingWindowCheck?: boolean;
}) {
const log = logger.getSubLogger({ prefix: ["isDateOutOfBounds"] });
const date = dayjs(dateString);
log.debug(
safeStringify({
dateString,
endOfDay: date.format(),
periodLimits: {
rollingEndDay: periodLimits.rollingEndDay?.format(),
rangeStartDay: periodLimits.rangeStartDay?.format(),
rangeEndDay: periodLimits.rangeEndDay?.format(),
},
})
);
if (periodLimits.rollingEndDay) {
const isAfterRollingEndDay = date.isAfter(periodLimits.rollingEndDay);
log.debug({
dateString,
endOfDay: date.format(),
isAfterRollingEndDay,
rollingEndDay: periodLimits.rollingEndDay.format(),
});
return isAfterRollingEndDay;
}
if (periodLimits.rangeStartDay && periodLimits.rangeEndDay) {
return date.isBefore(periodLimits.rangeStartDay) || date.isAfter(periodLimits.rangeEndDay);
}
return false;
}
export default function isOutOfBounds(
time: dayjs.ConfigType,
{
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
utcOffset,
}: Pick<
EventType,
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
> & {
utcOffset: number;
},
minimumBookingNotice?: number
) {
return (
isTimeOutOfBounds({ time, minimumBookingNotice }) ||
isDateOutOfBounds({
dateString: time,
periodLimits: calculatePeriodLimits({
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
// Temporary till we find a way to provide allDatesWithBookabilityStatus in handleNewBooking without re-computing availability for the booked timeslot
allDatesWithBookabilityStatus: null,
_skipRollingWindowCheck: true,
utcOffset,
}),
})
);
}
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PeriodType" ADD VALUE 'rolling_window';
+4 -3
View File
@@ -36,9 +36,10 @@ enum SchedulingType {
}
enum PeriodType {
UNLIMITED @map("unlimited")
ROLLING @map("rolling")
RANGE @map("range")
UNLIMITED @map("unlimited")
ROLLING @map("rolling")
ROLLING_WINDOW @map("rolling_window")
RANGE @map("range")
}
model Host {
@@ -12,14 +12,16 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organization
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds";
import { isDateOutOfBounds, isTimeOutOfBounds, calculatePeriodLimits } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import { UserRepository } from "@calcom/lib/server/repository/user";
import getSlots from "@calcom/lib/slots";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { PeriodType, Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -337,6 +339,18 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
logger.settings.minLevel = 2;
}
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 loggerWithEventDetails = logger.getSubLogger({
prefix: ["getAvailableSlots", `${eventType.id}:${input.usernameList}/${input.eventTypeSlug}`],
});
@@ -353,7 +367,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime;
};
const startTime = getStartTime(input.startTime, input.timeZone);
const startTime = getStartTime(startTimeAdjustedForRollingWindowComputation, input.timeZone);
const endTime =
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
@@ -512,15 +526,6 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
currentSeats,
};
const isTimeWithinBounds = (_time: Parameters<typeof isTimeOutOfBounds>[0]) =>
!isTimeOutOfBounds(_time, {
periodType: eventType.periodType,
periodStartDate: eventType.periodStartDate,
periodEndDate: eventType.periodEndDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
periodDays: eventType.periodDays,
});
const getSlotsTime = 0;
const checkForAvailabilityTime = 0;
const getSlotsCount = 0;
@@ -630,7 +635,6 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
);
}
availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time));
// fr-CA uses YYYY-MM-DD
const formatter = new Intl.DateTimeFormat("fr-CA", {
year: "numeric",
@@ -639,7 +643,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
timeZone: input.timeZone,
});
const computedAvailableSlots = availableTimeSlots.reduce(
const slotsMappedToDate = availableTimeSlots.reduce(
(
r: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>,
{ time, ...passThroughProps }
@@ -674,15 +678,54 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
Object.create(null)
);
loggerWithEventDetails.debug(safeStringify({ slotsMappedToDate }));
const availableDates = Object.keys(slotsMappedToDate);
const allDatesWithBookabilityStatus = getAllDatesWithBookabilityStatus(availableDates);
loggerWithEventDetails.debug(safeStringify({ availableDates }));
const periodLimits = calculatePeriodLimits({
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodCountCalendarDays: eventType.periodCountCalendarDays,
periodStartDate: eventType.periodStartDate,
periodEndDate: eventType.periodEndDate,
allDatesWithBookabilityStatus,
utcOffset: input.timeZone ? getUTCOffsetByTimezone(input.timeZone) ?? 0 : 0,
});
let foundALimitViolation = false;
const withinBoundsSlotsMappedToDate = Object.entries(slotsMappedToDate).reduce(
(withinBoundsSlotsMappedToDate, [date, slots]) => {
// Computation Optimization: If a limit violation has been found, we just consider all slots to be out of bounds beyond that date.
// We can't do the same for periodType=RANGE because it can start from a day other than today and today will hit the violation then.
if (foundALimitViolation && doesRangeStartFromToday(eventType.periodType)) {
return withinBoundsSlotsMappedToDate;
}
const isDateWithinBound = !isDateOutOfBounds({ dateString: date, periodLimits });
if (isDateWithinBound) {
// TODO: Slots calculation logic already seems to consider the minimum booking notice and past booking time and thus there shouldn't be need to filter out slots here.
withinBoundsSlotsMappedToDate[date] = slots.filter(
(slot) =>
!isTimeOutOfBounds({ time: slot.time, minimumBookingNotice: eventType.minimumBookingNotice })
);
} else {
foundALimitViolation = true;
}
return withinBoundsSlotsMappedToDate;
},
{} as typeof slotsMappedToDate
);
loggerWithEventDetails.debug(`getSlots took ${getSlotsTime}ms and executed ${getSlotsCount} times`);
loggerWithEventDetails.debug(
`checkForAvailability took ${checkForAvailabilityTime}ms and executed ${checkForAvailabilityCount} times`
);
loggerWithEventDetails.debug(`Available slots: ${JSON.stringify(computedAvailableSlots)}`);
loggerWithEventDetails.debug(`Available slots: ${JSON.stringify(withinBoundsSlotsMappedToDate)}`);
// We only want to run this on single targeted events and not dynamic
if (!Object.keys(computedAvailableSlots).length && input.usernameList?.length === 1) {
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
try {
await handleNotificationWhenNoSlots({
eventDetails: {
@@ -701,10 +744,14 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
}
return {
slots: computedAvailableSlots,
slots: withinBoundsSlotsMappedToDate,
};
}
function doesRangeStartFromToday(periodType: PeriodType) {
return periodType === PeriodType.ROLLING_WINDOW || periodType === PeriodType.ROLLING;
}
async function getUserIdFromUsername(
username: string,
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
@@ -734,3 +781,20 @@ async function getTeamIdFromSlug(
});
return team?.id;
}
export function getAllDatesWithBookabilityStatus(availableDates: string[]) {
const availableDatesSet = new Set(availableDates);
const firstDate = dayjs(availableDates[0]);
const lastDate = dayjs(availableDates[availableDates.length - 1]);
const allDates: Record<string, { isBookable: boolean }> = {};
let currentDate = firstDate;
while (currentDate <= lastDate) {
allDates[currentDate.format("YYYY-MM-DD")] = {
isBookable: availableDatesSet.has(currentDate.format("YYYY-MM-DD")),
};
currentDate = currentDate.add(1, "day");
}
return allDates;
}