274 lines
8.2 KiB
TypeScript
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,
|
|
}),
|
|
})
|
|
);
|
|
}
|