* only fetch bookings of this month for weighted rr * test set up * only get bookings of current month * reverts test setup * first changes for weight adjustments * reset booking count + adjust calibration * depreciate weightAdjustment * get only bookings created this month * fix typo * make sure createdAt for hosts is correct * use earliest possibel date as fallback * add missing createdAt date to tests * fix typo * clean up changes in tests * fix typo * change end date to current date * fix: Fall back to empty host array when no hosts are found * fix: Restructure code a little * fixed test, incorrectly used now outdated var * perf: remove Dayjs from getLuckyUser * Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array * Better mock for host.findMany * Remove team-event-types.test.ts, move to appropriate package * TypeScript cannot auto-infer that an array is non-empty when assigning to a var * fix: Type Fixes and DistributionMethod enum add * Optimise tests * Added test to show that bookings made before a newHost was added affect the lucky user result * Throw error when the usersWithHighestPriority is empty, which should never happen * WIP * remove comment * update migrations * get attributes weights and virtual queue data * use attribute weights and use bookings of virtual queue only * clean up migrations * code clean up from feedback * wrapper function for getLuckyUser * code clean up * fetch routingFormResponse in handleNewBooking * fix type errors * fix type errors in tests * fix getAttributesQueryValue import for tests * fix totalWeight * add test for attributes weights and virtual queues * clean up code * add test for prepareQueuesAndAttributesData * remove console.log * use lazy import * use findUnique * add fallback weight --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
665 lines
21 KiB
TypeScript
665 lines
21 KiB
TypeScript
import type { Prisma, User } from "@prisma/client";
|
|
|
|
import { getFieldResponse } from "@calcom/app-store/routing-forms/trpc/utils";
|
|
import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
|
|
import { zodRoutes, children1Schema } from "@calcom/app-store/routing-forms/zod";
|
|
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
|
import prisma from "@calcom/prisma";
|
|
import type { Booking } from "@calcom/prisma/client";
|
|
import type { AttributeType } from "@calcom/prisma/enums";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
|
|
async function getAttributesQueryValue() {
|
|
const { getAttributesQueryValue } = (await import("@calcom/app-store/routing-forms/lib/raqbUtils"))
|
|
.acrossQueryValueCompatiblity;
|
|
return getAttributesQueryValue;
|
|
}
|
|
|
|
type PartialBooking = Pick<Booking, "id" | "createdAt" | "userId" | "status"> & {
|
|
attendees: { email: string | null }[];
|
|
};
|
|
|
|
type PartialUser = Pick<User, "id" | "email">;
|
|
|
|
type AttributeWithWeights = {
|
|
name: string;
|
|
slug: string;
|
|
type: AttributeType;
|
|
id: string;
|
|
options: {
|
|
id: string;
|
|
value: string;
|
|
slug: string;
|
|
assignedUsers: {
|
|
weight: number | null;
|
|
member: {
|
|
userId: number;
|
|
};
|
|
}[];
|
|
}[];
|
|
};
|
|
|
|
type VirtualQueuesDataType = {
|
|
chosenRouteId: string;
|
|
fieldOptionData: {
|
|
fieldId: string;
|
|
selectedOptionIds: string | number | string[];
|
|
};
|
|
};
|
|
|
|
interface GetLuckyUserParams<T extends PartialUser> {
|
|
availableUsers: [T, ...T[]]; // ensure contains at least 1
|
|
eventType: { id: number; isRRWeightsEnabled: boolean; team: { parentId?: number | null } | null };
|
|
// all routedTeamMemberIds or all hosts of event types
|
|
allRRHosts: {
|
|
user: { id: number; email: string };
|
|
createdAt: Date;
|
|
weight?: number | null;
|
|
}[];
|
|
routingFormResponse?: RoutingFormResponse | null;
|
|
}
|
|
// === dayjs.utc().startOf("month").toDate();
|
|
const startOfMonth = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
|
|
// TS helper function.
|
|
const isNonEmptyArray = <T>(arr: T[]): arr is [T, ...T[]] => arr.length > 0;
|
|
|
|
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;
|
|
}
|
|
|
|
async function getHostsWithCalibration(
|
|
eventTypeId: number,
|
|
hosts: { userId: number; email: string; createdAt: Date }[],
|
|
virtualQueuesData?: VirtualQueuesDataType
|
|
) {
|
|
const [newHostsArray, existingBookings] = await Promise.all([
|
|
prisma.host.findMany({
|
|
where: {
|
|
userId: {
|
|
in: hosts.map((host) => host.userId),
|
|
},
|
|
eventTypeId,
|
|
isFixed: false,
|
|
createdAt: {
|
|
gte: startOfMonth,
|
|
},
|
|
},
|
|
}),
|
|
BookingRepository.getAllBookingsForRoundRobin({
|
|
eventTypeId,
|
|
users: hosts.map((host) => ({
|
|
id: host.userId,
|
|
email: host.email,
|
|
})),
|
|
startDate: startOfMonth,
|
|
endDate: new Date(),
|
|
virtualQueuesData,
|
|
}),
|
|
]);
|
|
// Return early if there are no new hosts or no existing bookings
|
|
if (newHostsArray.length === 0 || existingBookings.length === 0) {
|
|
return hosts.map((host) => ({ ...host, calibration: 0 }));
|
|
}
|
|
// Helper function to calculate calibration for a new host
|
|
function calculateCalibration(newHost: { userId: number; createdAt: Date }) {
|
|
const existingBookingsBeforeAdded = existingBookings.filter(
|
|
(booking) => booking.userId !== newHost.userId && booking.createdAt < newHost.createdAt
|
|
);
|
|
const hostsAddedBefore = hosts.filter(
|
|
(host) => host.userId !== newHost.userId && host.createdAt < newHost.createdAt
|
|
);
|
|
return existingBookingsBeforeAdded.length && hostsAddedBefore.length
|
|
? existingBookingsBeforeAdded.length / hostsAddedBefore.length
|
|
: 0;
|
|
}
|
|
// Calculate calibration for each new host and store in a Map
|
|
const newHostsWithCalibration = new Map(
|
|
newHostsArray.map((newHost) => [
|
|
newHost.userId,
|
|
{ ...newHost, calibration: calculateCalibration(newHost) },
|
|
])
|
|
);
|
|
// Map hosts with their respective calibration values
|
|
return hosts.map((host) => ({
|
|
...host,
|
|
calibration: newHostsWithCalibration.get(host.userId)?.calibration ?? 0,
|
|
}));
|
|
}
|
|
|
|
function getUsersWithHighestPriority<T extends PartialUser & { priority?: number | null }>({
|
|
availableUsers,
|
|
}: {
|
|
availableUsers: T[];
|
|
}) {
|
|
const highestPriority = Math.max(...availableUsers.map((user) => user.priority ?? 2));
|
|
const usersWithHighestPriority = availableUsers.filter(
|
|
(user) => user.priority === highestPriority || (user.priority == null && highestPriority === 2)
|
|
);
|
|
if (!isNonEmptyArray(usersWithHighestPriority)) {
|
|
throw new Error("Internal Error: Highest Priority filter should never return length=0.");
|
|
}
|
|
return usersWithHighestPriority;
|
|
}
|
|
|
|
async function filterUsersBasedOnWeights<
|
|
T extends PartialUser & {
|
|
weight?: number | null;
|
|
}
|
|
>({
|
|
availableUsers,
|
|
bookingsOfAvailableUsers,
|
|
allRRHosts,
|
|
eventType,
|
|
virtualQueuesData,
|
|
attributeWeights,
|
|
}: GetLuckyUserParams<T> & {
|
|
bookingsOfAvailableUsers: PartialBooking[];
|
|
virtualQueuesData?: VirtualQueuesDataType;
|
|
attributeWeights?: {
|
|
userId: number;
|
|
weight: number;
|
|
}[];
|
|
}): Promise<[T, ...T[]]> {
|
|
//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;
|
|
},
|
|
[]
|
|
);
|
|
|
|
//only get bookings where response matches the virtual queue
|
|
const bookingsOfNotAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({
|
|
eventTypeId: eventType.id,
|
|
users: notAvailableHosts,
|
|
startDate: startOfMonth,
|
|
endDate: new Date(),
|
|
virtualQueuesData,
|
|
});
|
|
|
|
const allBookings = bookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsers);
|
|
|
|
const allHostsWithCalibration = await getHostsWithCalibration(
|
|
eventType.id,
|
|
allRRHosts.map((host) => {
|
|
return { email: host.user.email, userId: host.user.id, createdAt: host.createdAt };
|
|
}),
|
|
virtualQueuesData
|
|
);
|
|
|
|
// Calculate the total calibration and weight of all round-robin hosts
|
|
let totalWeight: number;
|
|
|
|
if (attributeWeights) {
|
|
totalWeight = attributeWeights.reduce((totalWeight, userWeight) => {
|
|
totalWeight += userWeight.weight ?? 100;
|
|
return totalWeight;
|
|
}, 0);
|
|
} else {
|
|
totalWeight = allRRHosts.reduce((totalWeight, host) => {
|
|
totalWeight += host.weight ?? 100;
|
|
return totalWeight;
|
|
}, 0);
|
|
}
|
|
|
|
const totalCalibration = allHostsWithCalibration.reduce((totalCalibration, host) => {
|
|
totalCalibration += host.calibration;
|
|
return totalCalibration;
|
|
}, 0);
|
|
|
|
// Calculate booking shortfall for each available user
|
|
const usersWithBookingShortfalls = availableUsers.map((user) => {
|
|
let userWeight = user.weight ?? 100;
|
|
|
|
if (attributeWeights) {
|
|
userWeight = attributeWeights.find((userWeight) => userWeight.userId === user.id)?.weight ?? 100;
|
|
}
|
|
|
|
const targetPercentage = userWeight / totalWeight;
|
|
|
|
const userBookings = bookingsOfAvailableUsers.filter(
|
|
(booking) =>
|
|
booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email)
|
|
);
|
|
|
|
const targetNumberOfBookings = (allBookings.length + totalCalibration) * targetPercentage;
|
|
const userCalibration = allHostsWithCalibration.find((host) => host.userId === user.id)?.calibration ?? 0;
|
|
|
|
const bookingShortfall = targetNumberOfBookings - (userBookings.length + userCalibration);
|
|
|
|
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)
|
|
);
|
|
const remainingUsersAfterWeightFilter = availableUsers.filter((user) =>
|
|
userIdsWithMaxShortfallAndWeight.has(user.id)
|
|
);
|
|
if (!isNonEmptyArray(remainingUsersAfterWeightFilter)) {
|
|
throw new Error("Internal Error: Weight filter should never return length=0.");
|
|
}
|
|
return remainingUsersAfterWeightFilter;
|
|
}
|
|
|
|
export async function getLuckyUser<
|
|
T extends PartialUser & {
|
|
priority?: number | null;
|
|
weight?: number | null;
|
|
}
|
|
>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T>) {
|
|
const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams);
|
|
return _getLuckyUser(
|
|
{
|
|
...getLuckyUserParams,
|
|
availableUsers,
|
|
},
|
|
attributeWeights,
|
|
virtualQueuesData
|
|
);
|
|
}
|
|
|
|
export async function prepareQueuesAndAttributesData<T extends PartialUser>({
|
|
eventType,
|
|
routingFormResponse,
|
|
allRRHosts,
|
|
}: Omit<GetLuckyUserParams<T>, "availableUsers">) {
|
|
let attributeWeights;
|
|
let virtualQueuesData;
|
|
|
|
if (routingFormResponse && eventType.team?.parentId) {
|
|
const attributeWithEnabledWeights = await prisma.attribute.findFirst({
|
|
where: {
|
|
teamId: eventType.team?.parentId,
|
|
isWeightsEnabled: true,
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
type: true,
|
|
options: {
|
|
select: {
|
|
id: true,
|
|
value: true,
|
|
slug: true,
|
|
assignedUsers: {
|
|
select: {
|
|
member: {
|
|
select: {
|
|
userId: true,
|
|
},
|
|
},
|
|
weight: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (attributeWithEnabledWeights) {
|
|
// Virtual queues are defined by the attribute that has weights and is used with 'Value of field ...'
|
|
const queueAndAtributeWeightData = await getQueueAndAttributeWeightData(
|
|
allRRHosts,
|
|
routingFormResponse,
|
|
attributeWithEnabledWeights
|
|
);
|
|
|
|
console.log(`attributeWithEnabledWeights ${JSON.stringify(attributeWithEnabledWeights)}`);
|
|
|
|
if (queueAndAtributeWeightData?.averageWeightsHosts && queueAndAtributeWeightData?.virtualQueuesData) {
|
|
attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts;
|
|
virtualQueuesData = queueAndAtributeWeightData?.virtualQueuesData;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { attributeWeights, virtualQueuesData };
|
|
}
|
|
|
|
async function getQueueAndAttributeWeightData<T extends PartialUser & { priority?: number | null }>(
|
|
allRRHosts: GetLuckyUserParams<T>["allRRHosts"],
|
|
routingFormResponse: RoutingFormResponse,
|
|
attributeWithWeights: AttributeWithWeights
|
|
) {
|
|
let averageWeightsHosts: { userId: number; weight: number }[] = [];
|
|
|
|
const chosenRouteId = routingFormResponse?.chosenRouteId ?? undefined;
|
|
|
|
if (!chosenRouteId) return;
|
|
|
|
let fieldOptionData: { fieldId: string; selectedOptionIds: string | number | string[] } | undefined;
|
|
|
|
const routingForm = routingFormResponse?.form;
|
|
|
|
if (routingForm && routingFormResponse) {
|
|
const response = routingFormResponse.response as FormResponse;
|
|
|
|
const routes = zodRoutes.parse(routingForm.routes);
|
|
const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId);
|
|
|
|
if (chosenRoute && "attributesQueryValue" in chosenRoute) {
|
|
const parsedAttributesQueryValue = children1Schema.parse(chosenRoute.attributesQueryValue);
|
|
|
|
const attributesQueryValueWithLabel = (await getAttributesQueryValue())({
|
|
attributesQueryValue: chosenRoute.attributesQueryValue,
|
|
attributes: [attributeWithWeights],
|
|
response,
|
|
fields: routingFormResponse.form.fields as Fields,
|
|
getFieldResponse: getFieldResponse,
|
|
});
|
|
|
|
const parsedAttributesQueryValueWithLabel = children1Schema.parse(attributesQueryValueWithLabel);
|
|
|
|
if (parsedAttributesQueryValueWithLabel && parsedAttributesQueryValueWithLabel.children1) {
|
|
averageWeightsHosts = getAverageAttributeWeights(
|
|
allRRHosts,
|
|
parsedAttributesQueryValueWithLabel.children1,
|
|
attributeWithWeights
|
|
);
|
|
}
|
|
|
|
if (parsedAttributesQueryValue && parsedAttributesQueryValue.children1) {
|
|
fieldOptionData = getAttributesForVirtualQueues(
|
|
response,
|
|
parsedAttributesQueryValue.children1,
|
|
attributeWithWeights
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fieldOptionData) {
|
|
return { averageWeightsHosts, virtualQueuesData: { chosenRouteId, fieldOptionData } };
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
function getAverageAttributeWeights<
|
|
T extends PartialUser & {
|
|
priority?: number | null;
|
|
weight?: number | null;
|
|
}
|
|
>(
|
|
allRRHosts: GetLuckyUserParams<T>["allRRHosts"],
|
|
attributesQueryValueChild: Record<
|
|
string,
|
|
{
|
|
type?: string | undefined;
|
|
properties?:
|
|
| {
|
|
field?: any;
|
|
operator?: any;
|
|
value?: any;
|
|
valueSrc?: any;
|
|
}
|
|
| undefined;
|
|
}
|
|
>,
|
|
attributeWithWeights: AttributeWithWeights
|
|
) {
|
|
let averageWeightsHosts: { userId: number; weight: number }[] = [];
|
|
|
|
const fieldValueArray = Object.values(attributesQueryValueChild).map((child) => ({
|
|
field: child.properties?.field,
|
|
value: child.properties?.value,
|
|
}));
|
|
|
|
fieldValueArray.map((obj) => {
|
|
const attributeId = obj.field;
|
|
const allRRHostsWeights = new Map<number, number[]>();
|
|
|
|
if (attributeId === attributeWithWeights.id) {
|
|
obj.value.forEach((arrayobj: string[]) => {
|
|
arrayobj.forEach((attributeOption: string) => {
|
|
// attributeOption is either optionId or label, we only care about labels here
|
|
const attributeOptionWithUsers = attributeWithWeights.options.find(
|
|
(option) => option.value.toLowerCase() === attributeOption.toLowerCase()
|
|
);
|
|
|
|
allRRHosts.forEach((rrHost) => {
|
|
const weight =
|
|
attributeOptionWithUsers?.assignedUsers.find(
|
|
(assignedUser) => rrHost.user.id === assignedUser.member.userId
|
|
)?.weight ?? 100;
|
|
|
|
if (weight) {
|
|
if (allRRHostsWeights.has(rrHost.user.id)) {
|
|
allRRHostsWeights.get(rrHost.user.id)?.push(weight);
|
|
} else {
|
|
allRRHostsWeights.set(rrHost.user.id, [weight]);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
averageWeightsHosts = Array.from(allRRHostsWeights.entries()).map(([userId, weights]) => {
|
|
const totalWeight = weights.reduce((acc, weight) => acc + weight, 0);
|
|
const averageWeight = totalWeight / weights.length;
|
|
|
|
return {
|
|
userId,
|
|
weight: averageWeight,
|
|
};
|
|
});
|
|
}
|
|
});
|
|
|
|
return averageWeightsHosts;
|
|
}
|
|
|
|
function getAttributesForVirtualQueues(
|
|
response: Record<string, Pick<FormResponse[keyof FormResponse], "value">>,
|
|
attributesQueryValueChild: Record<
|
|
string,
|
|
{
|
|
type?: string | undefined;
|
|
properties?:
|
|
| {
|
|
field?: any;
|
|
operator?: any;
|
|
value?: any;
|
|
valueSrc?: any;
|
|
}
|
|
| undefined;
|
|
}
|
|
>,
|
|
attributeWithWeights: { id: string }
|
|
) {
|
|
let selectionOptions: Pick<VirtualQueuesDataType, "fieldOptionData">["fieldOptionData"] | undefined;
|
|
|
|
const fieldValueArray = Object.values(attributesQueryValueChild).map((child) => ({
|
|
field: child.properties?.field,
|
|
value: child.properties?.value,
|
|
}));
|
|
|
|
fieldValueArray.some((obj) => {
|
|
const attributeId = obj.field;
|
|
|
|
if (attributeId === attributeWithWeights.id) {
|
|
obj.value.some((arrayobj: string[]) => {
|
|
arrayobj.some((attributeOptionId: string) => {
|
|
const content = attributeOptionId.slice(1, -1);
|
|
|
|
const routingFormFieldId = content.includes("field:") ? content.split("field:")[1] : null;
|
|
|
|
if (routingFormFieldId) {
|
|
const fieldResponse = response[routingFormFieldId];
|
|
selectionOptions = { fieldId: routingFormFieldId, selectedOptionIds: fieldResponse.value };
|
|
return true; // break out of all loops
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
return selectionOptions;
|
|
}
|
|
|
|
type RoutingFormResponse = {
|
|
response: Prisma.JsonValue;
|
|
chosenRouteId: string | null;
|
|
form: {
|
|
fields: Prisma.JsonValue;
|
|
routes: Prisma.JsonValue;
|
|
};
|
|
};
|
|
|
|
async function _getLuckyUser<
|
|
T extends PartialUser & {
|
|
priority?: number | null;
|
|
weight?: number | null;
|
|
}
|
|
>(
|
|
{ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T>,
|
|
attributeWeights?: {
|
|
userId: number;
|
|
weight: number;
|
|
}[],
|
|
virtualQueuesData?: VirtualQueuesDataType
|
|
) {
|
|
//maybe pass response directly not id
|
|
const { eventType } = getLuckyUserParams;
|
|
|
|
// there is only one user
|
|
if (availableUsers.length === 1) {
|
|
return availableUsers[0];
|
|
}
|
|
const currentMonthBookingsOfAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({
|
|
eventTypeId: eventType.id,
|
|
users: availableUsers.map((user) => {
|
|
return { id: user.id, email: user.email };
|
|
}),
|
|
startDate: startOfMonth,
|
|
endDate: new Date(),
|
|
virtualQueuesData,
|
|
});
|
|
if (eventType.isRRWeightsEnabled) {
|
|
availableUsers = await filterUsersBasedOnWeights({
|
|
...getLuckyUserParams,
|
|
availableUsers,
|
|
bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers,
|
|
virtualQueuesData,
|
|
attributeWeights,
|
|
});
|
|
}
|
|
const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers });
|
|
// No need to round-robin through the only user, return early also.
|
|
if (highestPriorityUsers.length === 1) return highestPriorityUsers[0];
|
|
// TS is happy.
|
|
return leastRecentlyBookedUser({
|
|
...getLuckyUserParams,
|
|
availableUsers: highestPriorityUsers,
|
|
bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers,
|
|
});
|
|
}
|