From cb1f803d0aab51733f73cff7c4c7239f1cf68a73 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:26:59 -0500 Subject: [PATCH] feat: ooo calibration (#17864) * first version of ooo calibration * decrease precision when checking for full day event Co-authored-by: Alex van Andel * 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 Co-authored-by: Alex van Andel Co-authored-by: Benny Joo --- .../calendars/services/calendars.service.ts | 1 - .../googlecalendar/lib/CalendarService.ts | 79 +++++- packages/core/CalendarManager.ts | 26 +- packages/core/getBusyTimes.ts | 1 - packages/core/getCalendarsEvents.ts | 38 +++ packages/lib/date-ranges.ts | 20 ++ packages/lib/server/getLuckyUser.test.ts | 243 +++++++++++++++++- packages/lib/server/getLuckyUser.ts | 191 ++++++++++++-- packages/lib/server/repository/eventType.ts | 5 + .../availability/calendarOverlay.handler.ts | 1 - packages/types/Calendar.d.ts | 7 + 11 files changed, 563 insertions(+), 49 deletions(-) diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index d19cd7a13b..23b4236059 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -64,7 +64,6 @@ export class CalendarsService { ); try { const calendarBusyTimes = await getBusyCalendarTimes( - "", credentials, dateFrom, dateTo, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index a203a3bb3f..bde554eaad 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -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 { + ): 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); + }, [] 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( diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 0db329a22c..cf2a981137 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -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)); } diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index f8444f447e..e04f0a1ca5 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -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, diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index e98927cfc3..73f0956139 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -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, diff --git a/packages/lib/date-ranges.ts b/packages/lib/date-ranges.ts index 19e644009f..3e23e24eaa 100644 --- a/packages/lib/date-ranges.ts +++ b/packages/lib/date-ranges.ts @@ -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; +} diff --git a/packages/lib/server/getLuckyUser.test.ts b/packages/lib/server/getLuckyUser.test.ts index 9b89de9154..f2d56d212b 100644 --- a/packages/lib/server/getLuckyUser.test.ts +++ b/packages/lib/server/getLuckyUser.test.ts @@ -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), }, diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index 5c5f365e1b..7fe66b216e 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -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 { 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(); + + 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 & 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(); + + 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(); + + 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 { // get all clanedar services const calendarBusyTimes = await getBusyCalendarTimes( - "", credentials, dateFrom, dateTo, diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index d2bd0195c7..67454bcb1a 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -266,6 +266,13 @@ export interface Calendar { shouldServeCache?: boolean ): Promise; + // 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; listCalendars(event?: CalendarEvent): Promise;