diff --git a/apps/web/modules/signup-view.tsx b/apps/web/modules/signup-view.tsx index 29404cc1fb..d37d4677ec 100644 --- a/apps/web/modules/signup-view.tsx +++ b/apps/web/modules/signup-view.tsx @@ -624,7 +624,7 @@ export default function Signup({ -
+
{IS_CALCOM && ( <>
@@ -675,7 +675,7 @@ export default function Signup({
)} -
+
Cal.com Booking Page {FEATURES.map((feature) => ( <> -
+
{t(feature.title)} diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index d7182ff416..42a3a99a6c 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -100,6 +100,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => data: { formId, response: response, + chosenRouteId, }, }); diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index a585ce4b63..bfdb1a7479 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -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 => + 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 => - 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 diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 4c8fb1cbbc..6f798ce29f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 405952e8c5..cc610e9b26 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -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; diff --git a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 5f49ff80ca..46e78d4ff8 100644 --- a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -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 { diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index ff35e54804..0cb29db884 100644 --- a/packages/features/insights/filters/index.tsx +++ b/packages/features/insights/filters/index.tsx @@ -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(); }}> diff --git a/packages/lib/server/getLuckyUser.test.ts b/packages/lib/server/getLuckyUser.test.ts index 73370755bb..9b89de9154 100644 --- a/packages/lib/server/getLuckyUser.test.ts +++ b/packages/lib/server/getLuckyUser.test.ts @@ -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[]]; type GetLuckyUserAvailableUsersType = NonEmptyArray>; +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]); + }); +}); diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index b6af30bd58..20ba1c1677 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -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 & { attendees: { email: string | null }[]; }; type PartialUser = Pick; +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 { 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({ 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 & { bookingsOfAvailableUsers: PartialBooking[] }): Promise<[T, ...T[]]> { + virtualQueuesData, + attributeWeights, +}: GetLuckyUserParams & { + 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) { + const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams); + return _getLuckyUser( + { + ...getLuckyUserParams, + availableUsers, + }, + attributeWeights, + virtualQueuesData + ); +} + +export async function prepareQueuesAndAttributesData({ + eventType, + routingFormResponse, + allRRHosts, +}: Omit, "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( + allRRHosts: GetLuckyUserParams["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["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(); + + 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>, + attributesQueryValueChild: Record< + string, + { + type?: string | undefined; + properties?: + | { + field?: any; + operator?: any; + value?: any; + valueSrc?: any; + } + | undefined; + } + >, + attributeWithWeights: { id: string } +) { + let selectionOptions: Pick["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, + 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 }); diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index fcce0ffdf2..08d638d22f 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -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 }) { diff --git a/packages/prisma/migrations/20241108222820_add_weights_to_attribute/migration.sql b/packages/prisma/migrations/20241108222820_add_weights_to_attribute/migration.sql new file mode 100644 index 0000000000..3e76a1651b --- /dev/null +++ b/packages/prisma/migrations/20241108222820_add_weights_to_attribute/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1745f9416f..728c5e834d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) }