diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 4323419134..c239a7c1b5 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -3,13 +3,15 @@ import type { Booking, EventType } from "@prisma/client"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import dayjs from "@calcom/dayjs"; import { subtract } from "@calcom/lib/date-ranges"; +import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; import logger from "@calcom/lib/logger"; import { getPiiFreeBooking } from "@calcom/lib/piiFreeData"; import { performance } from "@calcom/lib/server/perfObserver"; import prisma from "@calcom/prisma"; import type { Prisma, SelectedCalendar } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; -import type { EventBusyDetails } from "@calcom/types/Calendar"; +import { stringToDayjs } from "@calcom/prisma/zod-utils"; +import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; export async function getBusyTimes(params: { @@ -261,32 +263,57 @@ export async function getBusyTimes(params: { } export async function getBusyTimesForLimitChecks(params: { - userId: number; + userIds: number[]; eventTypeId: number; - startDate: Date; - endDate: Date; + startDate: string; + endDate: string; rescheduleUid?: string | null; + bookingLimits?: IntervalLimit | null; + durationLimits?: IntervalLimit | null; }) { - const { userId, eventTypeId, startDate, endDate, rescheduleUid } = params; + const { userIds, eventTypeId, startDate, endDate, rescheduleUid, bookingLimits, durationLimits } = params; + const startTimeAsDayJs = stringToDayjs(startDate); + const endTimeAsDayJs = stringToDayjs(endDate); + + performance.mark("getBusyTimesForLimitChecksStart"); + + let busyTimes: EventBusyDetails[] = []; + + if (!bookingLimits && !durationLimits) { + return busyTimes; + } + + let limitDateFrom = stringToDayjs(startDate); + let limitDateTo = stringToDayjs(endDate); + + // expand date ranges by absolute minimum required to apply limits + // (yearly limits are handled separately for performance) + for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude[]) { + if (bookingLimits?.[key] || durationLimits?.[key]) { + const unit = intervalLimitKeyToUnit(key); + limitDateFrom = dayjs.min(limitDateFrom, startTimeAsDayJs.startOf(unit)); + limitDateTo = dayjs.max(limitDateTo, endTimeAsDayJs.endOf(unit)); + } + } logger.silly( - `Fetch limit checks bookings in range ${startDate} to ${endDate} for input ${JSON.stringify({ - userId, + `Fetch limit checks bookings in range ${limitDateFrom} to ${limitDateTo} for input ${JSON.stringify({ eventTypeId, status: BookingStatus.ACCEPTED, })}` ); - performance.mark("getBusyTimesForLimitChecksStart"); const where: Prisma.BookingWhereInput = { - userId, + userId: { + in: userIds, + }, eventTypeId, status: BookingStatus.ACCEPTED, // FIXME: bookings that overlap on one side will never be counted startTime: { - gte: startDate, + gte: limitDateFrom.toDate(), }, endTime: { - lte: endDate, + lte: limitDateTo.toDate(), }, }; @@ -308,14 +335,16 @@ export async function getBusyTimesForLimitChecks(params: { }, }, title: true, + userId: true, }, }); - const busyTimes = bookings.map(({ id, startTime, endTime, eventType, title }) => ({ + busyTimes = bookings.map(({ id, startTime, endTime, eventType, title, userId }) => ({ start: dayjs(startTime).toDate(), end: dayjs(endTime).toDate(), title, source: `eventType-${eventType?.id}-booking-${id}`, + userId, })); logger.silly(`Fetch limit checks bookings for eventId: ${eventTypeId} ${JSON.stringify(busyTimes)}`); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 34b68ad85e..cf4153e731 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -17,7 +17,7 @@ import { getTotalBookingDuration } from "@calcom/lib/server/queries"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils"; +import { EventTypeMetaDataSchema, stringToDayjsZod } from "@calcom/prisma/zod-utils"; import type { EventBusyDate, EventBusyDetails, @@ -25,14 +25,14 @@ import type { IntervalLimitUnit, } from "@calcom/types/Calendar"; -import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes"; +import { getBusyTimes } from "./getBusyTimes"; import monitorCallbackAsync, { monitorCallbackSync } from "./sentryWrapper"; const log = logger.getSubLogger({ prefix: ["getUserAvailability"] }); const availabilitySchema = z .object({ - dateFrom: stringToDayjs, - dateTo: stringToDayjs, + dateFrom: stringToDayjsZod, + dateTo: stringToDayjsZod, eventTypeId: z.number().optional(), username: z.string().optional(), userId: z.number().optional(), @@ -176,6 +176,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA seatsReferences: number; }; })[]; + busyTimesFromLimitsBookings: EventBusyDetails[]; } ) { const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } = @@ -219,8 +220,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA dateTo, duration, eventType, - user.id, - initialData?.rescheduleUid + initialData?.busyTimesFromLimitsBookings ?? [] ) : []; @@ -431,36 +431,13 @@ const _getBusyTimesFromLimits = async ( dateTo: Dayjs, duration: number | undefined, eventType: NonNullable, - userId: number, - rescheduleUid?: string | null + bookings: EventBusyDetails[] ) => { performance.mark("limitsStart"); // shared amongst limiters to prevent processing known busy periods const limitManager = new LimitManager(); - let limitDateFrom = dayjs(dateFrom); - let limitDateTo = dayjs(dateTo); - - // expand date ranges by absolute minimum required to apply limits - // (yearly limits are handled separately for performance) - for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude[]) { - if (bookingLimits?.[key] || durationLimits?.[key]) { - const unit = intervalLimitKeyToUnit(key); - limitDateFrom = dayjs.min(limitDateFrom, dateFrom.startOf(unit)); - limitDateTo = dayjs.max(limitDateTo, dateTo.endOf(unit)); - } - } - - // fetch only the data we need to check limits - const bookings = await getBusyTimesForLimitChecks({ - userId, - eventTypeId: eventType.id, - startDate: limitDateFrom.toDate(), - endDate: limitDateTo.toDate(), - rescheduleUid: rescheduleUid, - }); - // run this first, as counting bookings should always run faster.. if (bookingLimits) { performance.mark("bookingLimitsStart"); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a8cf3082b6..f680497d3b 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -23,6 +23,7 @@ import type { EventTypeAppsList } from "@calcom/app-store/utils"; import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; +import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import dayjs from "@calcom/dayjs"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; @@ -53,6 +54,7 @@ import { getFullName } from "@calcom/features/form-builder/utils"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { cancelScheduledJobs, scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; @@ -454,6 +456,22 @@ export async function ensureAvailableUsers( ) : undefined; + const bookingLimits = parseBookingLimit(eventType?.bookingLimits); + const durationLimits = parseDurationLimit(eventType?.durationLimits); + let busyTimesFromLimitsBookingsAllUsers: Awaited> = []; + + if (eventType && (bookingLimits || durationLimits)) { + busyTimesFromLimitsBookingsAllUsers = await getBusyTimesForLimitChecks({ + userIds: eventType.users.map((u) => u.id), + eventTypeId: eventType.id, + startDate: input.dateFrom, + endDate: input.dateTo, + rescheduleUid: input.originalRescheduledBooking?.uid ?? null, + bookingLimits, + durationLimits, + }); + } + /** Let's start checking for availability */ for (const user of eventType.users) { const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( @@ -467,6 +485,7 @@ export async function ensureAvailableUsers( user, eventType, rescheduleUid: input.originalRescheduledBooking?.uid ?? null, + busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers.filter((b) => b.userId === user.id), } ); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 0c5436aff3..897f0f88da 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -206,11 +206,13 @@ export const stringOrNumber = z.union([ z.number().int(), ]); -export const stringToDayjs = z.string().transform((val) => { +export const stringToDayjs = (val: string) => { const matches = val.match(/([+-]\d{2}:\d{2})$/); const timezone = matches ? matches[1] : "+00:00"; return dayjs(val).utcOffset(timezone); -}); +}; + +export const stringToDayjsZod = z.string().transform(stringToDayjs); export const bookingCreateBodySchema = z.object({ end: z.string().optional(), diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 1995b21420..34bb307a2f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -3,12 +3,14 @@ import { countBy } from "lodash"; import { v4 as uuid } from "uuid"; import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability"; +import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes"; 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 { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; +import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; @@ -336,6 +338,8 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { }, }; + const allUserIds = usersWithCredentials.map((user) => user.id); + const currentBookingsAllUsers = await prisma.booking.findMany({ where: { OR: [ @@ -343,7 +347,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { { ...sharedQuery, userId: { - in: usersWithCredentials.map((user) => user.id), + in: allUserIds, }, }, // The current user has a different booking at this time he/she attends @@ -386,6 +390,22 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { }, }); + const bookingLimits = parseBookingLimit(eventType?.bookingLimits); + const durationLimits = parseDurationLimit(eventType?.durationLimits); + let busyTimesFromLimitsBookingsAllUsers: Awaited> = []; + + if (eventType && (bookingLimits || durationLimits)) { + busyTimesFromLimitsBookingsAllUsers = await getBusyTimesForLimitChecks({ + userIds: allUserIds, + eventTypeId: eventType.id, + startDate: startTime.format(), + endDate: endTime.format(), + rescheduleUid: input.rescheduleUid, + bookingLimits, + durationLimits, + }); + } + /* We get all users working hours and busy slots */ const userAvailability = await Promise.all( usersWithCredentials.map(async (currentUser) => { @@ -418,6 +438,9 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { const { attendees: _attendees, ...bookingWithoutAttendees } = bookings; return bookingWithoutAttendees; }), + busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers.filter( + (b) => b.userId === currentUser.id + ), } ); if (!currentSeats && _currentSeats) currentSeats = _currentSeats; diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts index 99e9b4ee9d..1a2cd7ae3a 100644 --- a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts @@ -46,7 +46,7 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai dateFrom: input.dateFrom, dateTo: input.dateTo, }, - { user: member.user } + { user: member.user, busyTimesFromLimitsBookings: [] } ); }; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 775d8b7c23..8b8acdfee8 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -55,6 +55,7 @@ export type EventBusyDate = { export type EventBusyDetails = EventBusyDate & { title?: string; source?: string | null; + userId?: number | null; }; export type CalendarServiceType = typeof Calendar;