import type { Prisma } 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 { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; 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 { BookingStatus } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils"; import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar"; import { getBusyTimes } from "./getBusyTimes"; const availabilitySchema = z .object({ dateFrom: stringToDayjs, dateTo: stringToDayjs, 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(), }) .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); const getEventType = async (id: number) => { const eventType = await prisma.eventType.findUnique({ where: { id }, select: { id: true, seatsPerTimeSlot: true, bookingLimits: true, durationLimits: true, timeZone: true, length: true, metadata: true, schedule: { select: { availability: 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>; const getUser = (where: Prisma.UserWhereInput) => prisma.user.findFirst({ where, select: { ...availabilityUserSelect, credentials: true, }, }); type User = Awaited>; export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Dayjs) => prisma.booking.findMany({ where: { eventTypeId, startTime: { gte: dateFrom.format(), lte: dateTo.format(), }, status: BookingStatus.ACCEPTED, }, select: { uid: true, startTime: true, _count: { select: { attendees: true, }, }, }, }); export type CurrentSeats = Awaited>; /** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */ export async function getUserAvailability( query: { withSource?: boolean; username?: string; userId?: number; dateFrom: string; dateTo: string; eventTypeId?: number; afterEventBuffer?: number; beforeEventBuffer?: number; duration?: number; }, initialData?: { user?: User; eventType?: EventType; currentSeats?: CurrentSeats; } ) { const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } = 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" }); 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.id, dateFrom, dateTo); } const busyTimes = await getBusyTimes({ credentials: user.credentials, // needed to correctly apply limits (weeks can be part of two months) startTime: dateFrom.startOf("week").toISOString(), endTime: dateTo.endOf("week").toISOString(), eventTypeId, userId: user.id, username: `${user.username}`, beforeEventBuffer, afterEventBuffer, selectedCalendars: user.selectedCalendars, seatedEvent: !!eventType?.seatsPerTimeSlot, }); let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({ ...a, start: dayjs(a.start).toISOString(), end: dayjs(a.end).toISOString(), title: a.title, source: query.withSource ? a.source : undefined, })); const bookings = busyTimes.filter((busyTime) => busyTime.source?.startsWith(`eventType-${eventType?.id}`)); const bookingLimits = parseBookingLimit(eventType?.bookingLimits); if (bookingLimits) { const bookingBusyTimes = await getBusyTimesFromBookingLimits( bookings, bookingLimits, dateFrom, dateTo, eventType ); bufferedBusyTimes = bufferedBusyTimes.concat(bookingBusyTimes); } const durationLimits = parseDurationLimit(eventType?.durationLimits); if (durationLimits) { const durationBusyTimes = await getBusyTimesFromDurationLimits( bookings, durationLimits, dateFrom, dateTo, duration, eventType ); bufferedBusyTimes = bufferedBusyTimes.concat(durationBusyTimes); } const userSchedule = user.schedules.filter( (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId )[0]; const schedule = !eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule; const startGetWorkingHours = performance.now(); const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone; 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(); logger.debug(`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`); const dateOverrides = availability .filter((availability) => !!availability.date) .map((override) => { const startTime = dayjs.utc(override.startTime); const endTime = dayjs.utc(override.endTime); return { start: dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()).toDate(), end: dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()).toDate(), }; }); const dateRanges = buildDateRanges({ dateFrom, dateTo, availability, timeZone, }); const formattedBusyTimes = bufferedBusyTimes.map((busy) => ({ start: dayjs(busy.start), end: dayjs(busy.end), })); return { busy: bufferedBusyTimes, timeZone, dateRanges: subtract(dateRanges, formattedBusyTimes), workingHours, dateOverrides, currentSeats, }; } const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week" | "month" | "year") => { 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; }; const getBusyTimesFromBookingLimits = async ( bookings: EventBusyDetails[], bookingLimits: IntervalLimit, dateFrom: Dayjs, dateTo: Dayjs, eventType: EventType | undefined ) => { const busyTimes: EventBusyDetails[] = []; // Apply booking limit filter against our bookings for (const [key, limit] of Object.entries(bookingLimits)) { const limitKey = key as keyof IntervalLimit; if (limitKey === "PER_YEAR") { const yearlyBusyTime = await checkBookingLimit({ eventStartDate: dateFrom.toDate(), limitingNumber: limit, eventId: eventType?.id as number, key: "PER_YEAR", returnBusyTimes: true, }); if (!yearlyBusyTime) break; busyTimes.push({ start: yearlyBusyTime.start.toISOString(), end: yearlyBusyTime.end.toISOString(), }); break; } // Take PER_DAY and turn it into day and PER_WEEK into week etc. const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year"; const dates = getDatesBetween(dateFrom, dateTo, filter); // loop through all dates and check if we have reached the limit for (const date of dates) { let total = 0; const startDate = date.startOf(filter); // this is parsed above with parseBookingLimit so we know it's safe. const endDate = date.endOf(filter); for (const booking of bookings) { const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10); if ( // Only check OUR booking that matches the current eventTypeId // we don't care about another event type in this case as we dont need to know their booking limits !(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate)) ) { continue; } // increment total and check against the limit, adding a busy time if condition is met. total++; if (total >= limit) { busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() }); break; } } } } return busyTimes; }; const getBusyTimesFromDurationLimits = async ( bookings: EventBusyDetails[], durationLimits: IntervalLimit, dateFrom: Dayjs, dateTo: Dayjs, duration: number | undefined, eventType: EventType | undefined ) => { const busyTimes: EventBusyDetails[] = []; // Start check from larger time periods to smaller time periods, to skip unnecessary checks for (const [key, limit] of Object.entries(durationLimits).reverse()) { // Use aggregate sql query if we are checking PER_YEAR if (key === "PER_YEAR") { const totalBookingDuration = await getTotalBookingDuration({ eventId: eventType?.id as number, startDate: dateFrom.startOf("year").toDate(), endDate: dateFrom.endOf("year").toDate(), }); if (totalBookingDuration + (duration ?? 0) > limit) { busyTimes.push({ start: dateFrom.startOf("year").toISOString(), end: dateFrom.endOf("year").toISOString(), }); } continue; } const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year"; const dates = getDatesBetween(dateFrom, dateTo, filter); // loop through all dates and check if we have reached the limit for (const date of dates) { let total = (duration || eventType?.length) ?? 0; const startDate = date.startOf(filter); const endDate = date.endOf(filter); // add busy time if we have already reached the limit with just the selected duration if (total > limit) { busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() }); continue; } for (const booking of bookings) { const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10); if ( // Only check OUR booking that matches the current eventTypeId // we don't care about another event type in this case as we dont need to know their booking limits !(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate)) ) { continue; } // Add current booking duration to total and check against the limit, adding a busy time if condition is met. total += dayjs(booking.end).diff(dayjs(booking.start), "minute"); if (total > limit) { busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() }); break; } } } } return busyTimes; };