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); } } function guardAgainstBookingInThePast(date: Date) { if (date >= new Date()) { // Date is in the future. return; } throw new BookingDateInPastError(); } /** * 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; const log = logger.getSubLogger({ prefix: ["calculatePeriodLimits"] }); log.debug( safeStringify({ periodType, periodDays, periodCountCalendarDays, periodStartDate: periodStartDate, periodEndDate: periodEndDate, currentTime: currentTime.format(), }) ); switch (periodType) { case PeriodType.ROLLING: { const rollingEndDay = periodCountCalendarDays ? currentTime.add(periodDays, "days") : currentTime.businessDaysAdd(periodDays); // The future limit talks in terms of days so we take the end of the day here to consider the entire day return { rollingEndDay: rollingEndDay.endOf("day"), 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: { // The future limit talks in terms of days so we take the start of the day for starting range and endOf the day for ending 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()); // The future limit talks in terms of days so we take the end of the day here to consider the entire day 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)) { return true; } } return false; } 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 isTimeViolatingFutureLimit({ time, periodLimits, }: { time: dayjs.ConfigType; periodLimits: PeriodLimits; }) { const log = logger.getSubLogger({ prefix: ["isTimeViolatingFutureLimit"] }); const date = dayjs(time); if (periodLimits.rollingEndDay) { const isAfterRollingEndDay = date.isAfter(periodLimits.rollingEndDay); log.debug({ formattedDate: 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 }) || isTimeViolatingFutureLimit({ 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, }), }) ); }