From c21094ab0a089d13224df6beb9a30b43a0743261 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Tue, 25 Mar 2025 18:17:25 +0000 Subject: [PATCH] fix: Buffers when combined with seated events (#20233) --- packages/lib/getBusyTimes.test.ts | 128 +++++++++++++ packages/lib/getBusyTimes.ts | 168 ++++++------------ packages/lib/server/repository/booking.ts | 113 +++++++++++- .../trpc/server/routers/viewer/slots/util.ts | 139 ++------------- 4 files changed, 303 insertions(+), 245 deletions(-) create mode 100644 packages/lib/getBusyTimes.test.ts diff --git a/packages/lib/getBusyTimes.test.ts b/packages/lib/getBusyTimes.test.ts new file mode 100644 index 0000000000..1da126914e --- /dev/null +++ b/packages/lib/getBusyTimes.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import { getBusyTimes } from "./getBusyTimes"; + +const startOfTomorrow = dayjs().add(1, "day").startOf("day"); +const tomorrowDate = startOfTomorrow.format("YYYY-MM-DD"); + +const mockBookings = ({ + beforeEventBuffer = 0, + afterEventBuffer = 0, + seatsPerTimeSlot, +}: { + beforeEventBuffer?: number; + afterEventBuffer?: number; + seatsPerTimeSlot?: number; +}) => [ + { + id: 1, + startTime: startOfTomorrow.set("hour", 10).toDate(), + endTime: startOfTomorrow.set("hour", 11).toDate(), + title: "Booking Between X and Y", + userId: 1, + uid: "xxxx1", + eventType: { + id: 1, + beforeEventBuffer, + afterEventBuffer, + seatsPerTimeSlot: seatsPerTimeSlot ?? null, + }, + _count: { + seatsReferences: 1, + }, + }, + { + id: 2, + startTime: startOfTomorrow.set("hour", 14).toDate(), + endTime: startOfTomorrow.set("hour", 15).toDate(), + title: "Booking Between X and Y", + userId: 1, + uid: "xxxx2", + eventType: { + id: 1, + beforeEventBuffer, + afterEventBuffer, + seatsPerTimeSlot: seatsPerTimeSlot ?? null, + }, + ...(seatsPerTimeSlot + ? { + _count: { + seatsReferences: 1, + }, + } + : {}), + }, +]; + +describe("getBusyTimes", () => { + it("blocks a regular time slot", async () => { + const busyTimes = await getBusyTimes({ + credentials: [], + userId: 1, + userEmail: "exampleuser1@example.com", + username: "exampleuser1", + bypassBusyCalendarTimes: false, + selectedCalendars: [], + startTime: startOfTomorrow.format(), + endTime: startOfTomorrow.endOf("day").format(), + currentBookings: mockBookings({}), + }); + expect(busyTimes).toEqual([ + expect.objectContaining({ + start: new Date(`${tomorrowDate}T10:00:00.000Z`), + end: new Date(`${tomorrowDate}T11:00:00.000Z`), + source: "eventType-1-booking-1", + title: "Booking Between X and Y", + }), + expect.objectContaining({ + start: new Date(`${tomorrowDate}T14:00:00.000Z`), + end: new Date(`${tomorrowDate}T15:00:00.000Z`), + source: "eventType-1-booking-2", + title: "Booking Between X and Y", + }), + ]); + }); + it("should block before and after buffer times", async () => { + const busyTimes = await getBusyTimes({ + credentials: [], + userId: 1, + userEmail: "exampleuser1@example.com", + username: "exampleuser1", + bypassBusyCalendarTimes: false, + selectedCalendars: [], + startTime: startOfTomorrow.format(), + endTime: startOfTomorrow.endOf("day").format(), + currentBookings: [mockBookings({ beforeEventBuffer: 10 })[0]], + }); + expect(busyTimes).toEqual([ + expect.objectContaining({ + start: new Date(`${tomorrowDate}T09:50:00.000Z`), + end: new Date(`${tomorrowDate}T11:00:00.000Z`), + source: "eventType-1-booking-1", + title: "Booking Between X and Y", + }), + ]); + }); + it("should have busy times only if seated with remaining seats when buffers exist", async () => { + const busyTimes = await getBusyTimes({ + credentials: [], + userId: 1, + eventTypeId: 1, + userEmail: "exampleuser1@example.com", + username: "exampleuser1", + bypassBusyCalendarTimes: false, + selectedCalendars: [], + startTime: startOfTomorrow.format(), + endTime: startOfTomorrow.endOf("day").format(), + currentBookings: [mockBookings({ beforeEventBuffer: 10, seatsPerTimeSlot: 10 })[0]], + }); + expect(busyTimes).toEqual([ + expect.objectContaining({ + start: new Date(`${tomorrowDate}T09:50:00.000Z`), + end: new Date(`${tomorrowDate}T10:00:00.000Z`), + }), + ]); + }); +}); diff --git a/packages/lib/getBusyTimes.ts b/packages/lib/getBusyTimes.ts index 88d4a39051..cfc59ec498 100644 --- a/packages/lib/getBusyTimes.ts +++ b/packages/lib/getBusyTimes.ts @@ -1,6 +1,5 @@ import type { Booking, EventType } from "@prisma/client"; -import { Prisma } from "@prisma/client"; -import * as Sentry from "@sentry/nextjs"; +import type { Prisma } from "@prisma/client"; import dayjs from "@calcom/dayjs"; import { getBusyCalendarTimes } from "@calcom/lib/CalendarManager"; @@ -18,6 +17,7 @@ import type { EventBusyDetails } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; import { getDefinedBufferTimes } from "../features/eventtypes/lib/getDefinedBufferTimes"; +import { BookingRepository as BookingRepo } from "./server/repository/booking"; export async function getBusyTimes(params: { credentials: CredentialForCalendarService[]; @@ -100,15 +100,6 @@ export async function getBusyTimes(params: { const startTimeAdjustedWithMaxBuffer = dayjs(startTimeDate).subtract(maxBuffer, "minute").toDate(); const endTimeAdjustedWithMaxBuffer = dayjs(endTimeDate).add(maxBuffer, "minute").toDate(); - // startTime is less than endTimeDate and endTime grater than startTimeDate - const sharedQuery: Prisma.BookingWhereInput = { - startTime: { lte: endTimeAdjustedWithMaxBuffer }, - endTime: { gte: startTimeAdjustedWithMaxBuffer }, - status: { - in: [BookingStatus.ACCEPTED], - }, - }; - // INFO: Refactored to allow this method to take in a list of current bookings for the user. // Will keep support for retrieving a user's bookings if the caller does not already supply them. // This function is called from multiple places but we aren't refactoring all of them at this moment @@ -116,119 +107,64 @@ export async function getBusyTimes(params: { let bookings = params.currentBookings; if (!bookings) { - const getBookingsForBusyTimesSpan = Sentry.startInactiveSpan({ name: "getBookingsForBusyTimes" }); - const bookingsSelect = Prisma.validator()({ - id: true, - uid: true, - userId: true, - startTime: true, - endTime: true, - title: true, - eventType: { - select: { - id: true, - afterEventBuffer: true, - beforeEventBuffer: true, - seatsPerTimeSlot: true, - }, - }, - ...(seatedEvent && { - _count: { - select: { - seatsReferences: true, - }, - }, - }), + bookings = await BookingRepo.findAllExistingBookingsForEventTypeBetween({ + userIdAndEmailMap: new Map([[userId, userEmail]]), + eventTypeId, + startDate: startTimeAdjustedWithMaxBuffer, + endDate: endTimeAdjustedWithMaxBuffer, + seatedEvent, }); - - const currentBookingsAllUsersQueryOne = prisma.booking.findMany({ - where: { - // User is primary host (individual events, or primary organizer) - ...sharedQuery, - userId, - }, - select: bookingsSelect, - }); - - const currentBookingsAllUsersQueryTwo = prisma.booking.findMany({ - where: { - ...sharedQuery, - attendees: { - some: { - email: userEmail, - }, - }, - }, - select: bookingsSelect, - }); - - const unconfirmedBookingsBlockingSlots = eventTypeId - ? prisma.booking.findMany({ - where: { - startTime: { lte: endTimeDate }, - endTime: { gte: startTimeDate }, - eventType: { - id: eventTypeId, - requiresConfirmation: true, - requiresConfirmationWillBlockSlot: true, - }, - status: { - in: [BookingStatus.PENDING], - }, - }, - select: bookingsSelect, - }) - : null; - - const [resultOne, resultTwo, resultThree] = await Promise.all([ - currentBookingsAllUsersQueryOne, - currentBookingsAllUsersQueryTwo, - unconfirmedBookingsBlockingSlots, - ]); - - bookings = [...resultOne, ...resultTwo, ...(resultThree ?? [])]; - - getBookingsForBusyTimesSpan.end(); } const bookingSeatCountMap: { [x: string]: number } = {}; - const busyTimes = bookings.reduce( - (aggregate: EventBusyDetails[], { id, startTime, endTime, eventType, title, ...rest }) => { - if (rest._count?.seatsReferences) { - const bookedAt = `${dayjs(startTime).utc().format()}<>${dayjs(endTime).utc().format()}`; - bookingSeatCountMap[bookedAt] = bookingSeatCountMap[bookedAt] || 0; - bookingSeatCountMap[bookedAt]++; - // Seat references on the current event are non-blocking until the event is fully booked. - if ( - // there are still seats available. - bookingSeatCountMap[bookedAt] < (eventType?.seatsPerTimeSlot || 1) && - // and this is the seated event, other event types should be blocked. - eventTypeId === eventType?.id - ) { - // then we do not add the booking to the busyTimes. - return aggregate; + const busyTimes = bookings.reduce((aggregate: EventBusyDetails[], booking) => { + const { id, startTime, endTime, eventType, title, ...rest } = booking; + + const minutesToBlockBeforeEvent = (eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0); + const minutesToBlockAfterEvent = (eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0); + + if (rest._count?.seatsReferences) { + const bookedAt = `${dayjs(startTime).utc().format()}<>${dayjs(endTime).utc().format()}`; + bookingSeatCountMap[bookedAt] = bookingSeatCountMap[bookedAt] || 0; + bookingSeatCountMap[bookedAt]++; + // Seat references on the current event are non-blocking until the event is fully booked. + if ( + // there are still seats available. + bookingSeatCountMap[bookedAt] < (eventType?.seatsPerTimeSlot || 1) && + // and this is the seated event, other event types should be blocked. + eventTypeId === eventType?.id + ) { + // then we ONLY add the before/after buffer times as busy times. + if (minutesToBlockBeforeEvent) { + aggregate.push({ + start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(), + end: dayjs(startTime).toDate(), // The event starts after the buffer + }); + } + if (minutesToBlockAfterEvent) { + aggregate.push({ + start: dayjs(endTime).toDate(), // The event ends before the buffer + end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(), + }); } - // if it does get blocked at this point; we remove the bookingSeatCountMap entry - // doing this allows using the map later to remove the ranges from calendar busy times. - delete bookingSeatCountMap[bookedAt]; - } - if (rest.uid === rescheduleUid) { return aggregate; } - aggregate.push({ - start: dayjs(startTime) - .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute") - .toDate(), - end: dayjs(endTime) - .add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute") - .toDate(), - title, - source: `eventType-${eventType?.id}-booking-${id}`, - }); + // if it does get blocked at this point; we remove the bookingSeatCountMap entry + // doing this allows using the map later to remove the ranges from calendar busy times. + delete bookingSeatCountMap[bookedAt]; + } + // rescheduling the same booking to the same time should be possible. Why? + if (rest.uid === rescheduleUid) { return aggregate; - }, - [] - ); + } + aggregate.push({ + start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(), + end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(), + title, + source: `eventType-${eventType?.id}-booking-${id}`, + }); + return aggregate; + }, []); logger.debug( `Busy Time from Cal Bookings ${JSON.stringify({ diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 941c2c5abe..93d5e0ef19 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import type { FormResponse } from "@calcom/app-store/routing-forms/types/types"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; @@ -161,6 +161,117 @@ export class BookingRepository { }); } + static async findAllExistingBookingsForEventTypeBetween({ + eventTypeId, + seatedEvent = false, + startDate, + endDate, + userIdAndEmailMap, + }: { + startDate: Date; + endDate: Date; + eventTypeId?: number | null; + seatedEvent?: boolean; + userIdAndEmailMap: Map; + }) { + const sharedQuery = { + startTime: { lte: endDate }, + endTime: { gte: startDate }, + status: { + in: [BookingStatus.ACCEPTED], + }, + }; + + const bookingsSelect = Prisma.validator()({ + id: true, + uid: true, + userId: true, + startTime: true, + endTime: true, + title: true, + attendees: true, + eventType: { + select: { + id: true, + onlyShowFirstAvailableSlot: true, + afterEventBuffer: true, + beforeEventBuffer: true, + seatsPerTimeSlot: true, + requiresConfirmationWillBlockSlot: true, + requiresConfirmation: true, + allowReschedulingPastBookings: true, + }, + }, + ...(seatedEvent && { + _count: { + select: { + seatsReferences: true, + }, + }, + }), + }); + + const currentBookingsAllUsersQueryOne = prisma.booking.findMany({ + where: { + ...sharedQuery, + userId: { + in: Array.from(userIdAndEmailMap.keys()), + }, + }, + select: bookingsSelect, + }); + + const currentBookingsAllUsersQueryTwo = prisma.booking.findMany({ + where: { + ...sharedQuery, + attendees: { + some: { + email: { + in: Array.from(userIdAndEmailMap.values()), + }, + }, + }, + }, + select: bookingsSelect, + }); + + const currentBookingsAllUsersQueryThree = eventTypeId + ? prisma.booking.findMany({ + where: { + startTime: { lte: endDate }, + endTime: { gte: startDate }, + eventType: { + id: eventTypeId, + requiresConfirmation: true, + requiresConfirmationWillBlockSlot: true, + }, + status: { + in: [BookingStatus.PENDING], + }, + }, + select: bookingsSelect, + }) + : []; + + const [resultOne, resultTwo, resultThree] = await Promise.all([ + currentBookingsAllUsersQueryOne, + currentBookingsAllUsersQueryTwo, + currentBookingsAllUsersQueryThree, + ]); + // Prevent duplicate booking records when the organizer books his own event type. + // + // *Three is about PENDING, so will never overlap + // with the other two, which are about ACCEPTED. But ACCEPTED affects *One & *Two + // and WILL result in a duplicate booking if the organizer books himself. + const resultTwoWithOrganizersRemoved = resultTwo.filter((booking) => { + if (!booking.userId) return true; + const organizerEmail = userIdAndEmailMap.get(booking.userId); + return !booking.attendees.some((attendee) => attendee.email === organizerEmail); + }); + + return [...resultOne, ...resultTwoWithOrganizersRemoved, ...resultThree]; + } + static async getAllBookingsForRoundRobin({ users, eventTypeId, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index c317e7be8d..4aad32946f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -28,12 +28,13 @@ import { import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import monitorCallbackAsync, { monitorCallbackSync } from "@calcom/lib/sentryWrapper"; +import { BookingRepository as BookingRepo } from "@calcom/lib/server/repository/booking"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; import { UserRepository, withSelectedCalendars } from "@calcom/lib/server/repository/user"; import getSlots from "@calcom/lib/slots"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; import { PeriodType, Prisma } from "@calcom/prisma/client"; -import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; +import { SchedulingType } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { EventBusyDate } from "@calcom/types/Calendar"; import type { CredentialPayload, CredentialForCalendarService } from "@calcom/types/Credential"; @@ -225,20 +226,6 @@ export async function getRegularOrDynamicEventType( : await getEventType(input, organizationDetails); } -const groupTimeSlotsByDay = (timeSlots: { time: Dayjs }[]) => { - return timeSlots.reduce((acc: Record, { time }) => { - const day = time.format("YYYY-MM-DD"); // Group by date - const formattedTime = time.format("HH:mm"); // Format as HH:mm - - if (!acc[day]) { - acc[day] = []; - } - acc[day].push(formattedTime); - - return acc; - }, {}); -}; - function applyOccupiedSeatsToCurrentSeats(currentSeats: CurrentSeats, occupiedSeats: SelectedSlots[]) { const occupiedSeatsCount = countBy(occupiedSeats, (item) => item.slotUtcStartDate.toISOString()); Object.keys(occupiedSeatsCount).forEach((date) => { @@ -761,102 +748,6 @@ async function getTeamIdFromSlug( return team?.id; } -async function getExistingBookings( - startTimeDate: Date, - endTimeDate: Date, - eventType: Awaited>, - sharedQuery: { - startTime: { - lte: Date; - }; - endTime: { - gte: Date; - }; - status: { - in: "ACCEPTED"[]; - }; - }, - usersWithCredentials: ReturnType, - allUserIds: number[] -) { - const bookingsSelect = Prisma.validator()({ - id: true, - uid: true, - userId: true, - startTime: true, - endTime: true, - title: true, - attendees: true, - eventType: { - select: { - id: true, - onlyShowFirstAvailableSlot: true, - afterEventBuffer: true, - beforeEventBuffer: true, - seatsPerTimeSlot: true, - requiresConfirmationWillBlockSlot: true, - requiresConfirmation: true, - allowReschedulingPastBookings: true, - }, - }, - ...(!!eventType?.seatsPerTimeSlot && { - _count: { - select: { - seatsReferences: true, - }, - }, - }), - }); - - const currentBookingsAllUsersQueryOne = prisma.booking.findMany({ - where: { - ...sharedQuery, - userId: { - in: allUserIds, - }, - }, - select: bookingsSelect, - }); - - const currentBookingsAllUsersQueryTwo = prisma.booking.findMany({ - where: { - ...sharedQuery, - attendees: { - some: { - email: { - in: usersWithCredentials.map((user) => user.email), - }, - }, - }, - }, - select: bookingsSelect, - }); - - const currentBookingsAllUsersQueryThree = prisma.booking.findMany({ - where: { - startTime: { lte: endTimeDate }, - endTime: { gte: startTimeDate }, - eventType: { - id: eventType?.id, - requiresConfirmation: true, - requiresConfirmationWillBlockSlot: true, - }, - status: { - in: [BookingStatus.PENDING], - }, - }, - select: bookingsSelect, - }); - - const [resultOne, resultTwo, resultThree] = await Promise.all([ - currentBookingsAllUsersQueryOne, - currentBookingsAllUsersQueryTwo, - currentBookingsAllUsersQueryThree, - ]); - - return [...resultOne, ...resultTwo, ...resultThree]; -} - async function getOOODates(startTimeDate: Date, endTimeDate: Date, allUserIds: number[]) { return await prisma.outOfOfficeEntry.findMany({ where: { @@ -984,25 +875,17 @@ const calculateHostsAndAvailabilities = async ({ const endTimeDate = input.rescheduleUid && durationToUse ? endTime.add(durationToUse, "minute").toDate() : endTime.toDate(); - const sharedQuery = { - startTime: { lte: endTimeDate }, - endTime: { gte: startTimeDate }, - status: { - in: [BookingStatus.ACCEPTED], - }, - }; + const userIdAndEmailMap = new Map(usersWithCredentials.map((user) => [user.id, user.email])); + const allUserIds = Array.from(userIdAndEmailMap.keys()); - const allUserIds = usersWithCredentials.map((user) => user.id); const [currentBookingsAllUsers, outOfOfficeDaysAllUsers] = await Promise.all([ - monitorCallbackAsync( - getExistingBookings, - startTimeDate, - endTimeDate, - eventType, - sharedQuery, - usersWithCredentials, - allUserIds - ), + monitorCallbackAsync(BookingRepo.findAllExistingBookingsForEventTypeBetween, { + startDate: startTimeDate, + endDate: endTimeDate, + eventTypeId: eventType.id, + seatedEvent: Boolean(eventType.seatsPerTimeSlot), + userIdAndEmailMap, + }), monitorCallbackAsync(getOOODates, startTimeDate, endTimeDate, allUserIds), ]);