feat: ooo calibration (#17864)

* first version of ooo calibration

* decrease precision when checking for full day event

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* improve variable naming

* don't include hosts created after OOO

* fix failing tests

* fix all tests

* add test for ooo calibration

* first implementation to fix full day busy event reaching the current month

* allow mutiple day events

* make getAvailabilityWithTimeZones optional

* fix type error

* fix type errors

* fix tests

* fix type error

* fix test

* code clean up

* remove time adjustment from map

* code clean up

* fix tests

* getAllCalendars -> this.getAllCalendars

* fix typo

* extract mergeOverlappingRanges

* code improvements from feedback

* fix getCalIdsWithTimeZone

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-12-18 16:26:59 +00:00
1 parent 479d6b5961
commit cb1f803d0a
11 files changed
+563 -49

No files matched your search

@@ -64,7 +64,6 @@ export class CalendarsService {
);
try {
const calendarBusyTimes = await getBusyCalendarTimes(
"",
credentials,
dateFrom,
dateTo,
@@ -12,7 +12,6 @@ import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
@@ -525,22 +524,90 @@ export default class GoogleCalendarService implements Calendar {
async getCacheOrFetchAvailability(
args: FreeBusyArgs,
shouldServeCache?: boolean
): Promise<EventBusyDate[] | null> {
): Promise<(EventBusyDate & { id: string })[] | null> {
const freeBusyResult = await this.getFreeBusyResult(args, shouldServeCache);
if (!freeBusyResult.calendars) return null;
const result = Object.values(freeBusyResult.calendars).reduce((c, i) => {
const result = Object.entries(freeBusyResult.calendars).reduce((c, [id, i]) => {
i.busy?.forEach((busyTime) => {
c.push({
id,
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
}, [] as (EventBusyDate & { id: string })[]);
return result;
}
async getAvailabilityWithTimeZones(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> {
const calendar = await this.authedCalendar();
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return [];
}
const getCalIdsWithTimeZone = async () => {
const cals = await this.getAllCalendars(calendar, ["id", "timeZone"]);
if (!cals.length) return [];
if (selectedCalendarIds.length !== 0) {
return selectedCalendarIds.map((selectedCalendarId) => {
const calWithTz = cals.find((cal) => cal.id === selectedCalendarId);
return {
id: selectedCalendarId,
timeZone: calWithTz?.timeZone || "",
};
});
}
// we ever reach that code, we already check before if selectedCalendarIds is empty
return [];
};
try {
const calIdsWithTimeZone = await getCalIdsWithTimeZone();
const calIds = calIdsWithTimeZone.map((calIdWithTimeZone) => ({ id: calIdWithTimeZone.id }));
const originalStartDate = dayjs(dateFrom);
const originalEndDate = dayjs(dateTo);
const diff = originalEndDate.diff(originalStartDate, "days");
const freeBusyData = await this.getCacheOrFetchAvailability({
timeMin: dateFrom,
timeMax: dateTo,
items: calIds,
});
if (!freeBusyData) throw new Error("No response from google calendar");
const timeZoneMap = new Map(calIdsWithTimeZone.map((cal) => [cal.id, cal.timeZone]));
const freeBusyDataWithTimeZone = freeBusyData.map((freeBusy) => {
return {
start: freeBusy.start,
end: freeBusy.end,
timeZone: timeZoneMap.get(freeBusy.id) || "",
};
});
return freeBusyDataWithTimeZone;
} catch (error) {
this.log.error(
"There was an error getting availability from google calendar: ",
safeStringify({ error, selectedCalendars })
);
throw error;
}
}
async getAvailability(
dateFrom: string,
dateTo: string,
@@ -581,7 +648,7 @@ export default class GoogleCalendarService implements Calendar {
);
if (!freeBusyData) throw new Error("No response from google calendar");
return freeBusyData;
return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }));
} else {
const busyData = [];
@@ -607,7 +674,7 @@ export default class GoogleCalendarService implements Calendar {
startDate = endDate.add(1, "minutes");
endDate = startDate.add(90, "days");
}
return busyData;
return busyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }));
}
} catch (error) {
this.log.error(
+16 -10
View File
@@ -19,6 +19,7 @@ import type { CredentialPayload } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
import getCalendarsEvents from "./getCalendarsEvents";
import { getCalendarsEventsWithTimezones } from "./getCalendarsEvents";
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
@@ -153,27 +154,32 @@ const getMonths = (dateFrom: string, dateTo: string): string[] => {
};
export const getBusyCalendarTimes = async (
username: string,
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[],
shouldServeCache?: boolean
shouldServeCache?: boolean,
includeTimeZone?: boolean
) => {
let results: EventBusyDate[][] = [];
let results: (EventBusyDate & { timeZone?: string })[][] = [];
// const months = getMonths(dateFrom, dateTo);
try {
// Subtract 11 hours from the start date to avoid problems in UTC- time zones.
const startDate = dayjs(dateFrom).subtract(11, "hours").format();
// Add 14 hours from the start date to avoid problems in UTC+ time zones.
const endDate = dayjs(dateTo).endOf("month").add(14, "hours").format();
results = await getCalendarsEvents(
withCredentials,
startDate,
endDate,
selectedCalendars,
shouldServeCache
);
if (includeTimeZone) {
results = await getCalendarsEventsWithTimezones(withCredentials, startDate, endDate, selectedCalendars);
} else {
results = await getCalendarsEvents(
withCredentials,
startDate,
endDate,
selectedCalendars,
shouldServeCache
);
}
} catch (e) {
log.warn(safeStringify(e));
}
-1
View File
@@ -241,7 +241,6 @@ export async function getBusyTimes(params: {
if (credentials?.length > 0 && !bypassBusyCalendarTimes) {
const startConnectedCalendarsGet = performance.now();
const calendarBusyTimes = await getBusyCalendarTimes(
username,
credentials,
startTime,
endTime,
+38
View File
@@ -7,6 +7,44 @@ import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
// only for Google Calendar for now
export const getCalendarsEventsWithTimezones = async (
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
): Promise<(EventBusyDate & { timeZone: string })[][]> => {
const calendarCredentials = withCredentials
.filter((credential) => credential.type === "google_calendar")
// filter out invalid credentials - these won't work.
.filter((credential) => !credential.invalid);
const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential)));
const results = calendars.map(async (c, i) => {
/** Filter out nulls */
if (!c) return [];
/** We rely on the index so we can match credentials with calendars */
const { type } = calendarCredentials[i];
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = selectedCalendars
.filter((sc) => sc.integration === type)
// Needed to ensure cache keys are consistent
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
if (!passedSelectedCalendars.length) return [];
/** We extract external Ids so we don't cache too much */
const eventBusyDates =
(await c.getAvailabilityWithTimeZones?.(dateFrom, dateTo, passedSelectedCalendars)) || [];
return eventBusyDates;
});
const awaitedResults = await Promise.all(results);
return awaitedResults;
};
const getCalendarsEvents = async (
withCredentials: CredentialPayload[],
dateFrom: string,
+20
View File
@@ -315,3 +315,23 @@ export function subtract(
return result;
}
export function mergeOverlappingRanges(ranges: { start: Date; end: Date }[]): { start: Date; end: Date }[] {
if (ranges.length === 0) return [];
const sortedRanges = ranges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
const mergedRanges: { start: Date; end: Date }[] = [sortedRanges[0]];
for (let i = 1; i < sortedRanges.length; i++) {
const lastMergedRange = mergedRanges[mergedRanges.length - 1];
const currentRange = sortedRanges[i];
if (currentRange.start.getTime() <= lastMergedRange.end.getTime()) {
lastMergedRange.end = new Date(Math.max(lastMergedRange.end.getTime(), currentRange.end.getTime()));
} else {
mergedRanges.push(currentRange);
}
}
return mergedRanges;
}
+232 -11
View File
@@ -1,7 +1,8 @@
import CalendarManagerMock from "../../../tests/libs/__mocks__/CalendarManager";
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { v4 as uuid } from "uuid";
import { expect, it, describe, vi } from "vitest";
import { expect, it, describe, vi, beforeAll } from "vitest";
import dayjs from "@calcom/dayjs";
import { buildUser, buildBooking } from "@calcom/lib/test/builder";
@@ -17,6 +18,10 @@ vi.mock("@calcom/app-store/routing-forms/components/react-awesome-query-builder/
}));
vi.mock("@calcom/ui", () => ({}));
beforeAll(() => {
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
});
it("can find lucky user with maximize availability", async () => {
const users: GetLuckyUserAvailableUsersType = [
buildUser({
@@ -45,6 +50,10 @@ it("can find lucky user with maximize availability", async () => {
],
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
@@ -59,6 +68,7 @@ it("can find lucky user with maximize availability", async () => {
team: {},
},
allRRHosts: [],
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]);
});
@@ -92,6 +102,10 @@ it("can find lucky user with maximize availability and priority ranking", async
],
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
@@ -107,6 +121,7 @@ it("can find lucky user with maximize availability and priority ranking", async
team: {},
},
allRRHosts: [],
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]);
@@ -163,6 +178,7 @@ it("can find lucky user with maximize availability and priority ranking", async
team: {},
},
allRRHosts: [],
routingFormResponse: null,
})
).resolves.toStrictEqual(usersWithPriorities[2]);
@@ -224,6 +240,7 @@ it("can find lucky user with maximize availability and priority ranking", async
team: {},
},
allRRHosts: [],
routingFormResponse: null,
})
).resolves.toStrictEqual(usersWithSamePriorities[1]);
});
@@ -264,6 +281,9 @@ describe("maximize availability and weights", () => {
],
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
prismaMock.booking.findMany.mockResolvedValue([
@@ -291,12 +311,12 @@ describe("maximize availability and weights", () => {
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email },
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: new Date(0),
},
{
user: { id: users[1].id, email: users[1].email },
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
@@ -311,6 +331,7 @@ describe("maximize availability and weights", () => {
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]);
});
@@ -354,6 +375,8 @@ describe("maximize availability and weights", () => {
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
prismaMock.booking.findMany.mockResolvedValue([
@@ -386,12 +409,12 @@ describe("maximize availability and weights", () => {
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email },
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: new Date(0),
},
{
user: { id: users[1].id, email: users[1].email },
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
@@ -406,6 +429,7 @@ describe("maximize availability and weights", () => {
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[0]);
});
@@ -449,6 +473,8 @@ describe("maximize availability and weights", () => {
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
prismaMock.booking.findMany.mockResolvedValue([
@@ -481,12 +507,12 @@ describe("maximize availability and weights", () => {
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email },
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: new Date(0),
},
{
user: { id: users[1].id, email: users[1].email },
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
@@ -501,10 +527,193 @@ describe("maximize availability and weights", () => {
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[0]);
});
it("applies calibration when user had OOO entries this month", async () => {
const users: GetLuckyUserAvailableUsersType = [
buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test1@example.com",
bookings: [],
}),
buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "test2@example.com",
bookings: [],
}),
];
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: new Date(0),
},
{
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([
{
start: dayjs().subtract(10, "day").toDate(),
end: dayjs().subtract(5, "day").toDate(),
userId: users[0].id,
},
]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([
{
userId: allRRHosts[0].user.id,
weight: allRRHosts[0].weight,
createdAt: allRRHosts[0].createdAt,
},
]);
// bookings of current month
prismaMock.booking.findMany.mockResolvedValue([
buildBooking({
id: 4,
userId: 1,
createdAt: dayjs().subtract(2, "days").toDate(),
}),
// happened during OOO of userId 1
buildBooking({
id: 4,
userId: 2,
createdAt: dayjs().subtract(6, "days").toDate(),
}),
// happened during OOO of userId 1
buildBooking({
id: 5,
userId: 2,
createdAt: dayjs().subtract(7, "days").toDate(),
}),
]);
await expect(
getLuckyUser({
availableUsers: users,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]); // user[1] has one more bookings, but user[0] has calibration 2
});
it("applies calibration when user had full day calendar events this month", async () => {
const users: GetLuckyUserAvailableUsersType = [
buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test1@example.com",
bookings: [
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T06:30:00.000Z"),
},
],
}),
buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "test2@example.com",
bookings: [
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
},
],
}),
];
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: new Date(0),
},
{
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
];
CalendarManagerMock.getBusyCalendarTimes
.mockResolvedValueOnce([
{
start: dayjs().utc().startOf("month").toDate(),
end: dayjs().utc().startOf("month").add(3, "day").toDate(),
timeZone: "UTC",
},
])
.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([
{
userId: allRRHosts[0].user.id,
weight: allRRHosts[0].weight,
createdAt: allRRHosts[0].createdAt,
},
]);
prismaMock.booking.findMany.mockResolvedValue([
buildBooking({
id: 1,
userId: 1,
createdAt: dayjs().startOf("month").add(10, "day").toDate(),
}),
// happend during OOO
buildBooking({
id: 2,
userId: 2,
createdAt: dayjs().startOf("month").add(5, "hour").toDate(),
}),
// happend during OOO
buildBooking({
id: 3,
userId: 2,
createdAt: dayjs().startOf("month").add(20, "hour").toDate(),
}),
]);
await expect(
getLuckyUser({
availableUsers: users,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]); // user[1] has one more booking, but user[0] has calibration 2
});
it("applies calibration to newly added hosts so they are not penalized unfairly compared to their peers", async () => {
const users: GetLuckyUserAvailableUsersType = [
buildUser({
@@ -540,17 +749,20 @@ describe("maximize availability and weights", () => {
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email },
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: middleOfMonth,
},
{
user: { id: users[1].id, email: users[1].email },
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([
@@ -582,6 +794,7 @@ describe("maximize availability and weights", () => {
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]);
// findMany bookings are AFTER the new host (user 1) was added, calibration=0.
@@ -606,6 +819,7 @@ describe("maximize availability and weights", () => {
team: {},
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[0]);
});
@@ -742,6 +956,8 @@ describe("attribute weights and virtual queues", () => {
user: {
id: 1,
email: "test1@example.com",
credentials: [],
selectedCalendars: [],
},
createdAt: new Date(),
weight: 10,
@@ -750,6 +966,8 @@ describe("attribute weights and virtual queues", () => {
user: {
id: 2,
email: "test2@example.com",
credentials: [],
selectedCalendars: [],
},
createdAt: new Date(),
weight: 150,
@@ -858,6 +1076,9 @@ describe("attribute weights and virtual queues", () => {
chosenRouteId: routeId,
};
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
prismaMock.booking.findMany.mockResolvedValue([
@@ -950,12 +1171,12 @@ describe("attribute weights and virtual queues", () => {
const allRRHosts = [
{
user: { id: users[0].id, email: users[0].email },
user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] },
weight: users[0].weight,
createdAt: new Date(0),
},
{
user: { id: users[1].id, email: users[1].email },
user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] },
weight: users[1].weight,
createdAt: new Date(0),
},
+172 -19
View File
@@ -2,6 +2,8 @@ import type { Prisma, User } from "@prisma/client";
import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import dayjs from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils";
import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod";
@@ -9,8 +11,13 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { AttributeType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import { mergeOverlappingRanges } from "../date-ranges";
const log = logger.getSubLogger({ prefix: ["getLuckyUser"] });
const { getAttributesQueryValue } = acrossQueryValueCompatiblity;
@@ -59,7 +66,12 @@ interface GetLuckyUserParams<T extends PartialUser> {
eventType: { id: number; isRRWeightsEnabled: boolean; team: { parentId?: number | null } | null };
// all routedTeamMemberIds or all hosts of event types
allRRHosts: {
user: { id: number; email: string };
user: {
id: number;
email: string;
credentials: CredentialPayload[];
selectedCalendars: SelectedCalendar[];
};
createdAt: Date;
weight?: number | null;
}[];
@@ -133,20 +145,15 @@ function getHostsWithCalibration({
hosts,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
oooData,
}: {
hosts: { userId: number; email: string; createdAt: Date }[];
allRRHostsBookingsOfThisMonth: PartialBooking[];
allRRHostsCreatedThisMonth: { userId: number; createdAt: Date }[];
oooData: OOODataType;
}) {
const existingBookings = allRRHostsBookingsOfThisMonth;
// Return early if there are no new hosts or no existing bookings
if (allRRHostsCreatedThisMonth.length === 0 || existingBookings.length === 0) {
return hosts.map((host) => ({ ...host, calibration: 0 }));
}
// Helper function to calculate calibration for a new host
function calculateCalibration(newHost: { userId: number; createdAt: Date }) {
function calculateNewHostCalibration(newHost: { userId: number; createdAt: Date }) {
const existingBookingsBeforeAdded = existingBookings.filter(
(booking) => booking.userId !== newHost.userId && booking.createdAt < newHost.createdAt
);
@@ -159,7 +166,7 @@ function getHostsWithCalibration({
? existingBookingsBeforeAdded.length / hostsAddedBefore.length
: 0;
log.debug(
"calculateCalibration",
"calculateNewHostCalibration",
safeStringify({
newHost,
existingBookingsBeforeAdded: existingBookingsBeforeAdded.length,
@@ -169,17 +176,53 @@ function getHostsWithCalibration({
);
return calibration;
}
// Calculate calibration for each new host and store in a Map
const newHostsWithCalibration = new Map(
allRRHostsCreatedThisMonth.map((newHost) => [
newHost.userId,
{ ...newHost, calibration: calculateCalibration(newHost) },
])
);
// Map hosts with their respective calibration values
const existingBookings = allRRHostsBookingsOfThisMonth;
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 // attendee email check is missing here in case of fixed hosts
);
// - 1 because the we need to exclude the current user
calibration += bookingsInTimeframe.length / (hosts.length - 1);
});
oooCalibration.set(userId, calibration);
});
let newHostsWithCalibration: Map<
number,
{
calibration: number;
userId: number;
createdAt: Date;
}
> = new Map();
if (allRRHostsCreatedThisMonth.length && existingBookings.length) {
// Calculate calibration for each new host and store in a Map
newHostsWithCalibration = new Map(
allRRHostsCreatedThisMonth.map((newHost) => [
newHost.userId,
{ ...newHost, calibration: calculateNewHostCalibration(newHost) },
])
);
// Map hosts with their respective calibration values
}
return hosts.map((host) => ({
...host,
calibration: newHostsWithCalibration.get(host.userId)?.calibration ?? 0,
calibration:
(newHostsWithCalibration.get(host.userId)?.calibration ?? 0) + (oooCalibration.get(host.userId) ?? 0),
}));
}
@@ -205,6 +248,8 @@ function getUsersWithHighestPriority<T extends PartialUser & { priority?: number
return usersWithHighestPriority;
}
type OOODataType = { userId: number; oooEntries: { start: Date; end: Date }[] }[];
function filterUsersBasedOnWeights<
T extends PartialUser & {
weight?: number | null;
@@ -217,6 +262,7 @@ function filterUsersBasedOnWeights<
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
attributeWeights,
oooData,
}: GetLuckyUserParams<T> & FetchedData) {
//get all bookings of all other RR hosts that are not available
@@ -228,6 +274,7 @@ function filterUsersBasedOnWeights<
}),
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
oooData,
});
// Calculate the total calibration and weight of all round-robin hosts
@@ -330,6 +377,30 @@ function filterUsersBasedOnWeights<
};
}
async function getCurrentMonthCalendarBusyTimes(
usersWithCredentials: {
id: number;
email: string;
credentials: CredentialPayload[];
selectedCalendars: SelectedCalendar[];
}[]
): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> {
return Promise.all(
usersWithCredentials.map((user) =>
getBusyCalendarTimes(
user.credentials,
startOfMonth().toISOString(),
new Date().toISOString(),
user.selectedCalendars,
true
).then((busyTimes) => ({
userId: user.id,
busyTimes,
}))
)
);
}
async function getCurrentMonthsBookings({
eventTypeId,
users,
@@ -362,6 +433,7 @@ export async function getLuckyUser<
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
} = await fetchAllDataNeededForCalculations(getLuckyUserParams);
const { luckyUser } = getLuckyUser_requiresDataToBePreFetched({
@@ -373,6 +445,7 @@ export async function getLuckyUser<
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
});
return luckyUser;
@@ -391,6 +464,7 @@ type FetchedData = {
}[]
| null;
virtualQueuesData?: VirtualQueuesDataType | null;
oooData: OOODataType;
};
export function getLuckyUser_requiresDataToBePreFetched<
@@ -406,6 +480,7 @@ export function getLuckyUser_requiresDataToBePreFetched<
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
organizersWithLastCreated,
oooData,
} = getLuckyUserParams;
// there is only one user
@@ -430,6 +505,7 @@ export function getLuckyUser_requiresDataToBePreFetched<
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
oooData,
});
availableUsers = remainingUsersAfterWeightFilter;
usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls;
@@ -455,6 +531,13 @@ export function getLuckyUser_requiresDataToBePreFetched<
};
}
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;
}
async function fetchAllDataNeededForCalculations<
T extends PartialUser & {
priority?: number | null;
@@ -489,12 +572,14 @@ async function fetchAllDataNeededForCalculations<
const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams);
const [
currentMonthUserBusyTimes,
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
organizersWithLastCreated,
] = await Promise.all([
getCurrentMonthCalendarBusyTimes(allRRHosts.map((host) => host.user)),
getCurrentMonthsBookings({
eventTypeId: eventType.id,
users: availableUsers.map((user) => {
@@ -570,6 +655,70 @@ async function fetchAllDataNeededForCalculations<
}),
]);
const userFullDayBusyTimes = new Map<number, { start: Date; end: Date }[]>();
currentMonthUserBusyTimes.forEach((userBusyTime) => {
const fullDayBusyTimes = userBusyTime.busyTimes
.filter((busyTime) => {
//timeZone is always defined when calling getBusyCalendarTimes with includeTimeZone true
if (!busyTime.timeZone) return false;
// make sure start date and end date is converted to 00:00 for full day busy events
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);
// needed for full day busy events that started the month before
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);
});
// get cal.com OOO data
const oooEntries = await prisma.outOfOfficeEntry.findMany({
where: {
userId: {
in: allRRHosts.map((host) => host.user.id),
},
end: {
lte: new Date(),
gte: startOfMonth(),
},
},
select: {
start: true,
end: true,
userId: true,
},
});
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`);
@@ -582,6 +731,7 @@ async function fetchAllDataNeededForCalculations<
allRRHostsCreatedThisMonth: allRRHostsCreatedThisMonth.length,
virtualQueuesData,
attributeWeights,
oooData,
})
);
@@ -593,6 +743,7 @@ async function fetchAllDataNeededForCalculations<
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
};
}
@@ -614,6 +765,7 @@ export async function getOrderedListOfLuckyUsers<AvailableUser extends Available
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
} = await fetchAllDataNeededForCalculations(getLuckyUserParams);
log.info(
@@ -656,6 +808,7 @@ export async function getOrderedListOfLuckyUsers<AvailableUser extends Available
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
oooData,
});
if (!usersAndTheirBookingShortfalls.length) {
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { Ensure } from "@calcom/types/utils";
import { TRPCError } from "@trpc/server";
@@ -681,6 +682,10 @@ export class EventTypeRepository {
id: true,
name: true,
email: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
selectedCalendars: true,
},
},
weight: true,
@@ -76,7 +76,6 @@ export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => {
// get all clanedar services
const calendarBusyTimes = await getBusyCalendarTimes(
"",
credentials,
dateFrom,
dateTo,
+7
View File
@@ -266,6 +266,13 @@ export interface Calendar {
shouldServeCache?: boolean
): Promise<EventBusyDate[]>;
// for OOO calibration (only google calendar for now)
getAvailabilityWithTimeZones?(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]>;
fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]>;