270d4f6e82
## What does this PR do? Fixes that it can happen that Round Robin host is booked outside of availability. I found and fixed the following two scenarios where this can happen: - when host has a date override - when host is available for only a part the event time (for example, booking time 9:00-11:00 and user is only available between 10:00-11:00) Fixes #10315 Fixes #11690 It also fixes that it can happen that round robin doesn't correctly pick the luck user (least recently booked). This happened when a user was an attendee of a booking before, then we always compared this booking and never the actual last booking of this user. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How should this be tested? #### Booked outside of availability: 1. - Create Round Robin event and assign user1 and user2 as round robin hosts - event duration: 120 minutes - user 1 availability: - Monday to Friday 9:00-17:00 - user2 availability: - Monday to Friday 10:00-17:00 - Book event at a 9:00 slot -> check if i user1 is booked - Book event again at a 9:00 slot -> check if user1 is booked again (user2 is not available at that time) 2. - Change availability of user2 - Mark Monday as unavailable - Add date override on any day this month - Book any Monday this month -> see that user 1 is booked - Again Book any Monday this month -> see that user 1 is booked again #### Wrong lucky user - Book event and add user1's email as the attendee email address - Book several slots where both users should be available, and see that it alternates between user1 and user2 (before it ended up always booking user1) ## Mandatory Tasks - [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
import type { User } from "@prisma/client";
|
|
|
|
import prisma from "@calcom/prisma";
|
|
|
|
async function leastRecentlyBookedUser<T extends Pick<User, "id" | "email">>({
|
|
availableUsers,
|
|
eventTypeId,
|
|
}: {
|
|
availableUsers: T[];
|
|
eventTypeId: number;
|
|
}) {
|
|
// First we get all organizers (fixed host/single round robin user)
|
|
const organizersWithLastCreated = await prisma.user.findMany({
|
|
where: {
|
|
id: {
|
|
in: availableUsers.map((user) => user.id),
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
bookings: {
|
|
select: {
|
|
createdAt: true,
|
|
},
|
|
where: {
|
|
eventTypeId,
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
const organizerIdAndAtCreatedPair = organizersWithLastCreated.reduce(
|
|
(keyValuePair: { [userId: number]: Date }, user) => {
|
|
keyValuePair[user.id] = user.bookings[0]?.createdAt || new Date(0);
|
|
return keyValuePair;
|
|
},
|
|
{}
|
|
);
|
|
|
|
const bookings = await prisma.booking.findMany({
|
|
where: {
|
|
AND: [
|
|
{
|
|
eventTypeId,
|
|
},
|
|
{
|
|
attendees: {
|
|
some: {
|
|
email: {
|
|
in: availableUsers.map((user) => user.email),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
select: {
|
|
id: true,
|
|
createdAt: true,
|
|
attendees: {
|
|
select: {
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
});
|
|
|
|
const attendeeUserIdAndAtCreatedPair = bookings.reduce((aggregate: { [userId: number]: Date }, booking) => {
|
|
availableUsers.forEach((user) => {
|
|
if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate
|
|
// contains the user id, it's already got the most recent booking marked.
|
|
if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return;
|
|
if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings
|
|
aggregate[user.id] = booking.createdAt;
|
|
});
|
|
return aggregate;
|
|
}, {});
|
|
|
|
const userIdAndAtCreatedPair = {
|
|
...organizerIdAndAtCreatedPair,
|
|
...attendeeUserIdAndAtCreatedPair,
|
|
};
|
|
|
|
if (!userIdAndAtCreatedPair) {
|
|
throw new Error("Unable to find users by availableUser ids."); // should never happen.
|
|
}
|
|
|
|
const leastRecentlyBookedUser = availableUsers.sort((a, b) => {
|
|
if (userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id]) return 1;
|
|
else if (userIdAndAtCreatedPair[a.id] < userIdAndAtCreatedPair[b.id]) return -1;
|
|
// if two (or more) dates are identical, we randomize the order
|
|
else return Math.random() > 0.5 ? 1 : -1;
|
|
})[0];
|
|
|
|
return leastRecentlyBookedUser;
|
|
}
|
|
|
|
// TODO: Configure distributionAlgorithm from the event type configuration
|
|
// TODO: Add 'MAXIMIZE_FAIRNESS' algorithm.
|
|
export async function getLuckyUser<T extends Pick<User, "id" | "email">>(
|
|
distributionAlgorithm: "MAXIMIZE_AVAILABILITY" = "MAXIMIZE_AVAILABILITY",
|
|
{ availableUsers, eventTypeId }: { availableUsers: T[]; eventTypeId: number }
|
|
) {
|
|
if (availableUsers.length === 1) {
|
|
return availableUsers[0];
|
|
}
|
|
switch (distributionAlgorithm) {
|
|
case "MAXIMIZE_AVAILABILITY":
|
|
return leastRecentlyBookedUser<T>({ availableUsers, eventTypeId });
|
|
}
|
|
}
|