From e237749abd2c818e507f690787d3842c2f3043ca Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:41:53 +0300 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 Co-authored-by: Somay Chauhan Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- apps/api/v2/package.json | 2 +- docs/api-reference/v2/openapi.json | 252 +----------------- packages/lib/getBusyTimes.ts | 118 ++++---- packages/lib/getUserAvailability.ts | 4 + .../atoms/booker/BookerPlatformWrapper.tsx | 2 + packages/platform/atoms/booker/types.ts | 1 + .../examples/base/src/pages/booking.tsx | 1 + .../slots/slots-2024-04-15/inputs/index.ts | 6 + .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 7 + yarn.lock | 10 +- 11 files changed, 102 insertions(+), 302 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 594a9535fe..ba7222ed8c 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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": "*", diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 0084005663..b8a7e892bd 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -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": { diff --git a/packages/lib/getBusyTimes.ts b/packages/lib/getBusyTimes.ts index 2dbda02255..a72224e708 100644 --- a/packages/lib/getBusyTimes.ts +++ b/packages/lib/getBusyTimes.ts @@ -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); diff --git a/packages/lib/getUserAvailability.ts b/packages/lib/getUserAvailability.ts index b850bef376..8dfeb74fc3 100644 --- a/packages/lib/getUserAvailability.ts +++ b/packages/lib/getUserAvailability.ts @@ -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) { diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 5916310d14..a0cfeb4502 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -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, }); diff --git a/packages/platform/atoms/booker/types.ts b/packages/platform/atoms/booker/types.ts index 44bab500a4..780703671d 100644 --- a/packages/platform/atoms/booker/types.ts +++ b/packages/platform/atoms/booker/types.ts @@ -85,6 +85,7 @@ export type BookerPlatformWrapperAtomProps = Omit< onTimeslotsLoaded?: (slots: Record) => void; startTime?: string | Date; roundRobinHideOrgAndTeam?: boolean; + silentlyHandleCalendarFailures?: boolean; }; type VIEW_TYPE = keyof typeof BookerLayouts; diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index 93ac6a7fb4..bffb4e083d 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -136,6 +136,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string : { username: props.calUsername })} hostsLimit={3} allowUpdatingUrlParams={true} + silentlyHandleCalendarFailures={false} /> )} diff --git a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts index 622bb4a045..4c16e05bf5 100644 --- a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts +++ b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts @@ -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() diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 78afb85cd9..14235a5f55 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -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(), diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 1931a058f4..aafc904bad 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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); diff --git a/yarn.lock b/yarn.lock index 0454fe8784..7322b3451e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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