fix: Return empty available days if error querying calendar (#22828)

* Return a busy block placeholder if calendar throws an error

* Refactor `getCalendarsEvents` to return an object with a success prop

* Throw error in `getBusyTimes` if failed to fetch calendar availability

* Return empty available days if error getting busy times

* yeet.

* Type fix

* Fix type error in getLuckyUsers

* Type fixes

* Type fix

* Type fix

* Fix test

* Fix test mocks

* Refactor calendars.service to use new calendarBusyTimesQuery

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Joe Au-Yeung
2025-07-31 02:09:09 +01:00
committed by GitHub
co-authored by Alex van Andel
parent 82063cc9a1
commit f7b201b0b5
8 changed files with 131 additions and 90 deletions
@@ -83,32 +83,31 @@ export class CalendarsService {
calendarsToLoad,
userId
);
try {
const calendarBusyTimes = await getBusyCalendarTimes(
this.buildNonDelegationCredentials(credentials),
dateFrom,
dateTo,
composedSelectedCalendars
);
const calendarBusyTimesConverted = calendarBusyTimes.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
} catch (error) {
const calendarBusyTimesQuery = await getBusyCalendarTimes(
this.buildNonDelegationCredentials(credentials),
dateFrom,
dateTo,
composedSelectedCalendars
);
if (!calendarBusyTimesQuery.success) {
throw new InternalServerErrorException(
"Unable to fetch connected calendars events. Please try again later."
);
}
const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
}
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
+19 -13
View File
@@ -1281,12 +1281,15 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
success: true,
data: [
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
@@ -1347,12 +1350,15 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
success: true,
data: [
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
@@ -1421,7 +1427,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
const scenarioData = {
eventTypes: [
+6 -15
View File
@@ -245,21 +245,11 @@ export const getBusyCalendarTimes = async (
}
// const months = getMonths(dateFrom, dateTo);
// 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).add(14, "hours").format();
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).add(14, "hours").format();
log.debug(
"getBusyCalendarTimes manipulated dates",
safeStringify({
newStartDate: startDate,
newEndDate: endDate,
oldStartDate: dateFrom,
oldEndDate: dateTo,
})
);
if (includeTimeZone) {
results = await getCalendarsEventsWithTimezones(
deduplicatedCredentials,
@@ -281,8 +271,9 @@ export const getBusyCalendarTimes = async (
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.externalId),
error: safeStringify(e),
});
return { success: false, data: [{ start: startDate, end: endDate, source: "error-placeholder" }] };
}
return results.reduce((acc, availability) => acc.concat(availability), []);
return { success: true, data: results.reduce((acc, availability) => acc.concat(availability), []) };
};
export const createEvent = async (
+12 -1
View File
@@ -179,13 +179,24 @@ const _getBusyTimes = async (params: {
performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd");
if (credentials?.length > 0 && !bypassBusyCalendarTimes) {
const startConnectedCalendarsGet = performance.now();
const calendarBusyTimes = await getBusyCalendarTimes(
const calendarBusyTimesQuery = await getBusyCalendarTimes(
credentials,
startTime,
endTime,
selectedCalendars,
shouldServeCache
);
if (!calendarBusyTimesQuery.success) {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
);
}
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
+33 -18
View File
@@ -411,24 +411,39 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
: user.userLevelSelectedCalendars;
const busyTimes = await getBusyTimes({
credentials: user.credentials,
startTime: getBusyTimesStart,
endTime: getBusyTimesEnd,
eventTypeId,
userId: user.id,
userEmail: user.email,
username: `${user.username}`,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars,
seatedEvent: !!eventType?.seatsPerTimeSlot,
rescheduleUid: initialData?.rescheduleUid || null,
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
shouldServeCache,
});
let busyTimes = [];
try {
busyTimes = await getBusyTimes({
credentials: user.credentials,
startTime: getBusyTimesStart,
endTime: getBusyTimesEnd,
eventTypeId,
userId: user.id,
userEmail: user.email,
username: `${user.username}`,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars,
seatedEvent: !!eventType?.seatsPerTimeSlot,
rescheduleUid: initialData?.rescheduleUid || null,
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
shouldServeCache,
});
} catch (error) {
log.error(`Error fetching busy times for user ${username}:`, error);
return {
busy: [],
timeZone,
dateRanges: [],
oooExcludedDateRanges: [],
workingHours: [],
dateOverrides: [],
currentSeats: [],
datesOutOfOffice: undefined,
};
}
const detailedBusyTimes: EventBusyDetails[] = [
...busyTimes.map((a) => ({
+18 -15
View File
@@ -55,7 +55,7 @@ it("can find lucky user with maximize availability", async () => {
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
// TODO: we may be able to use native prisma generics somehow?
@@ -107,7 +107,7 @@ it("can find lucky user with maximize availability and priority ranking", async
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
// TODO: we may be able to use native prisma generics somehow?
@@ -289,7 +289,7 @@ describe("maximize availability and weights", () => {
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
@@ -392,7 +392,7 @@ describe("maximize availability and weights", () => {
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
@@ -500,7 +500,7 @@ describe("maximize availability and weights", () => {
}),
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.host.findMany.mockResolvedValue([]);
@@ -600,7 +600,7 @@ describe("maximize availability and weights", () => {
},
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([
{
@@ -707,13 +707,16 @@ describe("maximize availability and weights", () => {
];
CalendarManagerMock.getBusyCalendarTimes
.mockResolvedValueOnce([
{
start: dayjs().utc().startOf("month").toDate(),
end: dayjs().utc().startOf("month").add(3, "day").toDate(),
timeZone: "UTC",
},
])
.mockResolvedValueOnce({
success: true,
data: [
{
start: dayjs().utc().startOf("month").toDate(),
end: dayjs().utc().startOf("month").add(3, "day").toDate(),
timeZone: "UTC",
},
],
})
.mockResolvedValue([]);
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
@@ -817,7 +820,7 @@ describe("maximize availability and weights", () => {
},
];
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
// TODO: we may be able to use native prisma generics somehow?
@@ -1279,7 +1282,7 @@ describe("attribute weights and virtual queues", () => {
chosenRouteId: routeId,
};
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]);
prismaMock.user.findMany.mockResolvedValue(users);
+12 -5
View File
@@ -456,7 +456,7 @@ async function getCalendarBusyTimesOfInterval(
rrTimestampBasis: RRTimestampBasis,
meetingStartTime?: Date
): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> {
return Promise.all(
const usersBusyTimesQuery = await Promise.all(
usersWithCredentials.map((user) =>
getBusyCalendarTimes(
user.credentials,
@@ -465,12 +465,19 @@ async function getCalendarBusyTimesOfInterval(
user.userLevelSelectedCalendars,
true,
true
).then((busyTimes) => ({
userId: user.id,
busyTimes,
}))
)
)
);
return usersBusyTimesQuery.reduce((usersBusyTime, userBusyTimeQuery, index) => {
if (userBusyTimeQuery.success) {
usersBusyTime.push({
userId: usersWithCredentials[index].id,
busyTimes: userBusyTimeQuery.data,
});
}
return usersBusyTime;
}, [] as { userId: number; busyTimes: Awaited<ReturnType<typeof getBusyCalendarTimes>>["data"] }[]);
}
async function getBookingsOfInterval({
@@ -84,13 +84,22 @@ export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => {
});
// get all clanedar services
const calendarBusyTimes = await getBusyCalendarTimes(
const calendarBusyTimesQuery = await getBusyCalendarTimes(
credentials,
dateFrom,
dateTo,
composedSelectedCalendars
);
if (!calendarBusyTimesQuery.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch busy calendar times",
});
}
const calendarBusyTimes = calendarBusyTimesQuery.data;
// Convert to users timezone
const userTimeZone = input.loggedInUsersTz;