Files
calendar/packages/lib/server/getLuckyUser.ts
T
cd311f076c feat: Round Robin Weights (#15558)
* WIP

* add frontend

* backend to update weight

* UI improvements

* WIP weight algorithm

* enable weights switch + algorithm improvements

* fix weightDescription

* clean up code

* remove OOOEntryHost from schema.prisma

* implement logic (not yet tested)

* add tests for weight algorithm

* add test with weight adjustment

* finish unit tests

* fix type error

* fix type error

* add migration

* fix type error

* fix event type update handler

* fix failing test

* UI fixes for saving hosts

* make sure weightAdjustment is not lost on host changes

* fix weightadjustment for new hosts

* add weightAdjustment to availableUsers

* fix type errors

* fix default value for weight

* make weight and weightAdjustment optional

* fix type errors from schema changes

* type fix

* clean up code

* improve comments

* remove comment

* clean up code

* add tests & weight adjustment improvments

* better variable naming

* fixes for weight adjustments

* make weightAdjustments proportional to weights

* fix previous host weight adjustments

* improved tests for weight adjustments

* save weight and priority + sort hosts correctly

* fix type error

* code clean up

* remove console.log

* use BookingRepository to fetch bookings of users

* use BookingRepository to fetch bookings in getLuckyUser

* fix type errors

* fix weightAdjustment if changed from fixed to rr host

* disable weights when 'assign all' is enabled

* typo

* allow 0 weight

* set min (and max) for weight and priority

* use useWatch

* code clean up

* fix type error

* only count accepted bookings for RR

* fix type error

* improve data fetching of bookings

* only filter bookings of availableUsers

* code clean up form feedback

* fix tests

* don't count no show bookings

* code clean up

* choose user with highest weight

* use one reduce instead of two

* use reduce instead of filter and map

* don't show weights toggle when 'assign all' is enabled

* design fixes

* fix type errors

* fix: type check

* Update packages/features/eventtypes/components/AddMembersWithSwitch.tsx

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-15 15:01:15 +01:00

249 lines
7.8 KiB
TypeScript

import type { User } from "@prisma/client";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
type PartialBooking = Pick<Booking, "id" | "createdAt" | "userId" | "status"> & {
attendees: { email: string | null }[];
};
type PartialUser = Pick<User, "id" | "email">;
interface GetLuckyUserParams<T extends PartialUser> {
availableUsers: T[];
eventType: { id: number; isRRWeightsEnabled: boolean };
allRRHosts: {
user: { id: number; email: string };
weight?: number | null;
weightAdjustment?: number | null;
}[];
}
async function leastRecentlyBookedUser<T extends PartialUser>({
availableUsers,
eventType,
bookingsOfAvailableUsers,
}: GetLuckyUserParams<T> & { bookingsOfAvailableUsers: PartialBooking[] }) {
// 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: eventType.id,
status: BookingStatus.ACCEPTED,
attendees: {
some: {
noShow: false,
},
},
// not:true won't match null, thus we need to do an OR with null case separately(for bookings that might have null value for `noShowHost` as earlier it didn't have default false)
// https://github.com/calcom/cal.com/pull/15323#discussion_r1687728207
OR: [
{
noShowHost: false,
},
{
noShowHost: null,
},
],
},
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 attendeeUserIdAndAtCreatedPair = bookingsOfAvailableUsers.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;
}
function getUsersWithHighestPriority<T extends PartialUser & { priority?: number | null }>({
availableUsers,
}: {
availableUsers: T[];
}) {
const highestPriority = Math.max(...availableUsers.map((user) => user.priority ?? 2));
return availableUsers.filter(
(user) => user.priority === highestPriority || (user.priority == null && highestPriority === 2)
);
}
async function getUsersBasedOnWeights<
T extends PartialUser & {
weight?: number | null;
weightAdjustment?: number | null;
}
>({
availableUsers,
bookingsOfAvailableUsers,
allRRHosts,
eventType,
}: GetLuckyUserParams<T> & { bookingsOfAvailableUsers: PartialBooking[] }) {
//get all bookings of all other RR hosts that are not available
const availableUserIds = new Set(availableUsers.map((user) => user.id));
const notAvailableHosts = allRRHosts.reduce(
(
acc: {
id: number;
email: string;
}[],
host
) => {
if (!availableUserIds.has(host.user.id)) {
acc.push({
id: host.user.id,
email: host.user.email,
});
}
return acc;
},
[]
);
const bookingsOfNotAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventType.id,
users: notAvailableHosts,
});
const allBookings = bookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsers);
// Calculate the total weightAdjustments and weight of all round-robin hosts
const { allWeightAdjustments, totalWeight } = allRRHosts.reduce(
(acc, host) => {
acc.allWeightAdjustments += host.weightAdjustment ?? 0;
acc.totalWeight += host.weight ?? 100;
return acc;
},
{ allWeightAdjustments: 0, totalWeight: 0 }
);
// Calculate booking shortfall for each available user
const usersWithBookingShortfalls = availableUsers.map((user) => {
const targetPercentage = (user.weight ?? 100) / totalWeight;
const userBookings = bookingsOfAvailableUsers.filter(
(booking) =>
booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email)
);
const targetNumberOfBookings = (allBookings.length + allWeightAdjustments) * targetPercentage;
const bookingShortfall = targetNumberOfBookings - (userBookings.length + (user.weightAdjustment ?? 0));
return {
...user,
bookingShortfall,
};
});
// Find users with the highest booking shortfall
const maxShortfall = Math.max(...usersWithBookingShortfalls.map((user) => user.bookingShortfall));
const usersWithMaxShortfall = usersWithBookingShortfalls.filter(
(user) => user.bookingShortfall === maxShortfall
);
// ff more user's were found, find users with highest weights
const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100));
const userIdsWithMaxShortfallAndWeight = new Set(
usersWithMaxShortfall.filter((user) => user.weight === maxWeight).map((user) => user.id)
);
return availableUsers.filter((user) => userIdsWithMaxShortfallAndWeight.has(user.id));
}
// TODO: Configure distributionAlgorithm from the event type configuration
// TODO: Add 'MAXIMIZE_FAIRNESS' algorithm.
export async function getLuckyUser<
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
weightAdjustment?: number | null;
}
>(
distributionAlgorithm: "MAXIMIZE_AVAILABILITY" = "MAXIMIZE_AVAILABILITY",
getLuckyUserParams: GetLuckyUserParams<T>
) {
const { availableUsers, eventType, allRRHosts } = getLuckyUserParams;
if (availableUsers.length === 1) {
return availableUsers[0];
}
const bookingsOfAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventType.id,
users: availableUsers.map((user) => {
return { id: user.id, email: user.email };
}),
});
switch (distributionAlgorithm) {
case "MAXIMIZE_AVAILABILITY":
let possibleLuckyUsers = availableUsers;
if (eventType.isRRWeightsEnabled) {
possibleLuckyUsers = await getUsersBasedOnWeights({
...getLuckyUserParams,
bookingsOfAvailableUsers,
});
}
const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers: possibleLuckyUsers });
return leastRecentlyBookedUser<T>({
...getLuckyUserParams,
availableUsers: highestPriorityUsers,
bookingsOfAvailableUsers,
});
}
}