first commit
This commit is contained in:
885
calcom/packages/core/getUserAvailability.ts
Normal file
885
calcom/packages/core/getUserAvailability.ts
Normal file
@@ -0,0 +1,885 @@
|
||||
import type { Booking, Prisma, EventType as PrismaEventType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
|
||||
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { checkBookingLimit } from "@calcom/lib/server";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
|
||||
import prisma, { availabilityUserSelect } from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import { EventTypeMetaDataSchema, stringToDayjsZod } from "@calcom/prisma/zod-utils";
|
||||
import type {
|
||||
EventBusyDate,
|
||||
EventBusyDetails,
|
||||
IntervalLimit,
|
||||
IntervalLimitUnit,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import { getBusyTimes } from "./getBusyTimes";
|
||||
import monitorCallbackAsync, { monitorCallbackSync } from "./sentryWrapper";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getUserAvailability"] });
|
||||
const availabilitySchema = z
|
||||
.object({
|
||||
dateFrom: stringToDayjsZod,
|
||||
dateTo: stringToDayjsZod,
|
||||
eventTypeId: z.number().optional(),
|
||||
username: z.string().optional(),
|
||||
userId: z.number().optional(),
|
||||
afterEventBuffer: z.number().optional(),
|
||||
beforeEventBuffer: z.number().optional(),
|
||||
duration: z.number().optional(),
|
||||
withSource: z.boolean().optional(),
|
||||
returnDateOverrides: z.boolean(),
|
||||
})
|
||||
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
|
||||
|
||||
const getEventType = async (
|
||||
...args: Parameters<typeof _getEventType>
|
||||
): Promise<ReturnType<typeof _getEventType>> => {
|
||||
return monitorCallbackAsync(_getEventType, ...args);
|
||||
};
|
||||
|
||||
const _getEventType = async (id: number) => {
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
seatsPerTimeSlot: true,
|
||||
bookingLimits: true,
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
durationLimits: true,
|
||||
assignAllTeamMembers: true,
|
||||
schedulingType: true,
|
||||
timeZone: true,
|
||||
length: true,
|
||||
metadata: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
},
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!eventType) {
|
||||
return eventType;
|
||||
}
|
||||
return {
|
||||
...eventType,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
};
|
||||
};
|
||||
|
||||
type EventType = Awaited<ReturnType<typeof getEventType>>;
|
||||
|
||||
const getUser = async (...args: Parameters<typeof _getUser>): Promise<ReturnType<typeof _getUser>> => {
|
||||
return monitorCallbackAsync(_getUser, ...args);
|
||||
};
|
||||
|
||||
const _getUser = async (where: Prisma.UserWhereInput) => {
|
||||
return await prisma.user.findFirst({
|
||||
where,
|
||||
select: {
|
||||
...availabilityUserSelect,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type User = Awaited<ReturnType<typeof getUser>>;
|
||||
|
||||
export const getCurrentSeats = async (
|
||||
...args: Parameters<typeof _getCurrentSeats>
|
||||
): Promise<ReturnType<typeof _getCurrentSeats>> => {
|
||||
return monitorCallbackAsync(_getCurrentSeats, ...args);
|
||||
};
|
||||
|
||||
const _getCurrentSeats = async (
|
||||
eventType: {
|
||||
id?: number;
|
||||
schedulingType?: SchedulingType | null;
|
||||
hosts?: {
|
||||
user: {
|
||||
email: string;
|
||||
};
|
||||
}[];
|
||||
},
|
||||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs
|
||||
) => {
|
||||
const { schedulingType, hosts, id } = eventType;
|
||||
const hostEmails = hosts?.map((host) => host.user.email);
|
||||
const isTeamEvent =
|
||||
schedulingType === SchedulingType.MANAGED ||
|
||||
schedulingType === SchedulingType.ROUND_ROBIN ||
|
||||
schedulingType === SchedulingType.COLLECTIVE;
|
||||
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
eventTypeId: id,
|
||||
startTime: {
|
||||
gte: dateFrom.format(),
|
||||
lte: dateTo.format(),
|
||||
},
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
startTime: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
attendees: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return bookings.map((booking) => {
|
||||
const attendees = isTeamEvent
|
||||
? booking.attendees.filter((attendee) => !hostEmails?.includes(attendee.email))
|
||||
: booking.attendees;
|
||||
|
||||
return {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
_count: {
|
||||
attendees: attendees.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
|
||||
|
||||
export const getUserAvailability = async (
|
||||
...args: Parameters<typeof _getUserAvailability>
|
||||
): Promise<ReturnType<typeof _getUserAvailability>> => {
|
||||
return monitorCallbackAsync(_getUserAvailability, ...args);
|
||||
};
|
||||
|
||||
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
|
||||
const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse(
|
||||
query: {
|
||||
withSource?: boolean;
|
||||
username?: string;
|
||||
userId?: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
afterEventBuffer?: number;
|
||||
beforeEventBuffer?: number;
|
||||
duration?: number;
|
||||
returnDateOverrides: boolean;
|
||||
},
|
||||
initialData?: {
|
||||
user?: User;
|
||||
eventType?: EventType;
|
||||
currentSeats?: CurrentSeats;
|
||||
rescheduleUid?: string | null;
|
||||
currentBookings?: (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
|
||||
eventType: Pick<
|
||||
PrismaEventType,
|
||||
"id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"
|
||||
> | null;
|
||||
_count?: {
|
||||
seatsReferences: number;
|
||||
};
|
||||
})[];
|
||||
busyTimesFromLimitsBookings: EventBusyDetails[];
|
||||
}
|
||||
) {
|
||||
const {
|
||||
username,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
afterEventBuffer,
|
||||
beforeEventBuffer,
|
||||
duration,
|
||||
returnDateOverrides,
|
||||
} = availabilitySchema.parse(query);
|
||||
|
||||
if (!dateFrom.isValid() || !dateTo.isValid()) {
|
||||
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
|
||||
}
|
||||
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (username) where.username = username;
|
||||
if (userId) where.id = userId;
|
||||
|
||||
const user = initialData?.user || (await getUser(where));
|
||||
|
||||
if (!user) throw new HttpError({ statusCode: 404, message: "No user found in getUserAvailability" });
|
||||
log.debug(
|
||||
"getUserAvailability for user",
|
||||
safeStringify({ user: { id: user.id }, slot: { dateFrom, dateTo } })
|
||||
);
|
||||
|
||||
let eventType: EventType | null = initialData?.eventType || null;
|
||||
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
|
||||
|
||||
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
|
||||
current bookings with a seats event type and display them on the calendar, even if they are full */
|
||||
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
|
||||
if (!currentSeats && eventType?.seatsPerTimeSlot) {
|
||||
currentSeats = await getCurrentSeats(eventType, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
|
||||
const durationLimits = parseDurationLimit(eventType?.durationLimits);
|
||||
|
||||
const busyTimesFromLimits =
|
||||
eventType && (bookingLimits || durationLimits)
|
||||
? await getBusyTimesFromLimits(
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
duration,
|
||||
eventType,
|
||||
initialData?.busyTimesFromLimitsBookings ?? []
|
||||
)
|
||||
: [];
|
||||
|
||||
// TODO: only query what we need after applying limits (shrink date range)
|
||||
const getBusyTimesStart = dateFrom.toISOString();
|
||||
const getBusyTimesEnd = dateTo.toISOString();
|
||||
|
||||
const busyTimes = await getBusyTimes({
|
||||
credentials: user.credentials,
|
||||
startTime: getBusyTimesStart,
|
||||
endTime: getBusyTimesEnd,
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
username: `${user.username}`,
|
||||
beforeEventBuffer,
|
||||
afterEventBuffer,
|
||||
selectedCalendars: user.selectedCalendars,
|
||||
seatedEvent: !!eventType?.seatsPerTimeSlot,
|
||||
rescheduleUid: initialData?.rescheduleUid || null,
|
||||
duration,
|
||||
currentBookings: initialData?.currentBookings,
|
||||
});
|
||||
|
||||
const detailedBusyTimes: EventBusyDetails[] = [
|
||||
...busyTimes.map((a) => ({
|
||||
...a,
|
||||
start: dayjs(a.start).toISOString(),
|
||||
end: dayjs(a.end).toISOString(),
|
||||
title: a.title,
|
||||
source: query.withSource ? a.source : undefined,
|
||||
})),
|
||||
...busyTimesFromLimits,
|
||||
];
|
||||
|
||||
const userSchedule = user.schedules.filter(
|
||||
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
||||
)[0];
|
||||
|
||||
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
|
||||
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
|
||||
|
||||
const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id;
|
||||
|
||||
log.debug(
|
||||
"Using schedule:",
|
||||
safeStringify({
|
||||
chosenSchedule: schedule,
|
||||
eventTypeSchedule: eventType?.schedule,
|
||||
userSchedule: userSchedule,
|
||||
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
|
||||
})
|
||||
);
|
||||
|
||||
const startGetWorkingHours = performance.now();
|
||||
|
||||
const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone;
|
||||
|
||||
if (
|
||||
!(schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability))
|
||||
) {
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule });
|
||||
}
|
||||
|
||||
const availability = (
|
||||
schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)
|
||||
).map((a) => ({
|
||||
...a,
|
||||
userId: user.id,
|
||||
}));
|
||||
|
||||
const workingHours = getWorkingHours({ timeZone }, availability);
|
||||
|
||||
const endGetWorkingHours = performance.now();
|
||||
|
||||
const dateOverrides: TimeRange[] = [];
|
||||
// NOTE: getSchedule is currently calling this function for every user in a team event
|
||||
// but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor
|
||||
// since other callers do using this data.
|
||||
if (returnDateOverrides) {
|
||||
const availabilityWithDates = availability.filter((availability) => !!availability.date);
|
||||
|
||||
for (let i = 0; i < availabilityWithDates.length; i++) {
|
||||
const override = availabilityWithDates[i];
|
||||
const startTime = dayjs.utc(override.startTime);
|
||||
const endTime = dayjs.utc(override.endTime);
|
||||
const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute());
|
||||
const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute());
|
||||
if (
|
||||
overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") ||
|
||||
overrideEndDate.isBetween(dateFrom, dateTo, null, "[]")
|
||||
) {
|
||||
dateOverrides.push({
|
||||
start: overrideStartDate.toDate(),
|
||||
end: overrideEndDate.toDate(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const datesOutOfOffice = await getOutOfOfficeDays({
|
||||
userId: user.id,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
availability,
|
||||
});
|
||||
|
||||
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
availability,
|
||||
timeZone,
|
||||
travelSchedules: isDefaultSchedule
|
||||
? user.travelSchedules.map((schedule) => {
|
||||
return {
|
||||
startDate: dayjs(schedule.startDate),
|
||||
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
outOfOffice: datesOutOfOffice,
|
||||
});
|
||||
|
||||
const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
|
||||
start: dayjs(busy.start),
|
||||
end: dayjs(busy.end),
|
||||
}));
|
||||
|
||||
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
|
||||
|
||||
const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(oooExcludedDateRanges, formattedBusyTimes);
|
||||
|
||||
log.debug(
|
||||
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
|
||||
JSON.stringify({
|
||||
workingHoursInUtc: workingHours,
|
||||
dateOverrides,
|
||||
dateRangesAsPerAvailability: dateRanges,
|
||||
dateRangesInWhichUserIsAvailable,
|
||||
detailedBusyTimes,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
busy: detailedBusyTimes,
|
||||
timeZone,
|
||||
dateRanges: dateRangesInWhichUserIsAvailable,
|
||||
oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO,
|
||||
workingHours,
|
||||
dateOverrides,
|
||||
currentSeats,
|
||||
datesOutOfOffice,
|
||||
};
|
||||
};
|
||||
|
||||
const getPeriodStartDatesBetween = (
|
||||
...args: Parameters<typeof _getPeriodStartDatesBetween>
|
||||
): ReturnType<typeof _getPeriodStartDatesBetween> => {
|
||||
return monitorCallbackSync(_getPeriodStartDatesBetween, ...args);
|
||||
};
|
||||
|
||||
const _getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit) => {
|
||||
const dates = [];
|
||||
let startDate = dayjs(dateFrom).startOf(period);
|
||||
const endDate = dayjs(dateTo).endOf(period);
|
||||
while (startDate.isBefore(endDate)) {
|
||||
dates.push(startDate);
|
||||
startDate = startDate.add(1, period);
|
||||
}
|
||||
return dates;
|
||||
};
|
||||
|
||||
type BusyMapKey = `${IntervalLimitUnit}-${ReturnType<Dayjs["toISOString"]>}`;
|
||||
|
||||
/**
|
||||
* Helps create, check, and return busy times from limits (with parallel support)
|
||||
*/
|
||||
class LimitManager {
|
||||
private busyMap: Map<BusyMapKey, EventBusyDate> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a busy map key
|
||||
*/
|
||||
private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey {
|
||||
return `${unit}-${start.startOf(unit).toISOString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if already marked busy by ancestors or siblings
|
||||
*/
|
||||
isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) {
|
||||
if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true;
|
||||
|
||||
if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) {
|
||||
return true;
|
||||
} else if (
|
||||
unit === "week" &&
|
||||
// weeks can be part of two months
|
||||
((this.busyMap.has(LimitManager.createKey(start, "month")) &&
|
||||
this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) ||
|
||||
this.busyMap.has(LimitManager.createKey(start, "week")))
|
||||
) {
|
||||
return true;
|
||||
} else if (
|
||||
unit === "day" &&
|
||||
(this.busyMap.has(LimitManager.createKey(start, "month")) ||
|
||||
this.busyMap.has(LimitManager.createKey(start, "week")) ||
|
||||
this.busyMap.has(LimitManager.createKey(start, "day")))
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new busy time
|
||||
*/
|
||||
addBusyTime(start: Dayjs, unit: IntervalLimitUnit) {
|
||||
this.busyMap.set(`${unit}-${start.toISOString()}`, {
|
||||
start: start.toISOString(),
|
||||
end: start.endOf(unit).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all busy times
|
||||
*/
|
||||
getBusyTimes() {
|
||||
return Array.from(this.busyMap.values());
|
||||
}
|
||||
}
|
||||
|
||||
const getBusyTimesFromLimits = async (
|
||||
...args: Parameters<typeof _getBusyTimesFromLimits>
|
||||
): Promise<ReturnType<typeof _getBusyTimesFromLimits>> => {
|
||||
return monitorCallbackAsync(_getBusyTimesFromLimits, ...args);
|
||||
};
|
||||
|
||||
const _getBusyTimesFromLimits = async (
|
||||
bookingLimits: IntervalLimit | null,
|
||||
durationLimits: IntervalLimit | null,
|
||||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs,
|
||||
duration: number | undefined,
|
||||
eventType: NonNullable<EventType>,
|
||||
bookings: EventBusyDetails[]
|
||||
) => {
|
||||
performance.mark("limitsStart");
|
||||
|
||||
// shared amongst limiters to prevent processing known busy periods
|
||||
const limitManager = new LimitManager();
|
||||
|
||||
// run this first, as counting bookings should always run faster..
|
||||
if (bookingLimits) {
|
||||
performance.mark("bookingLimitsStart");
|
||||
await getBusyTimesFromBookingLimits(
|
||||
bookings,
|
||||
bookingLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventType.id,
|
||||
limitManager
|
||||
);
|
||||
performance.mark("bookingLimitsEnd");
|
||||
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
|
||||
}
|
||||
|
||||
// ..than adding up durations (especially for the whole year)
|
||||
if (durationLimits) {
|
||||
performance.mark("durationLimitsStart");
|
||||
await getBusyTimesFromDurationLimits(
|
||||
bookings,
|
||||
durationLimits,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
duration,
|
||||
eventType,
|
||||
limitManager
|
||||
);
|
||||
performance.mark("durationLimitsEnd");
|
||||
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
|
||||
}
|
||||
|
||||
performance.mark("limitsEnd");
|
||||
performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd");
|
||||
|
||||
return limitManager.getBusyTimes();
|
||||
};
|
||||
|
||||
const getBusyTimesFromBookingLimits = async (
|
||||
...args: Parameters<typeof _getBusyTimesFromBookingLimits>
|
||||
): Promise<ReturnType<typeof _getBusyTimesFromBookingLimits>> => {
|
||||
return monitorCallbackAsync(_getBusyTimesFromBookingLimits, ...args);
|
||||
};
|
||||
|
||||
const _getBusyTimesFromBookingLimits = async (
|
||||
bookings: EventBusyDetails[],
|
||||
bookingLimits: IntervalLimit,
|
||||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs,
|
||||
eventTypeId: number,
|
||||
limitManager: LimitManager
|
||||
) => {
|
||||
for (const key of descendingLimitKeys) {
|
||||
const limit = bookingLimits?.[key];
|
||||
if (!limit) continue;
|
||||
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
|
||||
for (const periodStart of periodStartDates) {
|
||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||
|
||||
// special handling of yearly limits to improve performance
|
||||
if (unit === "year") {
|
||||
try {
|
||||
await checkBookingLimit({
|
||||
eventStartDate: periodStart.toDate(),
|
||||
limitingNumber: limit,
|
||||
eventId: eventTypeId,
|
||||
key,
|
||||
});
|
||||
} catch (_) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const periodEnd = periodStart.endOf(unit);
|
||||
let totalBookings = 0;
|
||||
|
||||
for (const booking of bookings) {
|
||||
// consider booking part of period independent of end date
|
||||
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
|
||||
continue;
|
||||
}
|
||||
totalBookings++;
|
||||
if (totalBookings >= limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getBusyTimesFromDurationLimits = async (
|
||||
...args: Parameters<typeof _getBusyTimesFromDurationLimits>
|
||||
): Promise<ReturnType<typeof _getBusyTimesFromDurationLimits>> => {
|
||||
return monitorCallbackAsync(_getBusyTimesFromDurationLimits, ...args);
|
||||
};
|
||||
|
||||
const _getBusyTimesFromDurationLimits = async (
|
||||
bookings: EventBusyDetails[],
|
||||
durationLimits: IntervalLimit,
|
||||
dateFrom: Dayjs,
|
||||
dateTo: Dayjs,
|
||||
duration: number | undefined,
|
||||
eventType: NonNullable<EventType>,
|
||||
limitManager: LimitManager
|
||||
) => {
|
||||
for (const key of descendingLimitKeys) {
|
||||
const limit = durationLimits?.[key];
|
||||
if (!limit) continue;
|
||||
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
|
||||
for (const periodStart of periodStartDates) {
|
||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||
|
||||
const selectedDuration = (duration || eventType.length) ?? 0;
|
||||
|
||||
if (selectedDuration > limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
continue;
|
||||
}
|
||||
|
||||
// special handling of yearly limits to improve performance
|
||||
if (unit === "year") {
|
||||
const totalYearlyDuration = await getTotalBookingDuration({
|
||||
eventId: eventType.id,
|
||||
startDate: periodStart.toDate(),
|
||||
endDate: periodStart.endOf(unit).toDate(),
|
||||
});
|
||||
if (totalYearlyDuration + selectedDuration > limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const periodEnd = periodStart.endOf(unit);
|
||||
let totalDuration = selectedDuration;
|
||||
|
||||
for (const booking of bookings) {
|
||||
// consider booking part of period independent of end date
|
||||
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
|
||||
continue;
|
||||
}
|
||||
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
|
||||
if (totalDuration > limit) {
|
||||
limitManager.addBusyTime(periodStart, unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface GetUserAvailabilityParamsDTO {
|
||||
userId: number;
|
||||
dateFrom: Dayjs;
|
||||
dateTo: Dayjs;
|
||||
availability: (DateOverride | WorkingHours)[];
|
||||
}
|
||||
|
||||
export interface IFromUser {
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export interface IToUser {
|
||||
id: number;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export interface IOutOfOfficeData {
|
||||
[key: string]: {
|
||||
fromUser: IFromUser | null;
|
||||
toUser?: IToUser | null;
|
||||
reason?: string | null;
|
||||
emoji?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const getOutOfOfficeDays = async (
|
||||
...args: Parameters<typeof _getOutOfOfficeDays>
|
||||
): Promise<ReturnType<typeof _getOutOfOfficeDays>> => {
|
||||
return monitorCallbackAsync(_getOutOfOfficeDays, ...args);
|
||||
};
|
||||
|
||||
const _getOutOfOfficeDays = async ({
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
availability,
|
||||
}: GetUserAvailabilityParamsDTO): Promise<IOutOfOfficeData> => {
|
||||
const outOfOfficeDays = await prisma.outOfOfficeEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
// outside of range
|
||||
// (start <= 'dateTo' AND end >= 'dateFrom')
|
||||
{
|
||||
start: {
|
||||
lte: dateTo.toISOString(),
|
||||
},
|
||||
end: {
|
||||
gte: dateFrom.toISOString(),
|
||||
},
|
||||
},
|
||||
// start is between dateFrom and dateTo but end is outside of range
|
||||
// (start <= 'dateTo' AND end >= 'dateTo')
|
||||
{
|
||||
start: {
|
||||
lte: dateTo.toISOString(),
|
||||
},
|
||||
|
||||
end: {
|
||||
gte: dateTo.toISOString(),
|
||||
},
|
||||
},
|
||||
// end is between dateFrom and dateTo but start is outside of range
|
||||
// (start <= 'dateFrom' OR end <= 'dateTo')
|
||||
{
|
||||
start: {
|
||||
lte: dateFrom.toISOString(),
|
||||
},
|
||||
|
||||
end: {
|
||||
lte: dateTo.toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
start: true,
|
||||
end: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!outOfOfficeDays.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => {
|
||||
// here we should use startDate or today if start is before today
|
||||
// consider timezone in start and end date range
|
||||
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
|
||||
? dayjs().utc().startOf("day")
|
||||
: dayjs(start).utc().startOf("day");
|
||||
|
||||
// get number of day in the week and see if it's on the availability
|
||||
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
|
||||
const endDateRange = dayjs(end).utc().endOf("day");
|
||||
|
||||
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
|
||||
const dayNumberOnWeek = date.day();
|
||||
|
||||
if (!flattenDays?.includes(dayNumberOnWeek)) {
|
||||
continue; // Skip to the next iteration if day not found in flattenDays
|
||||
}
|
||||
|
||||
acc[date.format("YYYY-MM-DD")] = {
|
||||
// @TODO: would be good having start and end availability time here, but for now should be good
|
||||
// you can obtain that from user availability defined outside of here
|
||||
fromUser: { id: user.id, displayName: user.name },
|
||||
// optional chaining destructuring toUser
|
||||
toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
|
||||
reason: !!reason ? reason.reason : null,
|
||||
emoji: !!reason ? reason.emoji : null,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
type GetUserAvailabilityQuery = Parameters<typeof getUserAvailability>[0];
|
||||
type GetUserAvailabilityInitialData = NonNullable<Parameters<typeof getUserAvailability>[1]>;
|
||||
|
||||
const _getUsersAvailability = async ({
|
||||
users,
|
||||
query,
|
||||
initialData,
|
||||
}: {
|
||||
users: (NonNullable<GetUserAvailabilityInitialData["user"]> & {
|
||||
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
|
||||
})[];
|
||||
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
|
||||
initialData?: Omit<GetUserAvailabilityInitialData, "user">;
|
||||
}) => {
|
||||
return await Promise.all(
|
||||
users.map((user) =>
|
||||
_getUserAvailability(
|
||||
{
|
||||
...query,
|
||||
userId: user.id,
|
||||
username: user.username || "",
|
||||
},
|
||||
initialData
|
||||
? {
|
||||
...initialData,
|
||||
user,
|
||||
currentBookings: user.currentBookings,
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const getUsersAvailability = async (
|
||||
...args: Parameters<typeof _getUsersAvailability>
|
||||
): Promise<ReturnType<typeof _getUsersAvailability>> => {
|
||||
return monitorCallbackAsync(_getUsersAvailability, ...args);
|
||||
};
|
||||
Reference in New Issue
Block a user