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 */ allDatesWithBookabilityStatusInBookerTz, eventUtcOffset, bookerUtcOffset, /** * 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" > & { allDatesWithBookabilityStatusInBookerTz: Record | null; eventUtcOffset: number; bookerUtcOffset: number; _skipRollingWindowCheck?: boolean; }): PeriodLimits { const currentTime = dayjs(); const currentTimeInEventTz = currentTime.utcOffset(eventUtcOffset); const currentTimeInBookerTz = currentTime.utcOffset(bookerUtcOffset); periodDays = periodDays || 0; const log = logger.getSubLogger({ prefix: ["calculatePeriodLimits"] }); log.debug( safeStringify({ periodType, periodDays, periodCountCalendarDays, periodStartDate: periodStartDate, periodEndDate: periodEndDate, currentTime: currentTimeInEventTz.format(), }) ); switch (periodType) { case PeriodType.ROLLING: { // We use booker's timezone to calculate the end of the rolling period(for both ROLLING and ROLLING_WINDOW). This is because we want earliest possible timeslot to be available to be booked which could be on an earlier day(compared to event timezone) as per booker's timezone. // So, if 2 day rolling period is set and 2024-07-24T8:30:00 slot is available in event timezone, the corresponding slot in GMT-11 would be 2024-07-24T21:30:00. So, 24th should be bookable for that timeslot, which could only be made available if we consider things in booker timezone. const rollingEndDay = periodCountCalendarDays ? currentTimeInBookerTz.add(periodDays, "days") : currentTimeInBookerTz.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 { endOfRollingPeriodEndDayInBookerTz: rollingEndDay.endOf("day"), startOfRangeStartDayInEventTz: null, endOfRangeEndDayInEventTz: null, }; } case PeriodType.ROLLING_WINDOW: { if (_skipRollingWindowCheck) { return { endOfRollingPeriodEndDayInBookerTz: null, startOfRangeStartDayInEventTz: null, endOfRangeEndDayInEventTz: null, }; } if (!allDatesWithBookabilityStatusInBookerTz) { throw new Error("`allDatesWithBookabilityStatus` is required"); } const endOfRollingPeriodEndDayInBookerTz = getRollingWindowEndDate({ startDateInBookerTz: currentTimeInBookerTz, daysNeeded: periodDays, allDatesWithBookabilityStatusInBookerTz, countNonBusinessDays: periodCountCalendarDays, }); return { endOfRollingPeriodEndDayInBookerTz, startOfRangeStartDayInEventTz: null, endOfRangeEndDayInEventTz: null, }; } case PeriodType.RANGE: { // We take the start of the day for the start of the range and endOf the day for end of range, so that entire days are covered // We use organizer's timezone here(in contrast with ROLLING/ROLLING_WINDOW where number of days is available and not the specific date objects). // This is because in case of range the start and end date objects are determined by the organizer, so we should consider the range in organizer/event's timezone. const startOfRangeStartDayInEventTz = dayjs(periodStartDate).utcOffset(eventUtcOffset).startOf("day"); const endOfRangeEndDayInEventTz = dayjs(periodEndDate).utcOffset(eventUtcOffset).endOf("day"); return { endOfRollingPeriodEndDayInBookerTz: null, startOfRangeStartDayInEventTz, endOfRangeEndDayInEventTz, }; } } return { endOfRollingPeriodEndDayInBookerTz: null, startOfRangeStartDayInEventTz: null, endOfRangeEndDayInEventTz: null, }; } export function getRollingWindowEndDate({ startDateInBookerTz, daysNeeded, allDatesWithBookabilityStatusInBookerTz, 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` */ startDateInBookerTz: dayjs.Dayjs; daysNeeded: number; allDatesWithBookabilityStatusInBookerTz: Record; countNonBusinessDays: boolean | null; }) { const log = logger.getSubLogger({ prefix: ["getRollingWindowEndDate"] }); log.debug("called:", safeStringify({ startDay: startDateInBookerTz.format(), daysNeeded })); let counter = 1; let rollingEndDay; const startOfStartDayInEventTz = startDateInBookerTz.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; let startOfIterationDay = startOfStartDayInEventTz; // 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 onlyDateOfIterationDay = startOfIterationDay.format("YYYY-MM-DD"); const isBookable = !!allDatesWithBookabilityStatusInBookerTz[onlyDateOfIterationDay]?.isBookable; if (isBookable) { bookableDaysCount++; rollingEndDay = startOfIterationDay; } log.silly( `Loop Iteration: ${counter}`, safeStringify({ iterationDayBeginning: startOfIterationDay.format(), isBookable, bookableDaysCount, rollingEndDay: rollingEndDay?.format(), allDatesWithBookabilityStatusInBookerTz, }) ); startOfIterationDay = countNonBusinessDays ? startOfIterationDay.add(1, "days") : startOfIterationDay.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 * The future limit talks in terms of days so we take the end of the day here to consider the entire day */ const endOfLastDayOfWindowInBookerTz = (rollingEndDay ?? startOfIterationDay).endOf("day"); log.debug("Returning rollingEndDay", endOfLastDayOfWindowInBookerTz.format()); return endOfLastDayOfWindowInBookerTz; } /** * 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 = { endOfRollingPeriodEndDayInBookerTz: dayjs.Dayjs | null; startOfRangeStartDayInEventTz: dayjs.Dayjs | null; endOfRangeEndDayInEventTz: dayjs.Dayjs | null; }; export function isTimeViolatingFutureLimit({ time, periodLimits, }: { time: dayjs.ConfigType; periodLimits: PeriodLimits; }) { const log = logger.getSubLogger({ prefix: ["isTimeViolatingFutureLimit"] }); // Because we are just going to compare times, we need not convert the time to a particular timezone // date.isAfter/isBefore are timezone neutral const dateInSystemTz = dayjs(time); if (periodLimits.endOfRollingPeriodEndDayInBookerTz) { const isAfterRollingEndDay = dateInSystemTz.isAfter(periodLimits.endOfRollingPeriodEndDayInBookerTz); log.silly("rollingEndDayCheck", { formattedDate: dateInSystemTz.format(), isAfterRollingEndDay, endOfRollingPeriodEndDayInBookerTz: periodLimits.endOfRollingPeriodEndDayInBookerTz.format(), }); if (isAfterRollingEndDay) log.warn( "Booking is out of bounds due to rolling period end day.", safeStringify({ formattedDate: dateInSystemTz.format(), isAfterRollingEndDay, endOfRollingPeriodEndDayInBookerTz: periodLimits.endOfRollingPeriodEndDayInBookerTz.format(), }) ); return isAfterRollingEndDay; } if (periodLimits.startOfRangeStartDayInEventTz && periodLimits.endOfRangeEndDayInEventTz) { const isBeforeRangeStart = dateInSystemTz.isBefore(periodLimits.startOfRangeStartDayInEventTz); const isAfterRangeEnd = dateInSystemTz.isAfter(periodLimits.endOfRangeEndDayInEventTz); log.silly("rangeCheck", { formattedDate: dateInSystemTz.format(), isAfterRangeEnd, isBeforeRangeStart, startOfRangeStartDayInEventTz: periodLimits.startOfRangeStartDayInEventTz.format(), endOfRangeEndDayInEventTz: periodLimits.endOfRangeEndDayInEventTz.format(), }); if (isBeforeRangeStart || isAfterRangeEnd) log.warn( "Booking is out of bounds due to range start and end.", safeStringify({ formattedDate: dateInSystemTz.format(), isBeforeRangeStart, isAfterRangeEnd, startOfRangeStartDayInEventTz: periodLimits.startOfRangeStartDayInEventTz.format(), endOfRangeEndDayInEventTz: periodLimits.endOfRangeEndDayInEventTz.format(), }) ); return isBeforeRangeStart || isAfterRangeEnd; } return false; } export default function isOutOfBounds( time: dayjs.ConfigType, { periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, eventUtcOffset, bookerUtcOffset, }: Pick< EventType, "periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate" > & { eventUtcOffset: number; bookerUtcOffset: number; }, minimumBookingNotice?: number ) { const log = logger.getSubLogger({ prefix: ["isOutOfBounds"] }); const isOutOfBoundsByTime = isTimeOutOfBounds({ time, minimumBookingNotice }); const periodLimits = calculatePeriodLimits({ periodType, periodDays, periodCountCalendarDays, periodStartDate, periodEndDate, allDatesWithBookabilityStatusInBookerTz: null, // Temporary workaround _skipRollingWindowCheck: true, eventUtcOffset, bookerUtcOffset, }); const isOutOfBoundsByPeriod = isTimeViolatingFutureLimit({ time, periodLimits, }); if (isOutOfBoundsByTime) { log.warn( "Booking is out of bounds due to minimum booking notice.", safeStringify({ minimumBookingNotice }) ); return true; } if (isOutOfBoundsByPeriod) { log.warn("Booking is out of bounds due to period restrictions", safeStringify({ periodLimits })); return true; } return false; }