From 56022a1ba8336b1018f93d85e0979621ae856f48 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 20 May 2024 18:11:07 +0530 Subject: [PATCH] feat: Future Limit - Toggle to exclude unavailable days (#14915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Fix ordering of imports that is causing tests failure --------- Signed-off-by: zomars Co-authored-by: Alex van Andel Co-authored-by: Omar López Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../components/eventtype/EventLimitsTab.tsx | 330 ++++++++++------ apps/web/public/static/locales/en/common.json | 1 + .../test/handlers/requestReschedule.test.ts | 22 +- apps/web/test/lib/getSchedule.test.ts | 209 ++--------- apps/web/test/lib/getSchedule/expects.ts | 105 ++++++ .../getSchedule/futureLimit.timezone.test.ts | 353 ++++++++++++++++++ .../test/lib/getSchedule/setupAndTeardown.ts | 24 ++ apps/web/test/lib/getSchedule/utils.ts | 16 + .../utils/bookingScenario/bookingScenario.ts | 3 +- .../web/test/utils/bookingScenario/expects.ts | 3 +- .../getMockRequestDataForBooking.ts | 3 +- .../utils/bookingScenario/setupAndTeardown.ts | 4 +- apps/web/test/utils/bookingScenario/test.ts | 3 +- packages/config/prettier-preset.js | 2 + .../features/bookings/lib/handleNewBooking.ts | 2 + .../test/booking-limits.test.ts | 187 +++++++++- .../test/complex-schedules.test.ts | 17 +- .../test/date-overrides.test.ts | 17 +- .../test/dynamic-group-booking.test.ts | 3 +- .../test/fresh-booking.test.ts | 21 +- .../test/managed-event-type-booking.test.ts | 3 +- .../test/recurring-event.test.ts | 17 +- .../handleNewBooking/test/reschedule.test.ts | 15 +- .../collective-scheduling.test.ts | 23 +- .../test/workflow-notifications.test.ts | 11 +- .../lib/handleSeats/test/handleSeats.test.ts | 11 +- packages/features/eventtypes/lib/types.ts | 12 + .../webhooks/lib/test/webhooks.test.ts | 3 +- packages/lib/constants.ts | 5 + packages/lib/isOutOfBounds.timezone.test.ts | 233 ++++++++++++ packages/lib/isOutOfBounds.tsx | 281 ++++++++++++-- .../migration.sql | 2 + packages/prisma/schema.prisma | 7 +- .../trpc/server/routers/viewer/slots/util.ts | 98 ++++- 34 files changed, 1618 insertions(+), 428 deletions(-) create mode 100644 apps/web/test/lib/getSchedule/expects.ts create mode 100644 apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts create mode 100644 apps/web/test/lib/getSchedule/setupAndTeardown.ts create mode 100644 apps/web/test/lib/getSchedule/utils.ts create mode 100644 packages/lib/isOutOfBounds.timezone.test.ts create mode 100644 packages/prisma/migrations/20240506101739_add_rolling_window_period_type/migration.sql diff --git a/apps/web/components/eventtype/EventLimitsTab.tsx b/apps/web/components/eventtype/EventLimitsTab.tsx index 22d9272a59..d814df5e13 100644 --- a/apps/web/components/eventtype/EventLimitsTab.tsx +++ b/apps/web/components/eventtype/EventLimitsTab.tsx @@ -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; +}) { + const { t } = useLocale(); + return ( +
+ {!isDisabled && ( + + + + )} +
+ {t("within_date_range")}  +
+ ( + { + onChange({ + startDate, + endDate, + }); + }} + /> + )} + /> +
+
+
+ ); +} + +function RollingLimitRadioItem({ + radioValue, + isDisabled, + formMethods, + onChange, + rollingExcludeUnavailableDays, +}: { + radioValue: IPeriodType; + isDisabled: boolean; + formMethods: UseFormReturn; + 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 ( +
+ {!isDisabled && ( + + + + )} + +
+
+ + { - 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) - )} - /> -
- )} - {period.type === "RANGE" && ( -
- ( - { - onChange({ - startDate, - endDate, - }); - }} - /> - )} - /> -
- )} - {period.suffix ?  {period.suffix} : null} -
+ value={watchPeriodTypeUiValue} + onValueChange={(val) => { + formMethods.setValue( + "periodType", + getPeriodTypeFromUiValue({ + value: val as IPeriodType, + rollingExcludeUnavailableDays: formMethods.getValues("rollingExcludeUnavailableDays"), + }), + { + shouldDirty: true, + } ); - })} + }}> + {(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.ROLLING : true) && ( + { + formMethods.setValue("periodCountCalendarDays", opt?.value === 1, { + shouldDirty: true, + }); + }} + /> + )} + {(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.RANGE : true) && ( + + )}
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7ff76a6fd1..943fab682f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2441,6 +2441,7 @@ "csat_score": "CSAT Score", "lockedSMS": "Locked SMS", "signing_up_terms": "By proceeding, you agree to our <1>Terms and <2>Privacy Policy.", + "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", diff --git a/apps/web/test/handlers/requestReschedule.test.ts b/apps/web/test/handlers/requestReschedule.test.ts index ef1f2f96f2..afcb66a8e3 100644 --- a/apps/web/test/handlers/requestReschedule.test.ts +++ b/apps/web/test/handlers/requestReschedule.test.ts @@ -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; diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index fa3df7711c..9c17ed72c4 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -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 { - toHaveTimeSlots(expectedSlots: string[], date: { dateString: string }): R; - } - } -} - -expect.extend({ - toHaveTimeSlots( - schedule: { slots: Record }, - 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, diff --git a/apps/web/test/lib/getSchedule/expects.ts b/apps/web/test/lib/getSchedule/expects.ts new file mode 100644 index 0000000000..7bfb369948 --- /dev/null +++ b/apps/web/test/lib/getSchedule/expects.ts @@ -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 { + toHaveTimeSlots(expectedSlots: string[], date: { dateString: string; doExactMatch?: boolean }): R; + toHaveNoTimeSlots(date: { dateString: string }): R; + } + } +} + +expect.extend({ + toHaveTimeSlots( + schedule: { slots: Record }, + 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 }, { 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"; diff --git a/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts b/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts new file mode 100644 index 0000000000..f76ab2ad63 --- /dev/null +++ b/apps/web/test/lib/getSchedule/futureLimit.timezone.test.ts @@ -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, + }); + }); + }); + }); +}); diff --git a/apps/web/test/lib/getSchedule/setupAndTeardown.ts b/apps/web/test/lib/getSchedule/setupAndTeardown.ts new file mode 100644 index 0000000000..c28a3767d2 --- /dev/null +++ b/apps/web/test/lib/getSchedule/setupAndTeardown.ts @@ -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(); + }); +} diff --git a/apps/web/test/lib/getSchedule/utils.ts b/apps/web/test/lib/getSchedule/utils.ts new file mode 100644 index 0000000000..ccf2505561 --- /dev/null +++ b/apps/web/test/lib/getSchedule/utils.ts @@ -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`); +} diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 649e3b37c3..798a0b5f75 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -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: [ { diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 3f9bf23445..6eda229076 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -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. diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index 49298a98df..ff94043b97 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -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 { diff --git a/apps/web/test/utils/bookingScenario/setupAndTeardown.ts b/apps/web/test/utils/bookingScenario/setupAndTeardown.ts index 4d8512186b..26c7011db6 100644 --- a/apps/web/test/utils/bookingScenario/setupAndTeardown.ts +++ b/apps/web/test/utils/bookingScenario/setupAndTeardown.ts @@ -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 diff --git a/apps/web/test/utils/bookingScenario/test.ts b/apps/web/test/utils/bookingScenario/test.ts index b3957f982f..a42e4fc0a5 100644 --- a/apps/web/test/utils/bookingScenario/test.ts +++ b/apps/web/test/utils/bookingScenario/test.ts @@ -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 = ( diff --git a/packages/config/prettier-preset.js b/packages/config/prettier-preset.js index 94ae028782..fba3d2e4a5 100644 --- a/packages/config/prettier-preset.js +++ b/packages/config/prettier-preset.js @@ -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(.*)", "", "^@(calcom|ee)/(.*)$", "^@lib/(.*)$", diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index c0c96b7c84..b4bd389fc2 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 ); diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index 712e95272e..534ec67ad2 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -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 + ); + }); }); diff --git a/packages/features/bookings/lib/handleNewBooking/test/complex-schedules.test.ts b/packages/features/bookings/lib/handleNewBooking/test/complex-schedules.test.ts index 4ca6484a66..a739223d46 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/complex-schedules.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/complex-schedules.test.ts @@ -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", diff --git a/packages/features/bookings/lib/handleNewBooking/test/date-overrides.test.ts b/packages/features/bookings/lib/handleNewBooking/test/date-overrides.test.ts index b7f2523a20..7585842ef2 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/date-overrides.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/date-overrides.test.ts @@ -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", diff --git a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts index 2da113005d..8256a6d777 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts @@ -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(); diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 21e70e2169..90c0ce4633 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -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; diff --git a/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts index 9a86bbec7c..59214bf260 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts @@ -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(); diff --git a/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts b/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts index 8587acaaa6..72fb1a1c92 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts @@ -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) { diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index 6d555f6c30..3f568f6064 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -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; diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index 104a147f35..928002419b 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -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; diff --git a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts index b11ca02128..4ceceedca2 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts @@ -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; diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 08843c2996..c19fa48758 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -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", () => { diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 7f1da377dd..4f7495cec6 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -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; diff --git a/packages/features/webhooks/lib/test/webhooks.test.ts b/packages/features/webhooks/lib/test/webhooks.test.ts index f01f58b3c8..ae3d407504 100644 --- a/packages/features/webhooks/lib/test/webhooks.test.ts +++ b/packages/features/webhooks/lib/test/webhooks.test.ts @@ -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"; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 22dc2588cd..60371a8d83 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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; diff --git a/packages/lib/isOutOfBounds.timezone.test.ts b/packages/lib/isOutOfBounds.timezone.test.ts new file mode 100644 index 0000000000..0b742d157e --- /dev/null +++ b/packages/lib/isOutOfBounds.timezone.test.ts @@ -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"); + }); +}); diff --git a/packages/lib/isOutOfBounds.tsx b/packages/lib/isOutOfBounds.tsx index 7779dbb278..c2b0b4f759 100644 --- a/packages/lib/isOutOfBounds.tsx +++ b/packages/lib/isOutOfBounds.tsx @@ -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 | 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; + 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, + }), + }) + ); +} diff --git a/packages/prisma/migrations/20240506101739_add_rolling_window_period_type/migration.sql b/packages/prisma/migrations/20240506101739_add_rolling_window_period_type/migration.sql new file mode 100644 index 0000000000..dbf44c1762 --- /dev/null +++ b/packages/prisma/migrations/20240506101739_add_rolling_window_period_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PeriodType" ADD VALUE 'rolling_window'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1085d72dfb..5b52a3bab4 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index ed3d2bacd6..f96e2b3f31 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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[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, { 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 = {}; + + 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; +}