fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled (#23061)

* fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled

- When _bypassCalendarBusyTimes parameter is true and third-party calendar fetching fails
- System now falls back to internal bookings only instead of throwing error
- Maintains existing error behavior when bypass is disabled

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: add _silentCalendarFailures parameter to handle calendar failures gracefully

- Add silentlyHandleCalendarFailures parameter to getBusyTimes service
- Add _silentCalendarFailures parameter to getUserAvailability schema
- Add _silentCalendarFailures parameter to tRPC slots endpoint
- Add _silentCalendarFailures parameter to API v2 slots/available endpoint
- Parameter attempts calendar calls first, falls back silently on failure
- Different from bypassCalendarBusyTimes which skips calendar calls entirely

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: remove bypassBusyCalendarTimes logic from error handling

- Keep only silentlyHandleCalendarFailures logic in getBusyTimes error handling
- Remove redundant bypassBusyCalendarTimes condition from calendar failure handling
- Maintain existing bypassBusyCalendarTimes logic that skips calendar calls entirely

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: add silentlyHandleCalendarFailures prop to Booker atom

- Pass silentlyHandleCalendarFailures prop from BookerPlatformWrapper to useAvailableSlots
- Forward prop as _silentCalendarFailures parameter to API v2 slots/available endpoint
- Complete the parameter flow from Booker atom to underlying service layer
- Enables silent handling of calendar provider failures in Booker component

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: fix type and bump library

* chore: bump library

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2025-08-14 18:41:53 +03:00
committed by GitHub
co-authored by morgan@cal.com Somay Chauhan Morgan
parent 994a36d039
commit e237749abd
11 changed files with 102 additions and 302 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
+10 -242
View File
@@ -4264,9 +4264,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"get": {
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
@@ -4328,9 +4326,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
@@ -4402,9 +4398,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"delete": {
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
@@ -4474,9 +4468,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
@@ -22121,13 +22113,7 @@
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
@@ -22164,14 +22150,7 @@
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
},
"CreatePrivateLinkOutput": {
"type": "object",
@@ -22193,10 +22172,7 @@
]
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"GetPrivateLinksOutput": {
"type": "object",
@@ -22221,10 +22197,7 @@
}
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"UpdatePrivateLinkOutput": {
"type": "object",
@@ -22246,10 +22219,7 @@
]
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"DeletePrivateLinkOutput": {
"type": "object",
@@ -22274,10 +22244,7 @@
}
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"StripConnectOutputDto": {
"type": "object",
@@ -26267,205 +26234,6 @@
},
"required": ["status", "data"]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": ["status", "data"]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": ["status", "data"]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": ["status", "data"]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": ["status", "data"]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
+64 -54
View File
@@ -53,6 +53,7 @@ export class BusyTimesService {
})[]
| null;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
}) {
const {
@@ -70,6 +71,7 @@ export class BusyTimesService {
rescheduleUid,
duration,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = params;
@@ -198,63 +200,71 @@ export class BusyTimesService {
);
if (!calendarBusyTimesQuery.success) {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
if (silentlyHandleCalendarFailures) {
logger.warn(
`Calendar busy times fetch failed but handling silently due to silentlyHandleCalendarFailures flag for user ${username}`,
{
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.id),
}
);
} else {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
);
}
} else {
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
eventTypeId,
startTimeDate,
endTimeDate,
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
}
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
eventTypeId,
startTimeDate,
endTimeDate,
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
// calendar busy time from original rescheduled booking should not be blocked
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
/*
// TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
+4
View File
@@ -54,6 +54,7 @@ const availabilitySchema = z
withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(),
bypassBusyCalendarTimes: z.boolean().optional(),
silentlyHandleCalendarFailures: z.boolean().optional(),
shouldServeCache: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
@@ -110,6 +111,7 @@ type GetUserAvailabilityQuery = {
duration?: number;
returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
};
@@ -295,6 +297,7 @@ export class UserAvailabilityService {
duration,
returnDateOverrides,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = availabilitySchema.parse(query);
@@ -457,6 +460,7 @@ export class UserAvailabilityService {
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures,
shouldServeCache,
});
} catch (error) {
@@ -64,6 +64,7 @@ const BookerPlatformWrapperComponent = (
onTimeslotsLoaded,
startTime: customStartTime,
showNoAvailabilityDialog,
silentlyHandleCalendarFailures = false,
} = props;
const layout = BookerLayouts[view];
@@ -310,6 +311,7 @@ const BookerPlatformWrapperComponent = (
Boolean(event?.data?.id),
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
_silentCalendarFailures: silentlyHandleCalendarFailures,
...routingParams,
});
+1
View File
@@ -85,6 +85,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
onTimeslotsLoaded?: (slots: Record<string, Slot[]>) => void;
startTime?: string | Date;
roundRobinHideOrgAndTeam?: boolean;
silentlyHandleCalendarFailures?: boolean;
};
type VIEW_TYPE = keyof typeof BookerLayouts;
@@ -136,6 +136,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
: { username: props.calUsername })}
hostsLimit={3}
allowUpdatingUrlParams={true}
silentlyHandleCalendarFailures={false}
/>
</>
)}
@@ -194,6 +194,12 @@ export class GetAvailableSlotsInput_2024_04_15 {
@ApiHideProperty()
_bypassCalendarBusyTimes?: boolean;
@Transform(({ value }) => value && value.toLowerCase() === "true")
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_silentCalendarFailures?: boolean;
@Transform(({ value }) => (value ? value.toLowerCase() === "true" : false))
@IsBoolean()
@IsOptional()
@@ -35,6 +35,7 @@ export const getScheduleSchemaObject = z.object({
skipContactOwner: z.boolean().nullish(),
_enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(),
_silentCalendarFailures: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(),
queuedFormResponseId: z.string().nullish(),
@@ -737,6 +737,7 @@ export class AvailableSlotsService {
startTime,
endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
}: {
input: TGetScheduleInputSchema;
@@ -753,6 +754,7 @@ export class AvailableSlotsService {
startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>;
endTime: Dayjs;
bypassBusyCalendarTimes: boolean;
silentCalendarFailures: boolean;
shouldServeCache?: boolean;
}) {
const usersWithCredentials = this.getUsersWithCredentials({
@@ -887,6 +889,7 @@ export class AvailableSlotsService {
duration: input.duration || 0,
returnDateOverrides: false,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures: silentCalendarFailures,
shouldServeCache,
},
initialData: {
@@ -958,6 +961,7 @@ export class AvailableSlotsService {
const {
_enableTroubleshooter: enableTroubleshooter = false,
_bypassCalendarBusyTimes: bypassBusyCalendarTimes = false,
_silentCalendarFailures: silentCalendarFailures = false,
_shouldServeCache,
routingFormResponseId,
queuedFormResponseId,
@@ -1069,6 +1073,7 @@ export class AvailableSlotsService {
? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice)
: endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
});
@@ -1095,6 +1100,7 @@ export class AvailableSlotsService {
startTime: dayjs(),
endTime: twoWeeksFromNow,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
});
if (
@@ -1129,6 +1135,7 @@ export class AvailableSlotsService {
startTime,
endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
}));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
+5 -5
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -3559,13 +3559,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.306":
version: 0.0.306
resolution: "@calcom/platform-libraries@npm:0.0.306"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.311":
version: 0.0.311
resolution: "@calcom/platform-libraries@npm:0.0.311"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 602b8b446508429096b8e03e9edbe1ff744cf7c7af3fbe8a683aaaf0d3c96d1cd30ff356f72682449ca072beadaae95fdaa5c44b26e30cfce3bb838f519216f9
checksum: 7a38e8e0478dfe163a97ddab89285b98d7c7e86a73d3659bc29e31c059782708bfe2466e50657f6861e1c67bcfc66e1e3e44803ab3b5f65fd0fe656e880bb916
languageName: node
linkType: hard