628 lines
19 KiB
TypeScript
628 lines
19 KiB
TypeScript
// eslint-disable-next-line no-restricted-imports
|
|
import { countBy } from "lodash";
|
|
import { v4 as uuid } from "uuid";
|
|
|
|
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
|
|
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
|
|
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
|
import type { Dayjs } from "@calcom/dayjs";
|
|
import dayjs from "@calcom/dayjs";
|
|
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
|
import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds";
|
|
import logger from "@calcom/lib/logger";
|
|
import { performance } from "@calcom/lib/server/perfObserver";
|
|
import getSlots from "@calcom/lib/slots";
|
|
import prisma, { availabilityUserSelect } from "@calcom/prisma";
|
|
import { SchedulingType } from "@calcom/prisma/enums";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
|
import type { EventBusyDate } from "@calcom/types/Calendar";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import type { GetScheduleOptions } from "./getSchedule.handler";
|
|
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
|
|
|
|
export const checkIfIsAvailable = ({
|
|
time,
|
|
busy,
|
|
eventLength,
|
|
currentSeats,
|
|
}: {
|
|
time: Dayjs;
|
|
busy: EventBusyDate[];
|
|
eventLength: number;
|
|
currentSeats?: CurrentSeats;
|
|
}): boolean => {
|
|
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
|
|
return true;
|
|
}
|
|
|
|
const slotEndTime = time.add(eventLength, "minutes").utc();
|
|
const slotStartTime = time.utc();
|
|
|
|
return busy.every((busyTime) => {
|
|
const startTime = dayjs.utc(busyTime.start).utc();
|
|
const endTime = dayjs.utc(busyTime.end);
|
|
|
|
if (endTime.isBefore(slotStartTime) || startTime.isAfter(slotEndTime)) {
|
|
return true;
|
|
}
|
|
|
|
if (slotStartTime.isBetween(startTime, endTime, null, "[)")) {
|
|
return false;
|
|
} else if (slotEndTime.isBetween(startTime, endTime, null, "(]")) {
|
|
return false;
|
|
}
|
|
|
|
// Check if start times are the same
|
|
if (time.utc().isBetween(startTime, endTime, null, "[)")) {
|
|
return false;
|
|
}
|
|
// Check if slot end time is between start and end time
|
|
else if (slotEndTime.isBetween(startTime, endTime)) {
|
|
return false;
|
|
}
|
|
// Check if startTime is between slot
|
|
else if (startTime.isBetween(time, slotEndTime)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
};
|
|
|
|
async function getEventTypeId({
|
|
slug,
|
|
eventTypeSlug,
|
|
isTeamEvent,
|
|
organizationDetails,
|
|
}: {
|
|
slug?: string;
|
|
eventTypeSlug?: string;
|
|
isTeamEvent: boolean;
|
|
organizationDetails?: { currentOrgDomain: string | null; isValidOrgDomain: boolean };
|
|
}) {
|
|
if (!eventTypeSlug || !slug) return null;
|
|
|
|
let teamId;
|
|
let userId;
|
|
if (isTeamEvent) {
|
|
teamId = await getTeamIdFromSlug(
|
|
slug,
|
|
organizationDetails ?? { currentOrgDomain: null, isValidOrgDomain: false }
|
|
);
|
|
} else {
|
|
userId = await getUserIdFromUsername(
|
|
slug,
|
|
organizationDetails ?? { currentOrgDomain: null, isValidOrgDomain: false }
|
|
);
|
|
}
|
|
const eventType = await prisma.eventType.findFirst({
|
|
where: {
|
|
slug: eventTypeSlug,
|
|
...(teamId ? { teamId } : {}),
|
|
...(userId ? { userId } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
if (!eventType) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
return eventType?.id;
|
|
}
|
|
|
|
export async function getEventType(
|
|
input: TGetScheduleInputSchema,
|
|
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
|
|
) {
|
|
const { eventTypeSlug, usernameList, isTeamEvent } = input;
|
|
const eventTypeId =
|
|
input.eventTypeId ||
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
(await getEventTypeId({
|
|
slug: usernameList?.[0],
|
|
eventTypeSlug: eventTypeSlug,
|
|
isTeamEvent,
|
|
organizationDetails,
|
|
}));
|
|
|
|
if (!eventTypeId) {
|
|
return null;
|
|
}
|
|
|
|
const eventType = await prisma.eventType.findUnique({
|
|
where: {
|
|
id: eventTypeId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
minimumBookingNotice: true,
|
|
length: true,
|
|
offsetStart: true,
|
|
seatsPerTimeSlot: true,
|
|
timeZone: true,
|
|
slotInterval: true,
|
|
beforeEventBuffer: true,
|
|
afterEventBuffer: true,
|
|
bookingLimits: true,
|
|
durationLimits: true,
|
|
schedulingType: true,
|
|
periodType: true,
|
|
periodStartDate: true,
|
|
periodEndDate: true,
|
|
periodCountCalendarDays: true,
|
|
periodDays: true,
|
|
metadata: true,
|
|
schedule: {
|
|
select: {
|
|
availability: true,
|
|
timeZone: true,
|
|
},
|
|
},
|
|
availability: {
|
|
select: {
|
|
date: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
days: true,
|
|
},
|
|
},
|
|
hosts: {
|
|
select: {
|
|
isFixed: true,
|
|
user: {
|
|
select: {
|
|
credentials: true,
|
|
...availabilityUserSelect,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
users: {
|
|
select: {
|
|
credentials: true,
|
|
...availabilityUserSelect,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!eventType) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...eventType,
|
|
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
|
};
|
|
}
|
|
|
|
export async function getDynamicEventType(input: TGetScheduleInputSchema) {
|
|
// For dynamic booking, we need to get and update user credentials, schedule and availability in the eventTypeObject as they're required in the new availability logic
|
|
if (!input.eventTypeSlug) {
|
|
throw new TRPCError({
|
|
message: "eventTypeSlug is required for dynamic booking",
|
|
code: "BAD_REQUEST",
|
|
});
|
|
}
|
|
const dynamicEventType = getDefaultEvent(input.eventTypeSlug);
|
|
const users = await prisma.user.findMany({
|
|
where: {
|
|
username: {
|
|
in: Array.isArray(input.usernameList)
|
|
? input.usernameList
|
|
: input.usernameList
|
|
? [input.usernameList]
|
|
: [],
|
|
},
|
|
},
|
|
select: {
|
|
allowDynamicBooking: true,
|
|
...availabilityUserSelect,
|
|
credentials: true,
|
|
},
|
|
});
|
|
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
|
|
if (!isDynamicAllowed) {
|
|
throw new TRPCError({
|
|
message: "Some of the users in this group do not allow dynamic booking",
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
}
|
|
return Object.assign({}, dynamicEventType, {
|
|
users,
|
|
});
|
|
}
|
|
|
|
export function getRegularOrDynamicEventType(
|
|
input: TGetScheduleInputSchema,
|
|
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
|
|
) {
|
|
const isDynamicBooking = input.usernameList && input.usernameList.length > 1;
|
|
return isDynamicBooking ? getDynamicEventType(input) : getEventType(input, organizationDetails);
|
|
}
|
|
|
|
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
|
const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? "");
|
|
|
|
if (input.debug === true) {
|
|
logger.setSettings({ minLevel: "debug" });
|
|
}
|
|
if (process.env.INTEGRATION_TEST_MODE === "true") {
|
|
logger.setSettings({ minLevel: "silly" });
|
|
}
|
|
const startPrismaEventTypeGet = performance.now();
|
|
const eventType = await getRegularOrDynamicEventType(input, orgDetails);
|
|
const endPrismaEventTypeGet = performance.now();
|
|
logger.debug(
|
|
`Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms for event:${
|
|
input.eventTypeId
|
|
}`
|
|
);
|
|
if (!eventType) {
|
|
throw new TRPCError({ code: "NOT_FOUND" });
|
|
}
|
|
|
|
const getStartTime = (startTimeInput: string, timeZone?: string) => {
|
|
const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice, "minutes");
|
|
const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone);
|
|
|
|
return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime;
|
|
};
|
|
|
|
const startTime = getStartTime(input.startTime, input.timeZone);
|
|
const endTime =
|
|
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
|
|
|
|
if (!startTime.isValid() || !endTime.isValid()) {
|
|
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
|
|
}
|
|
let currentSeats: CurrentSeats | undefined;
|
|
|
|
let usersWithCredentials = eventType.users.map((user) => ({
|
|
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,
|
|
...user,
|
|
}));
|
|
// overwrite if it is a team event & hosts is set, otherwise keep using users.
|
|
if (eventType.schedulingType && !!eventType.hosts?.length) {
|
|
usersWithCredentials = eventType.hosts.map(({ isFixed, user }) => ({ isFixed, ...user }));
|
|
}
|
|
|
|
const durationToUse = input.duration || 0;
|
|
|
|
const startTimeDate =
|
|
input.rescheduleUid && durationToUse
|
|
? startTime.subtract(durationToUse, "minute").toDate()
|
|
: startTime.toDate();
|
|
|
|
const endTimeDate =
|
|
input.rescheduleUid && durationToUse ? endTime.add(durationToUse, "minute").toDate() : endTime.toDate();
|
|
|
|
const sharedQuery = {
|
|
startTime: { gte: startTimeDate },
|
|
endTime: { lte: endTimeDate },
|
|
status: {
|
|
in: [BookingStatus.ACCEPTED],
|
|
},
|
|
};
|
|
|
|
const currentBookingsAllUsers = await prisma.booking.findMany({
|
|
where: {
|
|
OR: [
|
|
// User is primary host (individual events, or primary organizer)
|
|
{
|
|
...sharedQuery,
|
|
userId: {
|
|
in: usersWithCredentials.map((user) => user.id),
|
|
},
|
|
},
|
|
// The current user has a different booking at this time he/she attends
|
|
{
|
|
...sharedQuery,
|
|
attendees: {
|
|
some: {
|
|
email: {
|
|
in: usersWithCredentials.map((user) => user.email),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
uid: true,
|
|
userId: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
title: true,
|
|
attendees: true,
|
|
eventType: {
|
|
select: {
|
|
id: true,
|
|
afterEventBuffer: true,
|
|
beforeEventBuffer: true,
|
|
seatsPerTimeSlot: true,
|
|
},
|
|
},
|
|
...(!!eventType?.seatsPerTimeSlot && {
|
|
_count: {
|
|
select: {
|
|
seatsReferences: true,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
/* We get all users working hours and busy slots */
|
|
const userAvailability = await Promise.all(
|
|
usersWithCredentials.map(async (currentUser) => {
|
|
const {
|
|
busy,
|
|
dateRanges,
|
|
currentSeats: _currentSeats,
|
|
timeZone,
|
|
} = await getUserAvailability(
|
|
{
|
|
userId: currentUser.id,
|
|
username: currentUser.username || "",
|
|
dateFrom: startTime.format(),
|
|
dateTo: endTime.format(),
|
|
eventTypeId: eventType.id,
|
|
afterEventBuffer: eventType.afterEventBuffer,
|
|
beforeEventBuffer: eventType.beforeEventBuffer,
|
|
duration: input.duration || 0,
|
|
},
|
|
{
|
|
user: currentUser,
|
|
eventType,
|
|
currentSeats,
|
|
rescheduleUid: input.rescheduleUid,
|
|
currentBookings: currentBookingsAllUsers
|
|
.filter(
|
|
(b) => b.userId === currentUser.id || b.attendees?.some((a) => a.email === currentUser.email)
|
|
)
|
|
.map((bookings) => {
|
|
const { attendees: _attendees, ...bookingWithoutAttendees } = bookings;
|
|
return bookingWithoutAttendees;
|
|
}),
|
|
}
|
|
);
|
|
if (!currentSeats && _currentSeats) currentSeats = _currentSeats;
|
|
return {
|
|
timeZone,
|
|
dateRanges,
|
|
busy,
|
|
user: currentUser,
|
|
};
|
|
})
|
|
);
|
|
|
|
const availabilityCheckProps = {
|
|
eventLength: input.duration || eventType.length,
|
|
currentSeats,
|
|
};
|
|
|
|
const isTimeWithinBounds = (_time: Parameters<typeof isTimeOutOfBounds>[0]) =>
|
|
!isTimeOutOfBounds(_time, {
|
|
periodType: eventType.periodType,
|
|
periodStartDate: eventType.periodStartDate,
|
|
periodEndDate: eventType.periodEndDate,
|
|
periodCountCalendarDays: eventType.periodCountCalendarDays,
|
|
periodDays: eventType.periodDays,
|
|
});
|
|
|
|
const getSlotsTime = 0;
|
|
const checkForAvailabilityTime = 0;
|
|
const getSlotsCount = 0;
|
|
const checkForAvailabilityCount = 0;
|
|
|
|
const timeSlots = getSlots({
|
|
inviteeDate: startTime,
|
|
eventLength: input.duration || eventType.length,
|
|
offsetStart: eventType.offsetStart,
|
|
dateRanges: getAggregatedAvailability(userAvailability, eventType.schedulingType),
|
|
minimumBookingNotice: eventType.minimumBookingNotice,
|
|
frequency: eventType.slotInterval || input.duration || eventType.length,
|
|
organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone,
|
|
});
|
|
|
|
let availableTimeSlots: typeof timeSlots = [];
|
|
// Load cached busy slots
|
|
const selectedSlots =
|
|
/* FIXME: For some reason this returns undefined while testing in Jest */
|
|
(await prisma.selectedSlots.findMany({
|
|
where: {
|
|
userId: { in: usersWithCredentials.map((user) => user.id) },
|
|
releaseAt: { gt: dayjs.utc().format() },
|
|
},
|
|
select: {
|
|
id: true,
|
|
slotUtcStartDate: true,
|
|
slotUtcEndDate: true,
|
|
userId: true,
|
|
isSeat: true,
|
|
eventTypeId: true,
|
|
},
|
|
})) || [];
|
|
await prisma.selectedSlots.deleteMany({
|
|
where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } },
|
|
});
|
|
|
|
availableTimeSlots = timeSlots;
|
|
|
|
if (selectedSlots?.length > 0) {
|
|
let occupiedSeats: typeof selectedSlots = selectedSlots.filter(
|
|
(item) => item.isSeat && item.eventTypeId === eventType.id
|
|
);
|
|
if (occupiedSeats?.length) {
|
|
const addedToCurrentSeats: string[] = [];
|
|
if (typeof availabilityCheckProps.currentSeats !== undefined) {
|
|
availabilityCheckProps.currentSeats = (availabilityCheckProps.currentSeats as CurrentSeats).map(
|
|
(item) => {
|
|
const attendees =
|
|
occupiedSeats.filter(
|
|
(seat) => seat.slotUtcStartDate.toISOString() === item.startTime.toISOString()
|
|
)?.length || 0;
|
|
if (attendees) addedToCurrentSeats.push(item.startTime.toISOString());
|
|
return {
|
|
...item,
|
|
_count: {
|
|
attendees: item._count.attendees + attendees,
|
|
},
|
|
};
|
|
}
|
|
) as CurrentSeats;
|
|
occupiedSeats = occupiedSeats.filter(
|
|
(item) => !addedToCurrentSeats.includes(item.slotUtcStartDate.toISOString())
|
|
);
|
|
}
|
|
|
|
if (occupiedSeats?.length && typeof availabilityCheckProps.currentSeats === undefined)
|
|
availabilityCheckProps.currentSeats = [];
|
|
const occupiedSeatsCount = countBy(occupiedSeats, (item) => item.slotUtcStartDate.toISOString());
|
|
Object.keys(occupiedSeatsCount).forEach((date) => {
|
|
(availabilityCheckProps.currentSeats as CurrentSeats).push({
|
|
uid: uuid(),
|
|
startTime: dayjs(date).toDate(),
|
|
_count: { attendees: occupiedSeatsCount[date] },
|
|
});
|
|
});
|
|
currentSeats = availabilityCheckProps.currentSeats;
|
|
}
|
|
availableTimeSlots = availableTimeSlots
|
|
.map((slot) => {
|
|
const busy = selectedSlots.reduce<EventBusyDate[]>((r, c) => {
|
|
if (!c.isSeat) {
|
|
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
|
|
}
|
|
return r;
|
|
}, []);
|
|
|
|
if (
|
|
checkIfIsAvailable({
|
|
time: slot.time,
|
|
busy,
|
|
...availabilityCheckProps,
|
|
})
|
|
) {
|
|
return slot;
|
|
}
|
|
return undefined;
|
|
})
|
|
.filter(
|
|
(
|
|
item:
|
|
| {
|
|
time: dayjs.Dayjs;
|
|
userIds?: number[] | undefined;
|
|
}
|
|
| undefined
|
|
): item is {
|
|
time: dayjs.Dayjs;
|
|
userIds?: number[] | undefined;
|
|
} => {
|
|
return !!item;
|
|
}
|
|
);
|
|
}
|
|
|
|
availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time));
|
|
// fr-CA uses YYYY-MM-DD
|
|
const formatter = new Intl.DateTimeFormat("fr-CA", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
timeZone: input.timeZone,
|
|
});
|
|
|
|
const computedAvailableSlots = availableTimeSlots.reduce(
|
|
(
|
|
r: Record<string, { time: string; users: string[]; attendees?: number; bookingUid?: string }[]>,
|
|
{ time, ...passThroughProps }
|
|
) => {
|
|
// TODO: Adds unit tests to prevent regressions in getSchedule (try multiple timezones)
|
|
|
|
// This used to be _time.tz(input.timeZone) but Dayjs tz() is slow.
|
|
// toLocaleDateString slugish, using Intl.DateTimeFormat we get the desired speed results.
|
|
const dateString = formatter.format(time.toDate());
|
|
|
|
r[dateString] = r[dateString] || [];
|
|
r[dateString].push({
|
|
...passThroughProps,
|
|
time: time.toISOString(),
|
|
users: (eventType.hosts
|
|
? eventType.hosts.map((hostUserWithCredentials) => {
|
|
const { user } = hostUserWithCredentials;
|
|
return user;
|
|
})
|
|
: eventType.users
|
|
).map((user) => user.username || ""),
|
|
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
|
|
...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && {
|
|
attendees:
|
|
currentSeats[
|
|
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
|
|
]._count.attendees,
|
|
bookingUid:
|
|
currentSeats[
|
|
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
|
|
].uid,
|
|
}),
|
|
});
|
|
return r;
|
|
},
|
|
Object.create(null)
|
|
);
|
|
|
|
logger.debug(`getSlots took ${getSlotsTime}ms and executed ${getSlotsCount} times`);
|
|
|
|
logger.debug(
|
|
`checkForAvailability took ${checkForAvailabilityTime}ms and executed ${checkForAvailabilityCount} times`
|
|
);
|
|
logger.silly(`Available slots: ${JSON.stringify(computedAvailableSlots)}`);
|
|
|
|
return {
|
|
slots: computedAvailableSlots,
|
|
};
|
|
}
|
|
|
|
async function getUserIdFromUsername(
|
|
username: string,
|
|
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
|
|
) {
|
|
const { currentOrgDomain, isValidOrgDomain } = organizationDetails;
|
|
const user = await prisma.user.findFirst({
|
|
where: {
|
|
username,
|
|
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
return user?.id;
|
|
}
|
|
|
|
async function getTeamIdFromSlug(
|
|
slug: string,
|
|
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
|
|
) {
|
|
const { currentOrgDomain, isValidOrgDomain } = organizationDetails;
|
|
const team = await prisma.team.findFirst({
|
|
where: {
|
|
slug,
|
|
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
return team?.id;
|
|
}
|