fix: Buffers when combined with seated events (#20233)

This commit is contained in:
Alex van Andel authored and GitHub committed 2025-03-25 18:17:25 +00:00
1 parent 9b952fd922
commit c21094ab0a
4 files changed
+303 -245

No files matched your search

+128
View File
@@ -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`),
}),
]);
});
});
+52 -116
View File
@@ -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<Prisma.BookingSelect>()({
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({
+112 -1
View File
@@ -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<number, string>;
}) {
const sharedQuery = {
startTime: { lte: endDate },
endTime: { gte: startDate },
status: {
in: [BookingStatus.ACCEPTED],
},
};
const bookingsSelect = Prisma.validator<Prisma.BookingSelect>()({
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,
+11 -128
View File
@@ -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<string, string[]>, { 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<ReturnType<typeof getEventType>>,
sharedQuery: {
startTime: {
lte: Date;
};
endTime: {
gte: Date;
};
status: {
in: "ACCEPTED"[];
};
},
usersWithCredentials: ReturnType<typeof getUsersWithCredentials>,
allUserIds: number[]
) {
const bookingsSelect = Prisma.validator<Prisma.BookingSelect>()({
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),
]);