2
0
Files
cal/calcom/packages/lib/isOutOfBounds.tsx
2024-08-09 00:39:27 +02:00

274 lines
8.2 KiB
TypeScript

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<string, { isBookable: boolean }> | 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<string, { isBookable: boolean }>;
countNonBusinessDays: boolean | null;
}) {
const log = logger.getSubLogger({ prefix: ["getRollingWindowEndDate"] });
log.debug("called:", safeStringify({ startDay: startDate.format(), daysNeeded }));
let counter = 1;
let rollingEndDay;
let currentDate = startDate.startOf("day");
// It helps to break out of the loop if we don't find enough bookable days.
const maxDaysToCheck = ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK;
let bookableDaysCount = 0;
// Add periodDays to currentDate, skipping non-bookable days.
while (bookableDaysCount < daysNeeded) {
// What if we don't find any bookable days. We should break out of the loop after a certain number of days.
if (counter > maxDaysToCheck) {
break;
}
const isBookable = !!allDatesWithBookabilityStatus[currentDate.format("YYYY-MM-DD")]?.isBookable;
if (isBookable) {
bookableDaysCount++;
rollingEndDay = currentDate;
}
log.silly(
`Loop Iteration: ${counter}`,
safeStringify({
currentDate: currentDate.format(),
isBookable,
bookableDaysCount,
rollingEndDay: rollingEndDay?.format(),
})
);
currentDate = countNonBusinessDays ? currentDate.add(1, "days") : currentDate.businessDaysAdd(1);
counter++;
}
/**
* We can't just return null(if rollingEndDay couldn't be obtained) and allow days farther than ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK to be booked as that would mean there is no future limit
*/
const rollingEndDayOrLastPossibleDayAsPerLimit = rollingEndDay ?? currentDate;
log.debug("Returning rollingEndDay", rollingEndDayOrLastPossibleDayAsPerLimit.format());
// 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,
}),
})
);
}