// 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[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((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, { 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; }