first commit
This commit is contained in:
273
calcom/packages/lib/isOutOfBounds.tsx
Normal file
273
calcom/packages/lib/isOutOfBounds.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user