feat: attribute weights and virtual queues (#17567)
* 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>
This commit is contained in:
co-authored by
CarinaWolli
Alex van Andel
parent
322ca2a12c
commit
5012a16794
@@ -624,7 +624,7 @@ export default function Signup({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12 dark:bg-none">
|
||||
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 dark:bg-none lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12">
|
||||
{IS_CALCOM && (
|
||||
<>
|
||||
<div className="-mt-4 mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
|
||||
@@ -675,7 +675,7 @@ export default function Signup({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] lg:block lg:py-[6px] lg:pl-[6px] dark:bg-white/5">
|
||||
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
|
||||
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
|
||||
<img
|
||||
className="hidden dark:block"
|
||||
@@ -686,7 +686,7 @@ export default function Signup({
|
||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||
{FEATURES.map((feature) => (
|
||||
<>
|
||||
<div className="mb-8 flex max-w-52 flex-col leading-none sm:mb-0">
|
||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
|
||||
@@ -100,6 +100,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
data: {
|
||||
formId,
|
||||
response: response,
|
||||
chosenRouteId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -64,70 +64,70 @@ export enum RouteActionType {
|
||||
}
|
||||
|
||||
export const routeActionTypeSchema = z.nativeEnum(RouteActionType);
|
||||
|
||||
export const children1Schema = z.object({
|
||||
children1: z
|
||||
.record(
|
||||
z.object({
|
||||
type: z.string().optional(),
|
||||
properties: z
|
||||
.object({
|
||||
field: z.any().optional(),
|
||||
operator: z.any().optional(),
|
||||
value: z.any().optional(),
|
||||
valueSrc: z.any().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
// Be very careful and lenient here. Just ensure that the rule isn't invalid without breaking anything
|
||||
.superRefine((children1, ctx) => {
|
||||
if (!children1) return;
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
Object.entries(children1).forEach(([, _rule]) => {
|
||||
const rule = _rule as unknown;
|
||||
if (!isObject(rule) || rule.type !== "rule") return;
|
||||
if (!isObject(rule.properties)) return;
|
||||
|
||||
const value = rule.properties.value || [];
|
||||
const valueSrc = rule.properties.valueSrc;
|
||||
if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!valueSrc.length) {
|
||||
// If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
|
||||
return;
|
||||
}
|
||||
|
||||
// MultiSelect array can be 2D array
|
||||
const flattenedValues = value.flat();
|
||||
|
||||
const validValues = flattenedValues.filter((value: unknown) => {
|
||||
// Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field
|
||||
// Also, it is possible that RAQB has some requirements to support null or empty string values.
|
||||
if (value === undefined) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!validValues.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Looks like you are trying to create a rule with no value",
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Stricter schema for validating before saving to DB
|
||||
* It doesn't decide what will be saved, it is just to validate the data before saving
|
||||
*/
|
||||
export const queryValueSaveValidationSchema = queryValueSchema
|
||||
.omit({ children1: true })
|
||||
.merge(
|
||||
z.object({
|
||||
children1: z
|
||||
.record(
|
||||
z.object({
|
||||
type: z.string().optional(),
|
||||
properties: z
|
||||
.object({
|
||||
field: z.any().optional(),
|
||||
operator: z.any().optional(),
|
||||
value: z.any().optional(),
|
||||
valueSrc: z.any().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
// Be very careful and lenient here. Just ensure that the rule isn't invalid without breaking anything
|
||||
.superRefine((children1, ctx) => {
|
||||
if (!children1) return;
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
Object.entries(children1).forEach(([, _rule]) => {
|
||||
const rule = _rule as unknown;
|
||||
if (!isObject(rule) || rule.type !== "rule") return;
|
||||
if (!isObject(rule.properties)) return;
|
||||
|
||||
const value = rule.properties.value || [];
|
||||
const valueSrc = rule.properties.valueSrc;
|
||||
if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!valueSrc.length) {
|
||||
// If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
|
||||
return;
|
||||
}
|
||||
|
||||
// MultiSelect array can be 2D array
|
||||
const flattenedValues = value.flat();
|
||||
|
||||
const validValues = flattenedValues.filter((value: unknown) => {
|
||||
// Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field
|
||||
// Also, it is possible that RAQB has some requirements to support null or empty string values.
|
||||
if (value === undefined) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!validValues.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Looks like you are trying to create a rule with no value",
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
})
|
||||
)
|
||||
.merge(children1Schema)
|
||||
.nullish();
|
||||
|
||||
export const attributeRoutingConfigSchema = z
|
||||
|
||||
@@ -467,13 +467,38 @@ async function handler(
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
eventType.rescheduleWithSameRoundRobinHost;
|
||||
|
||||
const userIdsSet = new Set(users.map((user) => user.id));
|
||||
|
||||
let routingFormResponse;
|
||||
|
||||
if (routedTeamMemberIds) {
|
||||
routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
where: {
|
||||
id: routingFormResponseId,
|
||||
},
|
||||
select: {
|
||||
response: true,
|
||||
form: {
|
||||
select: {
|
||||
routes: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
chosenRouteId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newLuckyUser = isSameRoundRobinHost
|
||||
? freeUsers.find((user) => user.id === originalRescheduledBookingUserId)
|
||||
: await getLuckyUser({
|
||||
// find a lucky user that is not already in the luckyUsers array
|
||||
availableUsers: freeUsers,
|
||||
allRRHosts: eventTypeWithUsers.hosts.filter((host) => !host.isFixed),
|
||||
allRRHosts: eventTypeWithUsers.hosts.filter(
|
||||
(host) => !host.isFixed && userIdsSet.has(host.user.id)
|
||||
), // users part of virtual queue
|
||||
eventType,
|
||||
routingFormResponse,
|
||||
});
|
||||
if (!newLuckyUser) {
|
||||
break; // prevent infinite loop
|
||||
|
||||
@@ -127,11 +127,8 @@ export const roundRobinReassignment = async ({
|
||||
|
||||
const reassignedRRHost = await getLuckyUser({
|
||||
availableUsers,
|
||||
eventType: {
|
||||
id: eventType.id,
|
||||
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
|
||||
},
|
||||
allRRHosts: eventType.hosts.filter((host) => !host.isFixed),
|
||||
eventType,
|
||||
allRRHosts: eventType.hosts.filter((host) => !host.isFixed), // todo: only use hosts from virtual queue
|
||||
});
|
||||
|
||||
const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id;
|
||||
|
||||
@@ -12,9 +12,8 @@ import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
|
||||
|
||||
import CreateEventTypeForm from "./CreateEventTypeForm";
|
||||
|
||||
import usePostHog from "../../ee/event-tracking/lib/posthog/userPostHog";
|
||||
import CreateEventTypeForm from "./CreateEventTypeForm";
|
||||
|
||||
// this describes the uniform data needed to create a new event type on Profile or Team
|
||||
export interface EventTypeParent {
|
||||
|
||||
@@ -23,7 +23,7 @@ const ClearFilters = () => {
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="h-[38px] min-w-24 border-0"
|
||||
className="min-w-24 h-[38px] border-0"
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
}}>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { expect, it, describe } from "vitest";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { expect, it, describe, vi } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { buildUser, buildBooking } from "@calcom/lib/test/builder";
|
||||
import { AttributeType } from "@calcom/prisma/enums";
|
||||
|
||||
import { getLuckyUser } from "./getLuckyUser";
|
||||
import { getLuckyUser, prepareQueuesAndAttributesData } from "./getLuckyUser";
|
||||
|
||||
type NonEmptyArray<T> = [T, ...T[]];
|
||||
type GetLuckyUserAvailableUsersType = NonEmptyArray<ReturnType<typeof buildUser>>;
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets", () => ({
|
||||
default: {},
|
||||
}));
|
||||
vi.mock("@calcom/ui", () => ({}));
|
||||
|
||||
it("can find lucky user with maximize availability", async () => {
|
||||
const users: GetLuckyUserAvailableUsersType = [
|
||||
buildUser({
|
||||
@@ -49,6 +56,7 @@ it("can find lucky user with maximize availability", async () => {
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: false,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts: [],
|
||||
})
|
||||
@@ -96,6 +104,7 @@ it("can find lucky user with maximize availability and priority ranking", async
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: false,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts: [],
|
||||
})
|
||||
@@ -151,6 +160,7 @@ it("can find lucky user with maximize availability and priority ranking", async
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: false,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts: [],
|
||||
})
|
||||
@@ -211,6 +221,7 @@ it("can find lucky user with maximize availability and priority ranking", async
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: false,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts: [],
|
||||
})
|
||||
@@ -297,6 +308,7 @@ describe("maximize availability and weights", () => {
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: true,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts,
|
||||
})
|
||||
@@ -391,6 +403,7 @@ describe("maximize availability and weights", () => {
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: true,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts,
|
||||
})
|
||||
@@ -485,6 +498,7 @@ describe("maximize availability and weights", () => {
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: true,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts,
|
||||
})
|
||||
@@ -565,6 +579,7 @@ describe("maximize availability and weights", () => {
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: true,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts,
|
||||
})
|
||||
@@ -588,9 +603,375 @@ describe("maximize availability and weights", () => {
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: true,
|
||||
team: {},
|
||||
},
|
||||
allRRHosts,
|
||||
})
|
||||
).resolves.toStrictEqual(users[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attribute weights and virtual queues", () => {
|
||||
it("prepareQueuesAndAttributesData returns correct attributeWeights and virtualQueuesData", async () => {
|
||||
const attributeOneOptionIdFirst = uuid();
|
||||
const attributeOneOptionIdSecond = uuid();
|
||||
const attributeTwoOptionIdFirst = uuid();
|
||||
const attributeTwoOptionIdSecond = uuid();
|
||||
const attributeId = uuid();
|
||||
const routeId = uuid();
|
||||
const fieldIdOne = uuid();
|
||||
const fieldIdTwo = uuid();
|
||||
|
||||
const routingFormResponse = {
|
||||
response: {
|
||||
[fieldIdOne]: {
|
||||
label: "company_size",
|
||||
value: attributeOneOptionIdFirst,
|
||||
},
|
||||
[fieldIdTwo]: { label: "headquarters", value: attributeTwoOptionIdSecond },
|
||||
},
|
||||
form: {
|
||||
routes: [
|
||||
{
|
||||
id: uuid(),
|
||||
action: { type: "eventTypeRedirectUrl", value: "team/team1/team1-event-1", eventTypeId: 29 },
|
||||
queryValue: { id: "a98ab8a9-4567-489a-bcde-f1932649bb8b", type: "group" },
|
||||
attributesQueryValue: {
|
||||
id: "b8ab8ba9-0123-4456-b89a-b1932649bb8b",
|
||||
type: "group",
|
||||
children1: {
|
||||
"a8999bb9-89ab-4cde-b012-31932649cc93": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: uuid(), //another attribute
|
||||
value: [[`{field:${fieldIdOne}}`]],
|
||||
operator: "multiselect_some_in",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributeRoutingConfig: {},
|
||||
},
|
||||
{
|
||||
//chosen route
|
||||
id: routeId,
|
||||
action: { type: "eventTypeRedirectUrl", value: "team/team1/team1-event-1", eventTypeId: 29 },
|
||||
queryValue: { id: "a98ab8a9-4567-489a-bcde-f1932649bb8b", type: "group" },
|
||||
attributesQueryValue: {
|
||||
id: "b8ab8ba9-0123-4456-b89a-b1932649bb8b",
|
||||
type: "group",
|
||||
children1: {
|
||||
"a8999bb9-89ab-4cde-b012-31932649cc93": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: attributeId,
|
||||
value: [[`{field:${fieldIdTwo}}`]],
|
||||
operator: "multiselect_some_in",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributeRoutingConfig: {},
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: fieldIdOne,
|
||||
type: "select",
|
||||
label: "company_size",
|
||||
options: [
|
||||
{ id: attributeOneOptionIdFirst, label: "1-10" },
|
||||
{ id: attributeOneOptionIdSecond, label: "11-20" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: fieldIdTwo,
|
||||
type: "select",
|
||||
label: "headquarters",
|
||||
options: [
|
||||
{ id: attributeTwoOptionIdFirst, label: "USA" },
|
||||
{ id: attributeTwoOptionIdSecond, label: "Germany" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
chosenRouteId: routeId,
|
||||
};
|
||||
|
||||
prismaMock.attribute.findFirst.mockResolvedValue({
|
||||
name: "Headquaters",
|
||||
id: attributeId,
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
slug: "headquarters",
|
||||
options: [
|
||||
{
|
||||
id: "12345",
|
||||
value: "Germany",
|
||||
slug: "Germany",
|
||||
assignedUsers: [
|
||||
{
|
||||
weight: 120,
|
||||
member: {
|
||||
userId: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
weight: 150,
|
||||
member: {
|
||||
userId: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const queuesAndAttributesData = await prepareQueuesAndAttributesData({
|
||||
eventType: { id: 1, isRRWeightsEnabled: true, team: { parentId: 1 } },
|
||||
routingFormResponse,
|
||||
allRRHosts: [
|
||||
{
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test1@example.com",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 2,
|
||||
email: "test2@example.com",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
weight: 150,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(queuesAndAttributesData).toEqual({
|
||||
attributeWeights: [
|
||||
{ userId: 1, weight: 120 },
|
||||
{ userId: 2, weight: 150 },
|
||||
],
|
||||
virtualQueuesData: {
|
||||
chosenRouteId: routeId,
|
||||
fieldOptionData: {
|
||||
fieldId: fieldIdTwo,
|
||||
selectedOptionIds: attributeTwoOptionIdSecond,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses attribute weights and counts only bookings within virtual queue", async () => {
|
||||
const users: GetLuckyUserAvailableUsersType = [
|
||||
buildUser({
|
||||
id: 1,
|
||||
username: "test1",
|
||||
name: "Test User 1",
|
||||
email: "test1@example.com",
|
||||
priority: 1,
|
||||
weight: 150,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T06:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildUser({
|
||||
id: 2,
|
||||
username: "test2",
|
||||
name: "Test User 2",
|
||||
email: "test2@example.com",
|
||||
priority: 3,
|
||||
weight: 50,
|
||||
bookings: [
|
||||
{
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const attributeOptionIdFirst = uuid();
|
||||
const attributeOptionIdSecond = uuid();
|
||||
const attributeId = uuid();
|
||||
const routeId = uuid();
|
||||
const fieldId = uuid();
|
||||
const formId = uuid();
|
||||
|
||||
const routingFormResponse = {
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "company_size",
|
||||
value: attributeOptionIdFirst,
|
||||
},
|
||||
},
|
||||
form: {
|
||||
routes: [
|
||||
{
|
||||
id: routeId,
|
||||
action: { type: "eventTypeRedirectUrl", value: "team/team1/team1-event-1", eventTypeId: 29 },
|
||||
queryValue: { id: "a98ab8a9-4567-489a-bcde-f1932649bb8b", type: "group" },
|
||||
attributesQueryValue: {
|
||||
id: "b8ab8ba9-0123-4456-b89a-b1932649bb8b",
|
||||
type: "group",
|
||||
children1: {
|
||||
"a8999bb9-89ab-4cde-b012-31932649cc93": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: attributeId,
|
||||
value: [[`{field:${fieldId}}`]],
|
||||
operator: "multiselect_some_in",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributeRoutingConfig: {},
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: fieldId,
|
||||
type: "select",
|
||||
label: "company_size",
|
||||
options: [
|
||||
{ id: attributeOptionIdFirst, label: "1-10" },
|
||||
{ id: attributeOptionIdSecond, label: "11-20" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
chosenRouteId: routeId,
|
||||
};
|
||||
|
||||
prismaMock.user.findMany.mockResolvedValue(users);
|
||||
prismaMock.host.findMany.mockResolvedValue([]);
|
||||
prismaMock.booking.findMany.mockResolvedValue([
|
||||
{
|
||||
...buildBooking({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
createdAt: new Date("2022-01-25T06:30:00.000Z"),
|
||||
}),
|
||||
routedFromRoutingFormReponse: {
|
||||
id: 1,
|
||||
formId,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "company_size",
|
||||
value: attributeOptionIdFirst, // booking part of virtual queue
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2022-01-25T06:30:00.000Z"),
|
||||
chosenRouteId: routeId,
|
||||
},
|
||||
},
|
||||
{
|
||||
...buildBooking({
|
||||
id: 3,
|
||||
userId: 2,
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
}),
|
||||
routedFromRoutingFormReponse: {
|
||||
id: 1,
|
||||
formId,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "company_size",
|
||||
value: attributeOptionIdFirst, // booking part of virtual queue
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
chosenRouteId: routeId,
|
||||
},
|
||||
},
|
||||
{
|
||||
...buildBooking({
|
||||
id: 3,
|
||||
userId: 2,
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
}),
|
||||
routedFromRoutingFormReponse: {
|
||||
id: 1,
|
||||
formId,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "company_size",
|
||||
value: attributeOptionIdSecond, // different queue, booking doesn't count
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2022-01-25T05:30:00.000Z"),
|
||||
chosenRouteId: routeId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.attribute.findFirst.mockResolvedValue({
|
||||
name: "Company Size",
|
||||
id: attributeId,
|
||||
type: AttributeType.SINGLE_SELECT,
|
||||
slug: "company_size",
|
||||
options: [
|
||||
{
|
||||
id: "4321",
|
||||
value: "1-10",
|
||||
slug: "1-10",
|
||||
assignedUsers: [
|
||||
{
|
||||
weight: 80,
|
||||
member: {
|
||||
userId: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
weight: 100,
|
||||
member: {
|
||||
userId: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const allRRHosts = [
|
||||
{
|
||||
user: { id: users[0].id, email: users[0].email },
|
||||
weight: users[0].weight,
|
||||
createdAt: new Date(0),
|
||||
},
|
||||
{
|
||||
user: { id: users[1].id, email: users[1].email },
|
||||
weight: users[1].weight,
|
||||
createdAt: new Date(0),
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
getLuckyUser({
|
||||
availableUsers: users,
|
||||
eventType: {
|
||||
id: 1,
|
||||
isRRWeightsEnabled: true,
|
||||
team: { parentId: 1 },
|
||||
},
|
||||
allRRHosts,
|
||||
routingFormResponse,
|
||||
})
|
||||
).resolves.toStrictEqual(users[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,62 @@
|
||||
import type { User } from "@prisma/client";
|
||||
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 };
|
||||
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));
|
||||
@@ -113,7 +151,8 @@ async function leastRecentlyBookedUser<T extends PartialUser>({
|
||||
|
||||
async function getHostsWithCalibration(
|
||||
eventTypeId: number,
|
||||
hosts: { userId: number; email: string; createdAt: Date }[]
|
||||
hosts: { userId: number; email: string; createdAt: Date }[],
|
||||
virtualQueuesData?: VirtualQueuesDataType
|
||||
) {
|
||||
const [newHostsArray, existingBookings] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
@@ -136,6 +175,7 @@ async function getHostsWithCalibration(
|
||||
})),
|
||||
startDate: startOfMonth,
|
||||
endDate: new Date(),
|
||||
virtualQueuesData,
|
||||
}),
|
||||
]);
|
||||
// Return early if there are no new hosts or no existing bookings
|
||||
@@ -192,7 +232,16 @@ async function filterUsersBasedOnWeights<
|
||||
bookingsOfAvailableUsers,
|
||||
allRRHosts,
|
||||
eventType,
|
||||
}: GetLuckyUserParams<T> & { bookingsOfAvailableUsers: PartialBooking[] }): Promise<[T, ...T[]]> {
|
||||
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));
|
||||
|
||||
@@ -215,11 +264,13 @@ async function filterUsersBasedOnWeights<
|
||||
[]
|
||||
);
|
||||
|
||||
//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);
|
||||
@@ -228,14 +279,24 @@ async function filterUsersBasedOnWeights<
|
||||
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
|
||||
const totalWeight = allRRHosts.reduce((totalWeight, host) => {
|
||||
totalWeight += host.weight ?? 100;
|
||||
return totalWeight;
|
||||
}, 0);
|
||||
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;
|
||||
@@ -244,7 +305,13 @@ async function filterUsersBasedOnWeights<
|
||||
|
||||
// Calculate booking shortfall for each available user
|
||||
const usersWithBookingShortfalls = availableUsers.map((user) => {
|
||||
const targetPercentage = (user.weight ?? 100) / totalWeight;
|
||||
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) =>
|
||||
@@ -252,7 +319,6 @@ async function filterUsersBasedOnWeights<
|
||||
);
|
||||
|
||||
const targetNumberOfBookings = (allBookings.length + totalCalibration) * targetPercentage;
|
||||
// I need to get the user's current calibration here
|
||||
const userCalibration = allHostsWithCalibration.find((host) => host.userId === user.id)?.calibration ?? 0;
|
||||
|
||||
const bookingShortfall = targetNumberOfBookings - (userBookings.length + userCalibration);
|
||||
@@ -290,7 +356,280 @@ export async function getLuckyUser<
|
||||
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];
|
||||
@@ -302,12 +641,15 @@ export async function getLuckyUser<
|
||||
}),
|
||||
startDate: startOfMonth,
|
||||
endDate: new Date(),
|
||||
virtualQueuesData,
|
||||
});
|
||||
if (eventType.isRRWeightsEnabled) {
|
||||
availableUsers = await filterUsersBasedOnWeights({
|
||||
...getLuckyUserParams,
|
||||
availableUsers,
|
||||
bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers,
|
||||
virtualQueuesData,
|
||||
attributeWeights,
|
||||
});
|
||||
}
|
||||
const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { Booking } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -27,11 +28,19 @@ const buildWhereClauseForActiveBookings = ({
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
virtualQueuesData,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
users: { id: number; email: string }[];
|
||||
virtualQueuesData?: {
|
||||
chosenRouteId: string;
|
||||
fieldOptionData: {
|
||||
fieldId: string;
|
||||
selectedOptionIds: string | number | string[];
|
||||
};
|
||||
};
|
||||
}): Prisma.BookingWhereInput => ({
|
||||
OR: [
|
||||
{
|
||||
@@ -70,6 +79,13 @@ const buildWhereClauseForActiveBookings = ({
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(virtualQueuesData
|
||||
? {
|
||||
routedFromRoutingFormReponse: {
|
||||
chosenRouteId: virtualQueuesData.chosenRouteId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
export class BookingRepository {
|
||||
@@ -151,11 +167,19 @@ export class BookingRepository {
|
||||
eventTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
virtualQueuesData,
|
||||
}: {
|
||||
users: { id: number; email: string }[];
|
||||
eventTypeId: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
virtualQueuesData?: {
|
||||
chosenRouteId: string;
|
||||
fieldOptionData: {
|
||||
fieldId: string;
|
||||
selectedOptionIds: string | number | string[];
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const allBookings = await prisma.booking.findMany({
|
||||
where: buildWhereClauseForActiveBookings({
|
||||
@@ -163,6 +187,7 @@ export class BookingRepository {
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
virtualQueuesData,
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
@@ -171,13 +196,37 @@ export class BookingRepository {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
startTime: true,
|
||||
routedFromRoutingFormReponse: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return allBookings;
|
||||
let queueBookings = allBookings;
|
||||
|
||||
if (virtualQueuesData) {
|
||||
queueBookings = allBookings.filter((booking) => {
|
||||
const responses = booking.routedFromRoutingFormReponse;
|
||||
const fieldId = virtualQueuesData.fieldOptionData.fieldId;
|
||||
const selectedOptionIds = virtualQueuesData.fieldOptionData.selectedOptionIds;
|
||||
|
||||
const response = responses?.response as FormResponse;
|
||||
|
||||
const responseValue = response[fieldId].value;
|
||||
|
||||
if (Array.isArray(responseValue) && Array.isArray(selectedOptionIds)) {
|
||||
//check if all values are the same (this only support 'all in' not 'any in')
|
||||
return (
|
||||
responseValue.length === selectedOptionIds.length &&
|
||||
responseValue.every((value, index) => value === selectedOptionIds[index])
|
||||
);
|
||||
} else {
|
||||
return responseValue === selectedOptionIds;
|
||||
}
|
||||
});
|
||||
}
|
||||
return queueBookings;
|
||||
}
|
||||
|
||||
static async findBookingByUid({ bookingUid }: { bookingUid: string }) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "App_RoutingForms_FormResponse" ADD COLUMN "chosenRouteId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attribute" ADD COLUMN "isWeightsEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AttributeToUser" ADD COLUMN "weight" INTEGER;
|
||||
@@ -965,6 +965,7 @@ model App_RoutingForms_FormResponse {
|
||||
routedToBookingUid String? @unique
|
||||
// We should not cascade delete the booking, because we want to keep the form response even if the routedToBooking is deleted
|
||||
routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid])
|
||||
chosenRouteId String?
|
||||
|
||||
@@unique([formFillerId, formId])
|
||||
@@index([formFillerId])
|
||||
@@ -1570,9 +1571,10 @@ model Attribute {
|
||||
|
||||
usersCanEditRelation Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
options AttributeOption[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
options AttributeOption[]
|
||||
isWeightsEnabled Boolean @default(false)
|
||||
}
|
||||
|
||||
model AttributeToUser {
|
||||
@@ -1583,6 +1585,7 @@ model AttributeToUser {
|
||||
|
||||
attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade)
|
||||
attributeOptionId String
|
||||
weight Int?
|
||||
|
||||
@@unique([memberId, attributeOptionId])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user