feat: Implements maxLeadThreshold (#17643)
* feat: Implements maxLeadThreshold which puts a maximum to the amount of lead a given host is allowed to be ahead * Refactor getLuckyUser + implement in handleNewBooking + move logic to repo * Decoupled @prisma from findQualifiedHosts * Fix deep type error * Add maxLeadThreshold to builder.ts * Removed a console.log :O + fix handleChildrenEventTypes input * Add distribution UI to enable balanced distribution/maximize availability * 0-1-2 instead of 0-1-2-3 (3 bookings instead of 4)
This commit is contained in:
@@ -2770,6 +2770,12 @@
|
||||
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||
"salesforce_option": "Salesforce Option",
|
||||
"lookup_field_name": "Lookup Field Name",
|
||||
"rr_distribution_method": "Distribution",
|
||||
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
|
||||
"rr_distribution_method_availability_title": "Maximize availability",
|
||||
"rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.",
|
||||
"rr_distribution_method_balanced_title": "Load balancing",
|
||||
"rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.",
|
||||
"exclude_emails_that_contain" : "Exclude emails that contain ...",
|
||||
"exclude_emails_match_found_error_message" : "Please enter a valid work email address",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
||||
@@ -57,7 +57,7 @@ import logger from "@calcom/lib/logger";
|
||||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { DistributionMethod, getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
||||
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
@@ -469,7 +469,7 @@ async function handler(
|
||||
|
||||
const newLuckyUser = isSameRoundRobinHost
|
||||
? freeUsers.find((user) => user.id === originalRescheduledBookingUserId)
|
||||
: await getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
: await getLuckyUser({
|
||||
// find a lucky user that is not already in the luckyUsers array
|
||||
availableUsers: freeUsers,
|
||||
allRRHosts: eventTypeWithUsers.hosts.filter((host) => !host.isFixed),
|
||||
|
||||
@@ -53,6 +53,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
maxLeadThreshold: true,
|
||||
minimumBookingNotice: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Logger } from "tslog";
|
||||
|
||||
import { filterHostsByLeadThreshold } from "@calcom/lib/bookings/filterHostsByLeadThreshold";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
@@ -16,9 +17,13 @@ import type { NewBookingEventType } from "./types";
|
||||
type Users = (Awaited<ReturnType<typeof loadUsers>>[number] & {
|
||||
isFixed?: boolean;
|
||||
metadata?: Prisma.JsonValue;
|
||||
createdAt?: Date;
|
||||
})[];
|
||||
|
||||
type EventType = Pick<NewBookingEventType, "hosts" | "users" | "id" | "userId" | "schedulingType">;
|
||||
type EventType = Pick<
|
||||
NewBookingEventType,
|
||||
"hosts" | "users" | "id" | "userId" | "schedulingType" | "maxLeadThreshold"
|
||||
>;
|
||||
|
||||
type InputProps = {
|
||||
req: IncomingMessage;
|
||||
@@ -79,7 +84,7 @@ export async function loadAndValidateUsers({
|
||||
}
|
||||
|
||||
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
|
||||
|
||||
// map fixed users
|
||||
users = users.map((user) => ({
|
||||
...user,
|
||||
isFixed:
|
||||
@@ -88,6 +93,22 @@ export async function loadAndValidateUsers({
|
||||
: user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN,
|
||||
}));
|
||||
|
||||
const qualifiedHosts = await filterHostsByLeadThreshold({
|
||||
eventTypeId: eventType.id,
|
||||
hosts: eventType.hosts.map((host) => ({
|
||||
isFixed: host.isFixed,
|
||||
createdAt: host.createdAt,
|
||||
email: host.user.email,
|
||||
user: host.user,
|
||||
})),
|
||||
maxLeadThreshold: eventType.maxLeadThreshold,
|
||||
});
|
||||
if (qualifiedHosts.length) {
|
||||
// remove users that are not in the qualified hosts array
|
||||
const qualifiedHostIds = new Set(qualifiedHosts.map((qualifiedHost) => qualifiedHost.user.id));
|
||||
users = users.filter((user) => qualifiedHostIds.has(user.id));
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Concerned users",
|
||||
safeStringify({
|
||||
|
||||
@@ -46,13 +46,19 @@ export const loadUsers = async ({
|
||||
|
||||
const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEventType["users"]> => {
|
||||
const hosts = eventType.hosts || [];
|
||||
const users = hosts.map(({ user, isFixed, priority, weight }) => ({
|
||||
const users = hosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
|
||||
...user,
|
||||
isFixed,
|
||||
priority,
|
||||
weight,
|
||||
createdAt,
|
||||
}));
|
||||
return users.length ? users : eventType.users;
|
||||
return users.length
|
||||
? users
|
||||
: eventType.users.map((user) => ({
|
||||
...user,
|
||||
createdAt: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => {
|
||||
|
||||
@@ -225,7 +225,6 @@ export default async function handleChildrenEventTypes({
|
||||
const updatePayloadFiltered = Object.entries(updatePayload)
|
||||
.filter(([key, _]) => key !== "children")
|
||||
.reduce((newObj, [key, value]) => ({ ...newObj, [key]: value }), {});
|
||||
console.log({ unlockedFieldProps });
|
||||
// Update event types for old users
|
||||
const oldEventTypes = await prisma.$transaction(
|
||||
oldUserIds.map((userId) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getLuckyUser } from "@calcom/lib/server";
|
||||
import { DistributionMethod } from "@calcom/lib/server/getLuckyUser";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
@@ -126,7 +125,7 @@ export const roundRobinReassignment = async ({
|
||||
roundRobinReassignLogger
|
||||
);
|
||||
|
||||
const reassignedRRHost = await getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
const reassignedRRHost = await getLuckyUser({
|
||||
availableUsers,
|
||||
eventType: {
|
||||
id: eventType.id,
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
import { Label, Select, SettingsToggle, RadioGroup as RadioArea } from "@calcom/ui";
|
||||
|
||||
export type EventTeamAssignmentTabBaseProps = Pick<EventTypeSetupProps, "teamMembers" | "team" | "eventType">;
|
||||
|
||||
@@ -333,7 +333,6 @@ const Hosts = ({
|
||||
assignAllTeamMembers: boolean;
|
||||
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
control,
|
||||
setValue,
|
||||
@@ -498,6 +497,44 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle mt-4 flex flex-col rounded-md">
|
||||
<div className="border-subtle rounded-t-md border p-6 pb-5">
|
||||
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
|
||||
<p className="text-subtle max-w-full break-words text-sm leading-tight">
|
||||
{t("rr_distribution_method_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 p-6">
|
||||
<Controller
|
||||
name="maxLeadThreshold"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<RadioArea.Group
|
||||
onValueChange={(val) => {
|
||||
if (val === "loadBalancing") onChange(3);
|
||||
else onChange(null);
|
||||
}}
|
||||
className="mt-1 flex flex-col gap-4">
|
||||
<RadioArea.Item
|
||||
value="maximizeAvailability"
|
||||
checked={value === null}
|
||||
className="w-full text-sm"
|
||||
classNames={{ container: "w-full" }}>
|
||||
<strong className="mb-1 block">{t("rr_distribution_method_availability_title")}</strong>
|
||||
<p>{t("rr_distribution_method_availability_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item
|
||||
value="loadBalancing"
|
||||
checked={value !== null}
|
||||
className="text-sm"
|
||||
classNames={{ container: "w-full" }}>
|
||||
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
|
||||
<p>{t("rr_distribution_method_balanced_description")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Hosts
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||
|
||||
@@ -137,6 +137,7 @@ export type FormValues = {
|
||||
forwardParamsSuccessRedirect: boolean | null;
|
||||
secondaryEmailId?: number;
|
||||
isRRWeightsEnabled: boolean;
|
||||
maxLeadThreshold?: number;
|
||||
};
|
||||
|
||||
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
filterHostsByLeadThreshold,
|
||||
errorCodes,
|
||||
_filterHostByLeadThreshold,
|
||||
} from "./filterHostsByLeadThreshold";
|
||||
|
||||
// Import the original Prisma client
|
||||
|
||||
// Mocking setup
|
||||
const prismaMock = {
|
||||
booking: {
|
||||
groupBy: vi.fn(), // Mock the groupBy method
|
||||
},
|
||||
};
|
||||
|
||||
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
|
||||
vi.spyOn(prisma.booking, "groupBy").mockImplementation(prismaMock.booking.groupBy);
|
||||
|
||||
afterEach(() => {
|
||||
// Clear call history before each test to avoid cross-test interference
|
||||
prismaMock.booking.groupBy.mockClear();
|
||||
});
|
||||
|
||||
describe("filterHostByLeadThreshold", () => {
|
||||
it("skips filter if host is fixed", async () => {
|
||||
const hosts = [{ isFixed: true, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } }];
|
||||
expect(
|
||||
filterHostsByLeadThreshold({
|
||||
hosts,
|
||||
maxLeadThreshold: 3,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(hosts);
|
||||
});
|
||||
it("skips filter if lead threshold is null", async () => {
|
||||
const hosts = [{ isFixed: false, createdAt: new Date(), user: { id: 1 } }];
|
||||
expect(
|
||||
filterHostsByLeadThreshold({
|
||||
hosts,
|
||||
maxLeadThreshold: null,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(hosts);
|
||||
});
|
||||
it("throws error when maxLeadThreshold = 0, 0 ahead makes no sense.", () => {
|
||||
expect(() =>
|
||||
_filterHostByLeadThreshold({
|
||||
host: { leadOffset: 3 },
|
||||
maxLeadThreshold: 0,
|
||||
})
|
||||
).toThrow(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
|
||||
});
|
||||
|
||||
it("correctly disqualifies a host when the lead offset is exceeding the threshold", async () => {
|
||||
prismaMock.booking.groupBy.mockResolvedValue([
|
||||
{ userId: 1, _count: { _all: 5 } },
|
||||
{ userId: 2, _count: { _all: 10 } },
|
||||
]);
|
||||
const hosts = [
|
||||
{ isFixed: false, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
|
||||
{ isFixed: false, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
|
||||
];
|
||||
// host is not disqualified as the threshold of 11 is not exceeded.
|
||||
expect(
|
||||
filterHostsByLeadThreshold({
|
||||
hosts,
|
||||
maxLeadThreshold: 11,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(hosts);
|
||||
// with a reduced threshold of 3 the second host (t=10) is disqualified
|
||||
expect(
|
||||
filterHostsByLeadThreshold({
|
||||
hosts,
|
||||
maxLeadThreshold: 3,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual([hosts.find(({ user: { id: userId } }) => userId === 1)]);
|
||||
// double check that lead thresholds are disabled when maxLeadThreshold=null as I'm paranoid.
|
||||
expect(
|
||||
filterHostsByLeadThreshold({
|
||||
hosts,
|
||||
maxLeadThreshold: null,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual(hosts);
|
||||
});
|
||||
|
||||
it("ignores fixed users towards fairness disqualification", async () => {
|
||||
prismaMock.booking.groupBy.mockResolvedValue([
|
||||
{ userId: 1, _count: { _all: 5 } },
|
||||
{ userId: 2, _count: { _all: 10 } },
|
||||
]);
|
||||
const hosts = [
|
||||
// fixed users do not count towards disqualification.
|
||||
{ isFixed: true, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
|
||||
{ isFixed: false, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
|
||||
];
|
||||
// with a reduced threshold of 3 the second host (t=10) is disqualified
|
||||
expect(
|
||||
filterHostsByLeadThreshold({
|
||||
hosts,
|
||||
maxLeadThreshold: 3,
|
||||
eventTypeId: 1,
|
||||
})
|
||||
).resolves.toStrictEqual([hosts.find(({ user: { id: userId } }) => userId === 1)]);
|
||||
expect(prismaMock.booking.groupBy).toHaveBeenCalledWith({
|
||||
by: ["userId"],
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
user: {
|
||||
id: {
|
||||
in: [2],
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
noShowHost: false,
|
||||
},
|
||||
{
|
||||
noShowHost: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: {
|
||||
in: ["example2@acme.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
attendees: { some: { noShow: false } },
|
||||
status: BookingStatus.ACCEPTED,
|
||||
eventTypeId: 1,
|
||||
createdAt: {
|
||||
gte: hosts[1].createdAt,
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
|
||||
const START_OF_MONTH = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
|
||||
|
||||
export const errorCodes = {
|
||||
MAX_LEAD_THRESHOLD_FALSY: "Max lead threshold should be null or > 1, not 0.",
|
||||
} as const;
|
||||
|
||||
function getMostRecentDate(dates: Date[]): Date {
|
||||
if (dates.length === 0)
|
||||
throw new Error("Array of date length provided in getMostRecentDate should not be empty.");
|
||||
|
||||
return dates.reduce((mostRecent, current) => (current > mostRecent ? current : mostRecent));
|
||||
}
|
||||
|
||||
const computeLeadOffsets = async <T = Record<string, unknown>>({
|
||||
hosts,
|
||||
eventTypeId,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
hosts: (T & {
|
||||
isFixed: false;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
};
|
||||
})[];
|
||||
}) => {
|
||||
if (!hosts.length) return [];
|
||||
// use either the beginning of the month, of the most recently added host; whichever is most recent
|
||||
const startDate = getMostRecentDate([...hosts.map(({ createdAt }) => createdAt), START_OF_MONTH]);
|
||||
// we need booking data now, this cannot be queried ahead of time as it requires knowing the most recent host date
|
||||
// data only available after the initial call.
|
||||
const bookingCounts = await BookingRepository.groupByActiveBookingCounts({
|
||||
startDate,
|
||||
users: hosts.map((host) => ({
|
||||
...host.user,
|
||||
})),
|
||||
eventTypeId,
|
||||
});
|
||||
const { minBookingCount, bookingCountMap } = bookingCounts.reduce(
|
||||
(
|
||||
acc: {
|
||||
minBookingCount: number;
|
||||
bookingCountMap: Record<number, number>;
|
||||
},
|
||||
booking
|
||||
) => {
|
||||
// satisfy TS, obviously as we've where'd on userId it cannot be null but try telling TS that..
|
||||
if (!booking.userId) return acc;
|
||||
acc.minBookingCount = Math.min(acc.minBookingCount, booking._count._all || Infinity);
|
||||
acc.bookingCountMap[booking.userId] = booking._count._all;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
minBookingCount: bookingCounts.length < hosts.length ? 0 : Infinity,
|
||||
bookingCountMap: {},
|
||||
}
|
||||
);
|
||||
|
||||
return hosts.map((host) => {
|
||||
const leadOffset = (bookingCountMap[host.user.id] || 0) - minBookingCount;
|
||||
return {
|
||||
...host,
|
||||
leadOffset,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const _filterHostByLeadThreshold = ({
|
||||
host,
|
||||
maxLeadThreshold,
|
||||
}: {
|
||||
host: { leadOffset: number };
|
||||
maxLeadThreshold: number;
|
||||
}) => {
|
||||
// it's possible that maxLeadThreshold is given as 0, which makes '0' sense.
|
||||
if (!maxLeadThreshold) {
|
||||
throw new Error(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
|
||||
}
|
||||
return host.leadOffset < maxLeadThreshold; // leadOffset is 0-indexed.
|
||||
};
|
||||
|
||||
/*
|
||||
* Filter the hosts by lead threshold, disqualifying hosts that have exceeded the maximum
|
||||
*
|
||||
* @throws errorCodes.MAX_LEAD_THRESHOLD_FALSY
|
||||
*/
|
||||
export const filterHostsByLeadThreshold = async <T = Record<string, unknown>>({
|
||||
hosts,
|
||||
maxLeadThreshold,
|
||||
eventTypeId,
|
||||
}: {
|
||||
hosts: ({ isFixed: boolean; createdAt: Date; user: { id: number; email: string } } & T)[];
|
||||
maxLeadThreshold: number | null;
|
||||
eventTypeId: number;
|
||||
}): Promise<Omit<T, "leadOffset">[]> => {
|
||||
if (maxLeadThreshold === null) return hosts;
|
||||
// Calculate offsets for non-fixed hosts only once
|
||||
const computedRoundRobinHosts = await computeLeadOffsets<T>({
|
||||
eventTypeId,
|
||||
hosts: hosts.filter((host) => !host.isFixed).map((host) => ({ ...host, isFixed: false as const })),
|
||||
});
|
||||
// Track indices of non-fixed hosts for easy mapping back to the original order
|
||||
let roundRobinIndex = 0;
|
||||
return hosts
|
||||
.map((host) => {
|
||||
if (host.isFixed) {
|
||||
// Return fixed hosts as they are
|
||||
return host as Omit<T, "leadOffset">;
|
||||
} else {
|
||||
// Apply lead threshold filtering on round-robin hosts
|
||||
const roundRobinHost = { ...computedRoundRobinHosts[roundRobinIndex++] };
|
||||
if (
|
||||
_filterHostByLeadThreshold({
|
||||
host: roundRobinHost,
|
||||
maxLeadThreshold,
|
||||
})
|
||||
) {
|
||||
// cleanup with destructure to remove leadOffset
|
||||
const { leadOffset, ...roundRobinHostWithoutLeadOffset } = roundRobinHost;
|
||||
return roundRobinHostWithoutLeadOffset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((host): host is Omit<T, "leadOffset"> => host !== null);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { vi, it, describe, expect, afterEach } from "vitest";
|
||||
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
|
||||
import { findQualifiedHosts } from "./findQualifiedHosts";
|
||||
|
||||
// Mock the filterHostsByLeadThreshold function
|
||||
vi.mock("./filterHostsByLeadThreshold", () => ({
|
||||
filterHostsByLeadThreshold: vi.fn(),
|
||||
}));
|
||||
|
||||
// Clear call history after each test
|
||||
afterEach(() => {
|
||||
(filterHostsByLeadThreshold as vi.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe("findQualifiedHosts", async () => {
|
||||
it("should return qualified hosts based on mock of filterHostsByLeadThreshold", async () => {
|
||||
const hosts = [
|
||||
{
|
||||
isFixed: true,
|
||||
createdAt: new Date(),
|
||||
user: {
|
||||
id: 2,
|
||||
email: "hellouser2@email.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
isFixed: false,
|
||||
createdAt: new Date(),
|
||||
user: {
|
||||
id: 1,
|
||||
email: "hellouser@email.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Configure the mock return value
|
||||
(filterHostsByLeadThreshold as vi.Mock).mockResolvedValue(hosts);
|
||||
|
||||
// Define the input for the test
|
||||
const eventType = {
|
||||
id: 1,
|
||||
hosts,
|
||||
users: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
maxLeadThreshold: null,
|
||||
};
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHosts(eventType);
|
||||
|
||||
// Verify the result
|
||||
expect(result).toEqual(hosts);
|
||||
});
|
||||
|
||||
it("should return hosts after valid input with users", async () => {
|
||||
const users = [
|
||||
{
|
||||
email: "hello@gmail.com",
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
email: "hello2@gmail.com",
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Define the input for the test
|
||||
const eventType = {
|
||||
id: 1,
|
||||
hosts: [],
|
||||
users,
|
||||
schedulingType: null,
|
||||
maxLeadThreshold: null,
|
||||
};
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHosts(eventType);
|
||||
|
||||
// Verify the result
|
||||
expect(result).toEqual(
|
||||
users.map((user) => ({
|
||||
user: user,
|
||||
isFixed: true,
|
||||
email: user.email,
|
||||
}))
|
||||
);
|
||||
|
||||
expect(filterHostsByLeadThreshold).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
|
||||
|
||||
export const findQualifiedHosts = async <
|
||||
T extends { email: string; id: number } & Record<string, unknown>
|
||||
>(eventType: {
|
||||
id: number;
|
||||
maxLeadThreshold: number | null;
|
||||
hosts?: ({ isFixed: boolean; createdAt: Date } & {
|
||||
user: T;
|
||||
})[];
|
||||
users?: T[];
|
||||
schedulingType: SchedulingType | null;
|
||||
}): Promise<
|
||||
{
|
||||
isFixed: boolean;
|
||||
email: string;
|
||||
user: T;
|
||||
}[]
|
||||
> => {
|
||||
const hosts =
|
||||
eventType.hosts?.length && eventType.schedulingType
|
||||
? await filterHostsByLeadThreshold({
|
||||
eventTypeId: eventType.id,
|
||||
hosts: eventType.hosts.map((host) => ({
|
||||
isFixed: host.isFixed,
|
||||
createdAt: host.createdAt,
|
||||
email: host.user.email,
|
||||
user: host.user,
|
||||
})),
|
||||
maxLeadThreshold: eventType.maxLeadThreshold,
|
||||
})
|
||||
: (eventType.users || []).map((user) => {
|
||||
return {
|
||||
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,
|
||||
email: user.email,
|
||||
user: user,
|
||||
};
|
||||
});
|
||||
return hosts;
|
||||
};
|
||||
@@ -110,6 +110,7 @@ const commons = {
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
secondaryEmailId: null,
|
||||
secondaryEmail: null,
|
||||
maxLeadThreshold: null,
|
||||
};
|
||||
|
||||
export const dynamicEvent = {
|
||||
|
||||
@@ -56,4 +56,5 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
durationLimits: true,
|
||||
eventTypeColor: true,
|
||||
hideCalendarEventDetails: true,
|
||||
maxLeadThreshold: true,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { DistributionMethod, getLuckyUser } from "./getLuckyUser";
|
||||
import { getLuckyUser } from "./getLuckyUser";
|
||||
|
||||
describe("getLuckyUser Integration tests", () => {
|
||||
describe("should not consider no show bookings for round robin: ", () => {
|
||||
@@ -102,7 +102,7 @@ describe("getLuckyUser Integration tests", () => {
|
||||
userIds.push(organizerThatShowedUp.id, organizerThatDidntShowUp.id);
|
||||
|
||||
expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp],
|
||||
eventType: {
|
||||
id: eventTypeId,
|
||||
@@ -172,7 +172,7 @@ describe("getLuckyUser Integration tests", () => {
|
||||
userIds.push(organizerWhoseAttendeeShowedUp.id, organizerWhoseAttendeeDidntShowUp.id);
|
||||
|
||||
expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: [organizerWhoseAttendeeShowedUp, organizerWhoseAttendeeDidntShowUp],
|
||||
eventType: {
|
||||
id: eventTypeId,
|
||||
@@ -291,7 +291,7 @@ describe("getLuckyUser Integration tests", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: [
|
||||
organizerWhoseAttendeeShowedUp,
|
||||
fixedHostOrganizerWhoseAttendeeDidNotShowUp,
|
||||
@@ -366,7 +366,7 @@ describe("getLuckyUser Integration tests", () => {
|
||||
userIds.push(user1.id, user2.id);
|
||||
|
||||
expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: [user1, user2],
|
||||
eventType: {
|
||||
id: eventTypeId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, it, describe } from "vitest";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { buildUser, buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
import { DistributionMethod, getLuckyUser } from "./getLuckyUser";
|
||||
import { getLuckyUser } from "./getLuckyUser";
|
||||
|
||||
type NonEmptyArray<T> = [T, ...T[]];
|
||||
type GetLuckyUserAvailableUsersType = NonEmptyArray<ReturnType<typeof buildUser>>;
|
||||
@@ -44,7 +44,7 @@ it("can find lucky user with maximize availability", async () => {
|
||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -91,7 +91,7 @@ it("can find lucky user with maximize availability and priority ranking", async
|
||||
|
||||
// both users have medium priority (one user has no priority set, default to medium) so pick least recently booked
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -146,7 +146,7 @@ it("can find lucky user with maximize availability and priority ranking", async
|
||||
prismaMock.host.findMany.mockResolvedValue([]);
|
||||
// pick the user with the highest priority
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: usersWithPriorities,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -206,7 +206,7 @@ it("can find lucky user with maximize availability and priority ranking", async
|
||||
|
||||
// pick the least recently booked user of the two with the highest priority
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: usersWithSamePriorities,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -292,7 +292,7 @@ describe("maximize availability and weights", () => {
|
||||
];
|
||||
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -386,7 +386,7 @@ describe("maximize availability and weights", () => {
|
||||
];
|
||||
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -480,7 +480,7 @@ describe("maximize availability and weights", () => {
|
||||
];
|
||||
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -560,7 +560,7 @@ describe("maximize availability and weights", () => {
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
@@ -583,7 +583,7 @@ describe("maximize availability and weights", () => {
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
|
||||
@@ -5,13 +5,6 @@ import prisma from "@calcom/prisma";
|
||||
import type { Booking } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export enum DistributionMethod {
|
||||
PRIORITIZE_AVAILABILITY = "PRIORITIZE_AVAILABILITY",
|
||||
// BALANCED_ASSIGNMENT = "BALANCED_ASSIGNMENT",
|
||||
// ROUND_ROBIN (for fairness, rotating through assignees)
|
||||
// LOAD_BALANCED (ensuring an even workload)
|
||||
}
|
||||
|
||||
type PartialBooking = Pick<Booking, "id" | "createdAt" | "userId" | "status"> & {
|
||||
attendees: { email: string | null }[];
|
||||
};
|
||||
@@ -291,17 +284,12 @@ async function filterUsersBasedOnWeights<
|
||||
return remainingUsersAfterWeightFilter;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
>(
|
||||
distributionMethod: DistributionMethod = DistributionMethod.PRIORITIZE_AVAILABILITY,
|
||||
{ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T>
|
||||
) {
|
||||
>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T>) {
|
||||
const { eventType } = getLuckyUserParams;
|
||||
// there is only one user
|
||||
if (availableUsers.length === 1) {
|
||||
@@ -315,25 +303,20 @@ export async function getLuckyUser<
|
||||
startDate: startOfMonth,
|
||||
endDate: new Date(),
|
||||
});
|
||||
|
||||
switch (distributionMethod) {
|
||||
case DistributionMethod.PRIORITIZE_AVAILABILITY: {
|
||||
if (eventType.isRRWeightsEnabled) {
|
||||
availableUsers = await filterUsersBasedOnWeights({
|
||||
...getLuckyUserParams,
|
||||
availableUsers,
|
||||
bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (eventType.isRRWeightsEnabled) {
|
||||
availableUsers = await filterUsersBasedOnWeights({
|
||||
...getLuckyUserParams,
|
||||
availableUsers,
|
||||
bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,56 @@ type TeamBookingsParamsWithCount = TeamBookingsParamsBase & {
|
||||
|
||||
type TeamBookingsParamsWithoutCount = TeamBookingsParamsBase;
|
||||
|
||||
const buildWhereClauseForActiveBookings = ({
|
||||
eventTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
users: { id: number; email: string }[];
|
||||
}): Prisma.BookingWhereInput => ({
|
||||
OR: [
|
||||
{
|
||||
user: {
|
||||
id: {
|
||||
in: users.map((user) => user.id),
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
noShowHost: false,
|
||||
},
|
||||
{
|
||||
noShowHost: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: {
|
||||
in: users.map((user) => user.email),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
attendees: { some: { noShow: false } },
|
||||
status: BookingStatus.ACCEPTED,
|
||||
eventTypeId,
|
||||
...(startDate || endDate
|
||||
? {
|
||||
createdAt: {
|
||||
...(startDate ? { gte: startDate } : {}),
|
||||
...(endDate ? { lte: endDate } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
export class BookingRepository {
|
||||
static async getBookingAttendees(bookingId: number) {
|
||||
return await prisma.attendee.findMany({
|
||||
@@ -74,6 +124,28 @@ export class BookingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async groupByActiveBookingCounts({
|
||||
users,
|
||||
eventTypeId,
|
||||
startDate,
|
||||
}: {
|
||||
users: { id: number; email: string }[];
|
||||
eventTypeId: number;
|
||||
startDate?: Date;
|
||||
}) {
|
||||
return await prisma.booking.groupBy({
|
||||
by: ["userId"],
|
||||
where: buildWhereClauseForActiveBookings({
|
||||
users,
|
||||
eventTypeId,
|
||||
startDate,
|
||||
}),
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getAllBookingsForRoundRobin({
|
||||
users,
|
||||
eventTypeId,
|
||||
@@ -85,48 +157,13 @@ export class BookingRepository {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}) {
|
||||
const whereClause: Prisma.BookingWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
user: {
|
||||
id: {
|
||||
in: users.map((user) => user.id),
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
noShowHost: false,
|
||||
},
|
||||
{
|
||||
noShowHost: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: {
|
||||
in: users.map((user) => user.email),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
attendees: { some: { noShow: false } },
|
||||
status: BookingStatus.ACCEPTED,
|
||||
eventTypeId,
|
||||
...(startDate && endDate
|
||||
? {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const allBookings = await prisma.booking.findMany({
|
||||
where: whereClause,
|
||||
where: buildWhereClauseForActiveBookings({
|
||||
eventTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
attendees: true,
|
||||
|
||||
@@ -610,6 +610,7 @@ export class EventTypeRepository {
|
||||
},
|
||||
},
|
||||
secondaryEmailId: true,
|
||||
maxLeadThreshold: true,
|
||||
});
|
||||
|
||||
return await prisma.eventType.findFirst({
|
||||
|
||||
@@ -119,6 +119,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
seatsPerTimeSlot: null,
|
||||
seatsShowAttendees: null,
|
||||
seatsShowAvailabilityCount: null,
|
||||
maxLeadThreshold: null,
|
||||
schedulingType: null,
|
||||
scheduleId: null,
|
||||
bookingLimits: null,
|
||||
|
||||
@@ -124,6 +124,7 @@ export const useEventTypeForm = ({
|
||||
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
|
||||
},
|
||||
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
|
||||
maxLeadThreshold: eventType.maxLeadThreshold,
|
||||
};
|
||||
}, [eventType, periodDates]);
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "maxLeadThreshold" INTEGER;
|
||||
@@ -149,6 +149,7 @@ model EventType {
|
||||
useEventTypeDestinationCalendarEmail Boolean @default(false)
|
||||
aiPhoneCallConfig AIPhoneCallConfiguration?
|
||||
isRRWeightsEnabled Boolean @default(false)
|
||||
maxLeadThreshold Int?
|
||||
|
||||
/// @zod.custom(imports.eventTypeColor)
|
||||
eventTypeColor Json?
|
||||
|
||||
@@ -696,6 +696,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
||||
isRRWeightsEnabled: true,
|
||||
eventTypeColor: true,
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
maxLeadThreshold: true,
|
||||
};
|
||||
|
||||
// All properties that are defined as unlocked based on all managed props
|
||||
|
||||
@@ -13,6 +13,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
||||
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
||||
import { findQualifiedHosts } from "@calcom/lib/bookings/findQualifiedHosts";
|
||||
import { getRoutedHostsWithContactOwnerAndFixedHosts } from "@calcom/lib/bookings/getRoutedUsers";
|
||||
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
|
||||
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
|
||||
@@ -180,6 +181,7 @@ export async function getEventType(
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
periodDays: true,
|
||||
metadata: true,
|
||||
maxLeadThreshold: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -223,6 +225,7 @@ export async function getEventType(
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
credentials: { select: credentialForCalendarServiceSelect },
|
||||
@@ -390,12 +393,15 @@ export function getUsersWithCredentialsConsideringContactOwner({
|
||||
return allHosts;
|
||||
}
|
||||
|
||||
const contactOwnerAndFixedHosts = hosts.reduce((usersArray, host) => {
|
||||
if (host.isFixed || host.user.email === contactOwnerEmail)
|
||||
usersArray.push({ ...host.user, isFixed: host.isFixed });
|
||||
const contactOwnerAndFixedHosts = hosts.reduce(
|
||||
(usersArray: (GetAvailabilityUser & { isFixed?: boolean })[], host) => {
|
||||
if (host.isFixed || host.user.email === contactOwnerEmail)
|
||||
usersArray.push({ ...host.user, isFixed: host.isFixed });
|
||||
|
||||
return usersArray;
|
||||
}, [] as (GetAvailabilityUser & { isFixed?: boolean })[]);
|
||||
return usersArray;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return contactOwnerAndFixedHosts;
|
||||
}
|
||||
@@ -467,24 +473,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
const eventHosts: {
|
||||
isFixed: boolean;
|
||||
email: string;
|
||||
user: (typeof eventType.hosts)[number]["user"];
|
||||
}[] =
|
||||
eventType.hosts?.length && eventType.schedulingType
|
||||
? eventType.hosts.map((host) => ({
|
||||
isFixed: host.isFixed,
|
||||
email: host.user.email,
|
||||
user: host.user,
|
||||
}))
|
||||
: eventType.users.map((user) => {
|
||||
return {
|
||||
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,
|
||||
email: user.email,
|
||||
user: user,
|
||||
};
|
||||
});
|
||||
const eventHosts = await monitorCallbackAsync(findQualifiedHosts<GetAvailabilityUser>, eventType);
|
||||
|
||||
const contactOwnerEmailFromInput = input.teamMemberEmail ?? null;
|
||||
const skipContactOwner = input.skipContactOwner;
|
||||
|
||||
Reference in New Issue
Block a user