cal.pub0.org/packages/trpc/server/routers/viewer/slots/util.ts

547 lines
17 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 { 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 }));
}
/* 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 }
);
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;
}