Files
calendar/packages/lib/server/getLuckyUser.ts
T
c28eb90928 chore: Upgrade prisma to 6.7.0 (#21264)
* chore: Upgrade prisma to 6.7.0

* Build fixes

* type fixes

Signed-off-by: Omar López <zomars@me.com>

* Update schema.prisma

* Patching

* Revert "Update schema.prisma"

This reverts commit 47d8618bf89ef4d007b30084df766f17281e21a1.

* Revert "Patching"

This reverts commit a1d2e3040e71690a44d4324db95d73b4d68c6adb.

* Revert schema changes

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* Update getPublicEvent.ts

* Update imports

Signed-off-by: Omar López <zomars@me.com>

* Update gitignore

Signed-off-by: Omar López <zomars@me.com>

* update remaining imports

Signed-off-by: Omar López <zomars@me.com>

* Delete .cursor/config.json

* Discard changes to packages/features/eventtypes/lib/getPublicEvent.ts

* Update _get.ts

* Update user.ts

* Update .gitignore

* update

* Update WorkflowStepContainer.tsx

* Update next-auth-custom-adapter.ts

* Update getPublicEvent.ts

* Update workflow.ts

* Update next-auth-custom-adapter.ts

* Update next-auth-options.ts

* Update bookingScenario.ts

* fix missing imports

* upgrades prismock

Signed-off-by: Omar López <zomars@me.com>

* patches prismock

Signed-off-by: Omar López <zomars@me.com>

* Update reschedule.test.ts

* Update prisma.ts

* patch prismock

Signed-off-by: Omar López <zomars@me.com>

* fix enums imports

Signed-off-by: Omar López <zomars@me.com>

* Revert "Update prisma.ts"

This reverts commit 64edcf8db54171ff4456c209d563b5d431d99619.

* Revert "patch prismock"

This reverts commit e95819113dc9d88e7130947aa120cd42710977c8.

* fix patch

* Fix test that overrun the boundary, it shouldn't test too much

* Move prisma import to changeSMSLockState

* Bring back broken test without illegal imports

* Merge with main and fix filter hosts by same round robin host

* Fixed buildDryRunBooking fn tests

* Fix and move ooo create or update handler test

* Fix packages/features/eventtypes/lib/isCurrentlyAvailable.test.ts

* Fix packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts

* Mock @calcom/prisma

* Fix: verify-email.test.ts

* fix: Moved WebhookService test and fixed default import mock

* Fix: Added missing prisma mock, handleNewBooking uses that of course

* We're not testing createContext here

* fix: Prisma mock fix for listMembers.test.ts

* More fixes to broken testcases

* Forgot to remove borked test

* Prevent the need to mock a lot of dependencies by moving out buildBaseWhereCondition to its own file

* Temporarily skip getCalendarEvents, needs a rewrite

* Fix: turns out you can access protected in testcases

* fix further mocks

* Added packages/features/insights/server/buildBaseWhereCondition.ts, types

* Always great to have a mock and then not use it

* And one less again.

* fix: confirm.handler.test, didn't mock prisma

* fix: Address minor nit by @eunjae & fix ImpersonationProvider test

* Updated isPrismaAvailableCheck that doesn't crash on import

* fix: Get Prisma directly from the client, it usually involves the Validator and does not need 'local' inclusion

* Add zod-prisma-types without the generator enabled (commented out)

* Uncomment and see what happens

* Change method of import as imports did not work in Input Schemas

* Remove custom 'zod' booking model, it does not belong with Prisma

* Fix all other global Model imports

* Rewrite most schema includes AND remove barrel file

* Add bookingCreateBodySchema to features/bookings

* Flurry of type fixes for compatibility with new zod gen

* Refactor out the custom prisma type createEventTypeInput

* Work around nullable eventTypeLocations

* HandlePayment type fix

* More fixes, final fix remaining is CompleteEventType

* Should fix a bunch more booking related type errors

* Missed one

* Some props missing from BookingCreateBodySchema

* Fix location type in handleChildrenEventTypes

* Little bit hacky imo but it works

* Final type error \o/

* Forgot to include Prisma

* Do not include zod-utils in booker/types

* Oops, was already including Booker/types

* Fix membership type, also disallow updating createdAt/updatedAt, make part of patch/post

* Fix api v1 type errors

* Fix EventTypeDescription typings

* Remove getParserWithGeneric, use userBodySchema with UserSchema

* use centralized timeZoneSchema

* Implement feedback by @zomars

* Couple of WIP pushes

* Fix tests

* Type fixes in `handleChildrenEventTypes` test

* Try and parse metadata before use

* Change zod-prisma-types configuration for optimal performance

* Fix prisma validator error in `prisma/selects/credential`

* Disable seperate relations model, hits a bug

* Import absolute - this makes rollup work in @platform/libraries

* Attempt at removing resolutions override

* Refactor using `Prisma.validator` to `satisfies`

* Build atoms using @calcom/prisma/client

* Build atoms using @calcom/prisma/client

* fixes

* Update eventTypeSelect.ts

* Adjust `eventTypeMetaDataSchemaWithUntypedApps` from `unknown` to `record(any)`

* `EventTypeDescription` rely on `descriptionAsSafeHTML` instead of `description`

* Add `seatsPerTimeSlot` to event type public select

* Fix typing in `users-public-view` getServerSide props

* Add missing `schedulingType` to prop

* chore: bump platform libraries

* Function return type is illegal, not sure how this passed eslint (#21567)

* Merged with main

* Update updateTokenObject.ts

* Update handleResponse.ts

* Update index.ts

* Update handleChildrenEventTypes.ts

* Update booking-idempotency-key.ts

* Update WebhookService.test.ts

* Update events.test.ts

* Update queued-response.test.ts

* Update events.test.ts

* Update getRoutedUrl.test.ts

* fix: type checks

Signed-off-by: Omar López <zomars@me.com>

* fixes

Signed-off-by: Omar López <zomars@me.com>

* chore: bump platform libraries

* Update yarn.lock

* more fixes

Signed-off-by: Omar López <zomars@me.com>

* fixes

Signed-off-by: Omar López <zomars@me.com>

* biuld fixes

* chore: bump platform libraries

* Update conferencing.repository.ts

* Update conferencing.repository.ts

* Update getCalendarsEvents.test.ts

* Update vite.config.js

* chore: bump platform libraries

* Update users.ts

* Discard changes to docs/api-reference/v2/openapi.json

* Update vite.config.ts

* updated platform libraries

* Update get.handler.test.ts

* Update get.handler.test.ts

* Update schema.prisma

* Discard changes to docs/api-reference/v2/openapi.json

* Update next-auth-custom-adapter.ts

* Update team.ts

* Flurry of type fixes

* Fix majority of insight related type errors

* Type fixes for unlink of account

* Make user nullable again

* Fixed a bunch of unit tests and one type error

* Attempted mock fix

* Attempted fix for Attribute type

* Ensure default import becomes prisma, but not direct usage

* Import default as prisma in prisma.module

* Add attributeOption to attribute type

* Fix calcom/prisma mock

* Refactor Prisma client imports to @calcom/prisma/client

Updated all imports from '@prisma/client' to '@calcom/prisma/client' across tests and repository files for consistency and to use the correct Prisma client package. This change improves maintainability and ensures the correct client is referenced throughout the codebase.

* Undo removal of max-warnings=0 to get main to merge

* Remove unit tests for e2e fixtures, provide new prisma mock

* Mock @calcom/prisma in event manager

* Mock @calcom/prisma in event manager

* Add correct format even with --no-verify

* Mock prisma in CalendarManager

* Add mock for permission-check.service

* Better injection in PrismaApiKeyRepository imports

* More mock fixes :)

* Fix listMembers.handler.test

* Fix User import

* Appropriately adjust all types to be imported as types, there were a lot of types imported as normal deps

* Why was this a thing?

* Strictly speaking; Not using prismock anymore

* Ditched patch file for prismock

* Fix output.service.ts platform type imports, need concrete for plainToClass

* Better typing and tests for unlinkConnectedAccount.handler

* Small type fix

* Disable calendar cache tests as they are dependent on prismock

* chore: bump platform lib

* getRoutedUrl test remove of unused import

* Extract select to external const on getEventTypesFromDB

* Direct select of userSelect from selects/user

* fix type error from merging 23653

* Fixed integration tests by removing hardcoded values that were possible due to mocking, but as its now directly hitting the db no longer

* fix: vite config atoms prisma client type location

* revert: example app prisma client

* revert: example app prisma client

* bump platform libs

* fix: use class instead of type for DI of PlatformBookingsService

* update platform libs

* remove unused variable

* chore: generate prisma client for api v2

* fix: api v2 e2e

* fix: atoms e2e

* fix: atoms e2e

* fix: atoms e2e

* fix: api v2 e2e

* fix: tsconfig apiv2 enums

* publish libraries

* Simplify check for existence teamId

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-09-11 15:27:50 +01:00

1134 lines
38 KiB
TypeScript

import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { getBusyCalendarTimes } from "@calcom/lib/CalendarManager";
import logger from "@calcom/lib/logger";
import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils";
import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { HostRepository } from "@calcom/lib/server/repository/host";
import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo";
import type { UserRepository } from "@calcom/lib/server/repository/user";
import type { Prisma } from "@calcom/prisma/client";
import type { User, Booking, SelectedCalendar } from "@calcom/prisma/client";
import type { AttributeType } from "@calcom/prisma/enums";
import { RRTimestampBasis, RRResetInterval } from "@calcom/prisma/enums";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { mergeOverlappingRanges } from "../date-ranges";
const log = logger.getSubLogger({ prefix: ["getLuckyUser"] });
const { getAttributesQueryValue } = acrossQueryValueCompatiblity;
type PartialBooking = Pick<Booking, "id" | "createdAt" | "userId" | "status"> & {
attendees: { email: string | null }[];
};
type PartialUser = Pick<User, "id" | "email">;
export type RoutingFormResponse = {
response: Prisma.JsonValue;
chosenRouteId: string | null;
form: {
fields: Prisma.JsonValue;
routes: Prisma.JsonValue;
};
};
type AttributeWithWeights = {
name: string;
slug: string;
type: AttributeType;
id: string;
options: {
id: string;
value: string;
slug: string;
assignedUsers: {
weight: number | null;
member: {
userId: number;
};
}[];
}[];
};
type VirtualQueuesDataType = {
chosenRouteId: string;
fieldOptionData: {
fieldId: string;
selectedOptionIds: string | number | string[];
};
};
interface GetLuckyUserParams<T extends PartialUser> {
availableUsers: [T, ...T[]]; // ensure contains at least 1
eventType: {
id: number;
isRRWeightsEnabled: boolean;
team: {
parentId?: number | null;
rrResetInterval: RRResetInterval | null;
rrTimestampBasis: RRTimestampBasis;
} | null;
includeNoShowInRRCalculation: boolean;
};
// all routedTeamMemberIds or all hosts of event types
allRRHosts: {
user: {
id: number;
email: string;
credentials: CredentialForCalendarService[];
userLevelSelectedCalendars: SelectedCalendar[];
};
createdAt: Date;
weight?: number | null;
}[];
routingFormResponse: RoutingFormResponse | null;
meetingStartTime?: Date;
}
// === Utility Functions kept outside of the class ===
const startOfMonth = (date: Date = new Date()) =>
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
const startOfDay = (date: Date = new Date()) =>
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const endOfDay = (date: Date) =>
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999));
const endOfMonth = (date: Date) =>
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0, 23, 59, 59, 999));
export const getIntervalEndDate = ({
interval,
rrTimestampBasis,
meetingStartTime,
}: {
interval: RRResetInterval;
rrTimestampBasis: RRTimestampBasis;
meetingStartTime?: Date;
}) => {
if (rrTimestampBasis === RRTimestampBasis.START_TIME) {
if (!meetingStartTime) {
throw new Error("Meeting start time is required");
}
if (interval === RRResetInterval.DAY) {
return endOfDay(meetingStartTime);
}
return endOfMonth(meetingStartTime);
}
return new Date();
};
export const getIntervalStartDate = ({
interval,
rrTimestampBasis,
meetingStartTime,
}: {
interval: RRResetInterval;
rrTimestampBasis: RRTimestampBasis;
meetingStartTime?: Date;
}) => {
if (rrTimestampBasis === RRTimestampBasis.START_TIME) {
if (!meetingStartTime) {
throw new Error("Meeting start time is required");
}
if (interval === RRResetInterval.DAY) {
return startOfDay(meetingStartTime);
}
return startOfMonth(meetingStartTime);
}
if (interval === RRResetInterval.DAY) {
return startOfDay();
}
return startOfMonth();
};
const isNonEmptyArray = <T>(arr: T[]): arr is [T, ...T[]] => arr.length > 0;
function isFullDayEvent(date1: Date, date2: Date) {
const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000;
const difference = Math.abs(date1.getTime() - date2.getTime());
if (difference % MILLISECONDS_IN_A_DAY === 0) return true;
}
type OOODataType = { userId: number; oooEntries: { start: Date; end: Date }[] }[];
type FetchedData = {
bookingsOfNotAvailableUsersOfInterval: PartialBooking[];
bookingsOfAvailableUsersOfInterval: PartialBooking[];
allRRHostsBookingsOfInterval: PartialBooking[];
allRRHostsCreatedInInterval: { userId: number; createdAt: Date }[];
organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[];
attributeWeights?:
| {
userId: number;
weight: number;
}[]
| null;
virtualQueuesData?: VirtualQueuesDataType | null;
oooData: OOODataType;
};
type AvailableUserBase = PartialUser & {
priority: number | null;
weight: number | null;
};
export interface ILuckyUserService {
bookingRepository: BookingRepository;
hostRepository: HostRepository;
oooRepository: PrismaOOORepository;
userRepository: UserRepository;
attributeRepository: PrismaAttributeRepository;
}
export class LuckyUserService implements ILuckyUserService {
public bookingRepository: BookingRepository;
public hostRepository: HostRepository;
public oooRepository: PrismaOOORepository;
public userRepository: UserRepository;
public attributeRepository: PrismaAttributeRepository;
constructor(deps: ILuckyUserService) {
this.bookingRepository = deps.bookingRepository;
this.hostRepository = deps.hostRepository;
this.oooRepository = deps.oooRepository;
this.userRepository = deps.userRepository;
this.attributeRepository = deps.attributeRepository;
}
private leastRecentlyBookedUser<T extends PartialUser>({
availableUsers,
bookingsOfAvailableUsers,
organizersWithLastCreated,
}: GetLuckyUserParams<T> & {
bookingsOfAvailableUsers: PartialBooking[];
organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[];
}) {
const organizerIdAndAtCreatedPair = organizersWithLastCreated.reduce(
(keyValuePair: { [userId: number]: Date }, user) => {
keyValuePair[user.id] = user.bookings[0]?.createdAt || new Date(0);
return keyValuePair;
},
{}
);
const attendeeUserIdAndAtCreatedPair = bookingsOfAvailableUsers.reduce(
(aggregate: { [userId: number]: Date }, booking) => {
availableUsers.forEach((user) => {
if (aggregate[user.id]) return;
if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return;
if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return;
aggregate[user.id] = booking.createdAt;
});
return aggregate;
},
{}
);
const userIdAndAtCreatedPair = {
...organizerIdAndAtCreatedPair,
...attendeeUserIdAndAtCreatedPair,
};
log.info(
"userIdAndAtCreatedPair",
safeStringify({
organizerIdAndAtCreatedPair,
attendeeUserIdAndAtCreatedPair,
userIdAndAtCreatedPair,
})
);
if (!userIdAndAtCreatedPair) {
throw new Error("Unable to find users by availableUser ids.");
}
const leastRecentlyBookedUser = availableUsers.sort((a, b) => {
if (userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id]) return 1;
else if (userIdAndAtCreatedPair[a.id] < userIdAndAtCreatedPair[b.id]) return -1;
else return 0;
})[0];
return leastRecentlyBookedUser;
}
private getHostsWithCalibration({
hosts,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
oooData,
}: {
hosts: { userId: number; email: string; createdAt: Date }[];
allRRHostsBookingsOfInterval: PartialBooking[];
allRRHostsCreatedInInterval: { userId: number; createdAt: Date }[];
oooData: OOODataType;
}) {
function calculateNewHostCalibration(newHost: { userId: number; createdAt: Date }) {
const existingBookingsBeforeAdded = existingBookings.filter(
(booking) => booking.userId !== newHost.userId && booking.createdAt < newHost.createdAt
);
const hostsAddedBefore = hosts.filter(
(host) => host.userId !== newHost.userId && host.createdAt < newHost.createdAt
);
const calibration =
existingBookingsBeforeAdded.length && hostsAddedBefore.length
? existingBookingsBeforeAdded.length / hostsAddedBefore.length
: 0;
log.debug(
"calculateNewHostCalibration",
safeStringify({
newHost,
existingBookingsBeforeAdded: existingBookingsBeforeAdded.length,
hostsAddedBefore: hostsAddedBefore.length,
calibration,
})
);
return calibration;
}
const existingBookings = allRRHostsBookingsOfInterval;
const oooCalibration = new Map<number, number>();
oooData.forEach(({ userId, oooEntries }) => {
let calibration = 0;
oooEntries.forEach((oooEntry) => {
const bookingsInTimeframe = existingBookings.filter(
(booking) =>
booking.createdAt >= oooEntry.start &&
booking.createdAt <= oooEntry.end &&
booking.userId !== userId
);
calibration += bookingsInTimeframe.length / (hosts.length - 1);
});
oooCalibration.set(userId, calibration);
});
let newHostsWithCalibration: Map<
number,
{
calibration: number;
userId: number;
createdAt: Date;
}
> = new Map();
if (allRRHostsCreatedInInterval.length && existingBookings.length) {
newHostsWithCalibration = new Map(
allRRHostsCreatedInInterval.map((newHost) => [
newHost.userId,
{ ...newHost, calibration: calculateNewHostCalibration(newHost) },
])
);
}
return hosts.map((host) => ({
...host,
calibration:
(newHostsWithCalibration.get(host.userId)?.calibration ?? 0) + (oooCalibration.get(host.userId) ?? 0),
}));
}
private getUsersWithHighestPriority<T extends PartialUser & { priority?: number | null }>({
availableUsers,
}: {
availableUsers: T[];
}) {
const highestPriority = Math.max(...availableUsers.map((user) => user.priority ?? 2));
const usersWithHighestPriority = availableUsers.filter(
(user) => user.priority === highestPriority || (user.priority == null && highestPriority === 2)
);
if (!isNonEmptyArray(usersWithHighestPriority)) {
throw new Error("Internal Error: Highest Priority filter should never return length=0.");
}
log.info(
"getUsersWithHighestPriority",
safeStringify({
highestPriorityUsers: usersWithHighestPriority.map((user) => user.id),
})
);
return usersWithHighestPriority;
}
private filterUsersBasedOnWeights<
T extends PartialUser & {
weight?: number | null;
}
>({
availableUsers,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHosts,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
attributeWeights,
oooData,
}: GetLuckyUserParams<T> & FetchedData) {
const allBookings = bookingsOfAvailableUsersOfInterval.concat(bookingsOfNotAvailableUsersOfInterval);
const allHostsWithCalibration = this.getHostsWithCalibration({
hosts: allRRHosts.map((host) => {
return { email: host.user.email, userId: host.user.id, createdAt: host.createdAt };
}),
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
oooData,
});
let totalWeight: number;
if (attributeWeights && attributeWeights.length > 0) {
totalWeight = attributeWeights.reduce((totalWeight, userWeight) => {
totalWeight += userWeight.weight ?? 100;
return totalWeight;
}, 0);
} else {
totalWeight = allRRHosts.reduce((totalWeight, host) => {
totalWeight += host.weight ?? 100;
return totalWeight;
}, 0);
}
const totalCalibration = allHostsWithCalibration.reduce((totalCalibration, host) => {
totalCalibration += host.calibration;
return totalCalibration;
}, 0);
const usersWithBookingShortfalls = availableUsers.map((user) => {
let userWeight = user.weight ?? 100;
if (attributeWeights) {
userWeight = attributeWeights.find((userWeight) => userWeight.userId === user.id)?.weight ?? 100;
}
const targetPercentage = userWeight / totalWeight;
const userBookings = bookingsOfAvailableUsersOfInterval.filter(
(booking) =>
booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email)
);
const targetNumberOfBookings = (allBookings.length + totalCalibration) * targetPercentage;
const userCalibration =
allHostsWithCalibration.find((host) => host.userId === user.id)?.calibration ?? 0;
const bookingShortfall = targetNumberOfBookings - (userBookings.length + userCalibration);
return {
...user,
calibration: userCalibration,
weight: userWeight,
targetNumberOfBookings,
bookingShortfall,
numBookings: userBookings.length,
};
});
const maxShortfall = Math.max(...usersWithBookingShortfalls.map((user) => user.bookingShortfall));
const usersWithMaxShortfall = usersWithBookingShortfalls.filter(
(user) => user.bookingShortfall === maxShortfall
);
const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100));
const userIdsWithMaxShortfallAndWeight = new Set(
usersWithMaxShortfall
.filter((user) => {
const weight = user.weight ?? 100;
return weight === maxWeight;
})
.map((user) => user.id)
);
const remainingUsersAfterWeightFilter = availableUsers.filter((user) =>
userIdsWithMaxShortfallAndWeight.has(user.id)
);
log.debug(
"filterUsersBasedOnWeights",
safeStringify({
userIdsWithMaxShortfallAndWeight: userIdsWithMaxShortfallAndWeight,
usersWithMaxShortfall: usersWithMaxShortfall.map((user) => user.email),
usersWithBookingShortfalls: usersWithBookingShortfalls.map((user) => ({
calibration: user.calibration,
bookingShortfall: user.bookingShortfall,
email: user.email,
targetNumberOfBookings: user.targetNumberOfBookings,
weight: user.weight,
numBookings: user.numBookings,
})),
remainingUsersAfterWeightFilter: remainingUsersAfterWeightFilter.map((user) => user.email),
})
);
if (!isNonEmptyArray(remainingUsersAfterWeightFilter)) {
throw new Error("Internal Error: Weight filter should never return length=0.");
}
return {
remainingUsersAfterWeightFilter,
usersAndTheirBookingShortfalls: usersWithBookingShortfalls.map((user) => ({
id: user.id,
calibration: user.calibration,
bookingShortfall: user.bookingShortfall,
weight: user.weight,
})),
};
}
private 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) => {
const attributeOptionWithUsers = attributeWithWeights.options.find(
(option) => option.value.toLowerCase() === attributeOption.toLowerCase()
);
allRRHosts.forEach((rrHost) => {
const assignedUser = attributeOptionWithUsers?.assignedUsers.find(
(assignedUser) => rrHost.user.id === assignedUser.member.userId
);
if (allRRHostsWeights.has(rrHost.user.id)) {
allRRHostsWeights.get(rrHost.user.id)?.push(assignedUser?.weight ?? rrHost.weight ?? 100);
} else {
allRRHostsWeights.set(rrHost.user.id, [assignedUser?.weight ?? rrHost.weight ?? 100]);
}
});
});
});
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,
};
});
}
});
log.debug(
"getAverageAttributeWeights",
safeStringify({ allRRHosts, attributesQueryValueChild, attributeWithWeights, averageWeightsHosts })
);
return averageWeightsHosts;
}
private 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;
}
});
});
}
});
return selectionOptions;
}
private async 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 = raqbQueryValueSchema.parse(chosenRoute.attributesQueryValue);
const attributesQueryValueWithLabel = getAttributesQueryValue({
attributesQueryValue: chosenRoute.attributesQueryValue,
attributes: [attributeWithWeights],
dynamicFieldValueOperands: {
fields: (routingFormResponse.form.fields as Fields) || [],
response,
},
});
const parsedAttributesQueryValueWithLabel = raqbQueryValueSchema.parse(attributesQueryValueWithLabel);
if (parsedAttributesQueryValueWithLabel && parsedAttributesQueryValueWithLabel.children1) {
averageWeightsHosts = this.getAverageAttributeWeights(
allRRHosts,
parsedAttributesQueryValueWithLabel.children1,
attributeWithWeights
);
}
if (parsedAttributesQueryValue && parsedAttributesQueryValue.children1) {
fieldOptionData = this.getAttributesForVirtualQueues(
response,
parsedAttributesQueryValue.children1,
attributeWithWeights
);
}
}
}
if (fieldOptionData) {
return { averageWeightsHosts, virtualQueuesData: { chosenRouteId, fieldOptionData } };
}
return;
}
private async getCalendarBusyTimesOfInterval(
usersWithCredentials: {
id: number;
email: string;
credentials: CredentialForCalendarService[];
userLevelSelectedCalendars: SelectedCalendar[];
}[],
interval: RRResetInterval,
rrTimestampBasis: RRTimestampBasis,
meetingStartTime?: Date
): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> {
const usersBusyTimesQuery = await Promise.all(
usersWithCredentials.map((user) =>
getBusyCalendarTimes(
user.credentials,
getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }).toISOString(),
getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }).toISOString(),
user.userLevelSelectedCalendars,
true,
true
)
)
);
return usersBusyTimesQuery.reduce((usersBusyTime, userBusyTimeQuery, index) => {
if (userBusyTimeQuery.success) {
usersBusyTime.push({
userId: usersWithCredentials[index].id,
busyTimes: userBusyTimeQuery.data,
});
}
return usersBusyTime;
}, [] as { userId: number; busyTimes: Awaited<ReturnType<typeof getBusyCalendarTimes>>["data"] }[]);
}
private async getBookingsOfInterval({
eventTypeId,
users,
virtualQueuesData,
interval,
includeNoShowInRRCalculation,
rrTimestampBasis,
meetingStartTime,
}: {
eventTypeId: number;
users: { id: number; email: string }[];
virtualQueuesData: VirtualQueuesDataType | null;
interval: RRResetInterval;
includeNoShowInRRCalculation: boolean;
rrTimestampBasis: RRTimestampBasis;
meetingStartTime?: Date;
}) {
return await this.bookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventTypeId,
users,
startDate: getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }),
endDate: getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }),
virtualQueuesData,
includeNoShowInRRCalculation,
rrTimestampBasis,
});
}
private async fetchAllDataNeededForCalculations<
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
>(getLuckyUserParams: GetLuckyUserParams<T>): Promise<FetchedData> {
const startTime = performance.now();
const { availableUsers, allRRHosts, eventType, meetingStartTime } = getLuckyUserParams;
const notAvailableHosts = (function getNotAvailableHosts() {
const availableUserIds = new Set(availableUsers.map((user) => user.id));
return allRRHosts.reduce(
(
acc: {
id: number;
email: string;
}[],
host
) => {
if (!availableUserIds.has(host.user.id)) {
acc.push({
id: host.user.id,
email: host.user.email,
});
}
return acc;
},
[]
);
})();
const { attributeWeights, virtualQueuesData } = await this.prepareQueuesAndAttributesData(
getLuckyUserParams
);
const interval =
eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrResetInterval
? getLuckyUserParams.eventType.team?.rrResetInterval
: RRResetInterval.MONTH;
const rrTimestampBasis =
eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrTimestampBasis
? getLuckyUserParams.eventType.team.rrTimestampBasis
: RRTimestampBasis.CREATED_AT;
const intervalStartDate = getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime });
const intervalEndDate = getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime });
const [
userBusyTimesOfInterval,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
] = await Promise.all([
this.getCalendarBusyTimesOfInterval(
allRRHosts.map((host) => host.user),
interval,
rrTimestampBasis,
meetingStartTime
),
this.getBookingsOfInterval({
eventTypeId: eventType.id,
users: availableUsers.map((user) => {
return { id: user.id, email: user.email };
}),
virtualQueuesData: virtualQueuesData ?? null,
interval,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
rrTimestampBasis,
meetingStartTime,
}),
this.getBookingsOfInterval({
eventTypeId: eventType.id,
users: notAvailableHosts,
virtualQueuesData: virtualQueuesData ?? null,
interval,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
rrTimestampBasis,
meetingStartTime,
}),
this.getBookingsOfInterval({
eventTypeId: eventType.id,
users: allRRHosts.map((host) => {
return { id: host.user.id, email: host.user.email };
}),
virtualQueuesData: virtualQueuesData ?? null,
interval,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
rrTimestampBasis,
meetingStartTime,
}),
this.hostRepository.findHostsCreatedInInterval({
eventTypeId: eventType.id,
userIds: allRRHosts.map((host) => host.user.id),
startDate: intervalStartDate,
}),
this.userRepository.findUsersWithLastBooking({
userIds: availableUsers.map((user) => user.id),
eventTypeId: eventType.id,
}),
]);
const userFullDayBusyTimes = new Map<number, { start: Date; end: Date }[]>();
userBusyTimesOfInterval.forEach((userBusyTime) => {
const fullDayBusyTimes = userBusyTime.busyTimes
.filter((busyTime) => {
if (!busyTime.timeZone) return false;
const timezoneOffset = dayjs(busyTime.start).tz(busyTime.timeZone).utcOffset() * 60000;
let start = new Date(new Date(busyTime.start).getTime() + timezoneOffset);
const end = new Date(new Date(busyTime.end).getTime() + timezoneOffset);
const earliestStartTime = new Date(Date.UTC(new Date().getFullYear(), new Date().getMonth(), 1));
if (start < earliestStartTime) start = earliestStartTime;
return end.getTime() < new Date().getTime() && isFullDayEvent(start, end);
})
.map((busyTime) => ({ start: new Date(busyTime.start), end: new Date(busyTime.end) }));
userFullDayBusyTimes.set(userBusyTime.userId, fullDayBusyTimes);
});
const oooEntries = await this.oooRepository.findOOOEntriesInInterval({
userIds: allRRHosts.map((host) => host.user.id),
startDate: intervalStartDate,
endDate: intervalEndDate,
});
const oooEntriesGroupedByUserId = new Map<number, { start: Date; end: Date }[]>();
oooEntries.forEach((entry) => {
if (!oooEntriesGroupedByUserId.has(entry.userId)) {
oooEntriesGroupedByUserId.set(entry.userId, []);
}
oooEntriesGroupedByUserId.get(entry.userId)!.push({ start: entry.start, end: entry.end });
});
const oooData: { userId: number; oooEntries: { start: Date; end: Date }[] }[] = [];
userFullDayBusyTimes.forEach((fullDayBusyTimes, userId) => {
const oooEntriesForUser = oooEntriesGroupedByUserId.get(userId) || [];
const combinedEntries = [...oooEntriesForUser, ...fullDayBusyTimes];
const oooEntries = mergeOverlappingRanges(combinedEntries);
oooData.push({
userId,
oooEntries,
});
});
const endTime = performance.now();
log.info(`fetchAllDataNeededForCalculations took ${endTime - startTime}ms`);
log.debug(
"fetchAllDataNeededForCalculations",
safeStringify({
bookingsOfAvailableUsersOfInterval: bookingsOfAvailableUsersOfInterval.length,
bookingsOfNotAvailableUsersOfInterval: bookingsOfNotAvailableUsersOfInterval.length,
allRRHostsBookingsOfInterval: allRRHostsBookingsOfInterval.length,
allRRHostsCreatedInInterval: allRRHostsCreatedInInterval.length,
virtualQueuesData,
attributeWeights,
oooData,
})
);
return {
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
};
}
// === Public methods exposed by the service ===
public async getLuckyUser<
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
>(getLuckyUserParams: GetLuckyUserParams<T>) {
const fetchedData = await this.fetchAllDataNeededForCalculations(getLuckyUserParams);
const { luckyUser } = this.getLuckyUser_requiresDataToBePreFetched({
...getLuckyUserParams,
...fetchedData,
});
return luckyUser;
}
public getLuckyUser_requiresDataToBePreFetched<
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T> & FetchedData) {
const {
eventType,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
oooData,
} = getLuckyUserParams;
if (availableUsers.length === 1) {
return { luckyUser: availableUsers[0], usersAndTheirBookingShortfalls: [] };
}
let usersAndTheirBookingShortfalls: {
id: number;
bookingShortfall: number;
calibration: number;
weight: number;
}[] = [];
if (eventType.isRRWeightsEnabled) {
const {
remainingUsersAfterWeightFilter,
usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls,
} = this.filterUsersBasedOnWeights({
...getLuckyUserParams,
availableUsers,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
oooData,
});
availableUsers = remainingUsersAfterWeightFilter;
usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls;
}
const highestPriorityUsers = this.getUsersWithHighestPriority({ availableUsers });
if (highestPriorityUsers.length === 1) {
return {
luckyUser: highestPriorityUsers[0],
usersAndTheirBookingShortfalls,
};
}
return {
luckyUser: this.leastRecentlyBookedUser({
...getLuckyUserParams,
availableUsers: highestPriorityUsers,
bookingsOfAvailableUsers: bookingsOfAvailableUsersOfInterval,
organizersWithLastCreated,
}),
usersAndTheirBookingShortfalls,
};
}
public async prepareQueuesAndAttributesData<T extends PartialUser>({
eventType,
routingFormResponse,
allRRHosts,
}: Omit<GetLuckyUserParams<T>, "availableUsers">) {
let attributeWeights;
let virtualQueuesData;
const organizationId = eventType.team?.parentId;
log.debug("prepareQueuesAndAttributesData", safeStringify({ routingFormResponse, organizationId }));
if (routingFormResponse && organizationId) {
const routingForm = routingFormResponse?.form;
const routes = zodRoutes.parse(routingForm.routes);
const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId);
if (chosenRoute && "attributeIdForWeights" in chosenRoute) {
const attributeIdForWeights = chosenRoute.attributeIdForWeights;
if (attributeIdForWeights) {
const attributeWithEnabledWeights = await this.attributeRepository.findUniqueWithWeights({
teamId: organizationId,
attributeId: attributeIdForWeights,
isWeightsEnabled: true,
});
if (attributeWithEnabledWeights) {
const queueAndAtributeWeightData = await this.getQueueAndAttributeWeightData(
allRRHosts,
routingFormResponse,
attributeWithEnabledWeights as AttributeWithWeights
);
if (
queueAndAtributeWeightData?.averageWeightsHosts &&
queueAndAtributeWeightData?.virtualQueuesData
) {
attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts;
virtualQueuesData = queueAndAtributeWeightData?.virtualQueuesData;
}
}
}
}
}
return { attributeWeights, virtualQueuesData };
}
public async getOrderedListOfLuckyUsers<AvailableUser extends AvailableUserBase>(
getLuckyUserParams: GetLuckyUserParams<AvailableUser>
) {
const { availableUsers, eventType } = getLuckyUserParams;
const {
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
} = await this.fetchAllDataNeededForCalculations(getLuckyUserParams);
log.info(
"getOrderedListOfLuckyUsers",
safeStringify({
availableUsers: availableUsers.map((user) => {
return { id: user.id, email: user.email, priority: user.priority, weight: user.weight };
}),
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
})
);
let remainingAvailableUsers = [...availableUsers];
let bookingsOfRemainingAvailableUsersOfInterval = [...bookingsOfAvailableUsersOfInterval];
const orderedUsersSet = new Set<AvailableUser>();
const perUserBookingsCount: Record<number, number> = {};
const startTime = performance.now();
let usersAndTheirBookingShortfalls: {
id: number;
bookingShortfall: number;
calibration: number;
weight: number;
}[] = [];
while (remainingAvailableUsers.length > 0) {
const { luckyUser, usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls } =
this.getLuckyUser_requiresDataToBePreFetched({
...getLuckyUserParams,
eventType,
availableUsers: remainingAvailableUsers as [AvailableUser, ...AvailableUser[]],
bookingsOfAvailableUsersOfInterval: bookingsOfRemainingAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
});
if (!usersAndTheirBookingShortfalls.length) {
usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls;
}
if (orderedUsersSet.has(luckyUser)) {
throw new Error(
`Error building ordered list of lucky users. The lucky user ${luckyUser.email} is already in the set.`
);
}
orderedUsersSet.add(luckyUser);
perUserBookingsCount[luckyUser.id] = bookingsOfAvailableUsersOfInterval.filter(
(booking) => booking.userId === luckyUser.id
).length;
remainingAvailableUsers = remainingAvailableUsers.filter((user) => user.id !== luckyUser.id);
bookingsOfRemainingAvailableUsersOfInterval = bookingsOfRemainingAvailableUsersOfInterval.filter(
(booking) => remainingAvailableUsers.map((user) => user.id).includes(booking.userId ?? 0)
);
}
const endTime = performance.now();
log.info(`getOrderedListOfLuckyUsers took ${endTime - startTime}ms`);
const bookingShortfalls: Record<number, number> = {};
const calibrations: Record<number, number> = {};
const weights: Record<number, number> = {};
usersAndTheirBookingShortfalls.forEach((user) => {
bookingShortfalls[user.id] = parseFloat(user.bookingShortfall.toFixed(2));
calibrations[user.id] = parseFloat(user.calibration.toFixed(2));
weights[user.id] = user.weight;
});
return {
users: Array.from(orderedUsersSet),
isUsingAttributeWeights: !!attributeWeights && !!virtualQueuesData,
perUserData: {
bookingsCount: perUserBookingsCount,
bookingShortfalls: eventType.isRRWeightsEnabled ? bookingShortfalls : null,
calibrations: eventType.isRRWeightsEnabled ? calibrations : null,
weights: eventType.isRRWeightsEnabled ? weights : null,
},
};
}
}