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:
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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+7
@@ -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[]>;
|
||||
|
||||
Reference in new issue
Block a user