Files
cal.diy/packages/features/calendars/lib/CalendarManager.ts
T
Benny JooandDevin AI 6287026638 fix: prevent 500 error when deleting calendar events with empty uid (#27500)
* fix: prevent 500 error when deleting calendar events with empty uid

This fix addresses a 500 error that occurs when trying to delete a Google
Calendar event with an empty event ID, which results in a malformed API URL.

Root cause: When calendar event creation fails or returns without an ID,
booking references were being created with empty uids. Later, when trying
to delete these events (e.g., during reschedule), the empty uid caused
a 404 from Google Calendar API which was then thrown as a 500 error.

Changes:
1. CalendarManager.deleteEvent: Added validation to skip deletion if
   bookingRefUid is empty, with appropriate error logging. This is a
   safety net for existing bad data in the database.

2. EventManager.create: Added filtering to exclude booking references
   with empty uids from being stored, with error logging to help
   diagnose the root cause of missing event IDs.

3. EventManager.updateLocation: Same filtering applied for consistency.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: revert EventManager filtering to preserve existing behavior

The test expects that booking references with empty uids are still created
when calendar event creation fails. This is intentional behavior to track
failed calendar syncs. The CalendarManager.deleteEvent fix handles the
deletion case gracefully by skipping the API call when uid is empty.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: throw ErrorWithCode instead of silently returning when bookingRefUid is empty

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: validate uid before calling calendar.deleteEvent to prevent 500 error

- Add uid check in lastAttendeeDeleteBooking.ts before calling calendar.deleteEvent
- Add uid check in EventManager.updateAllCalendarEvents before calling calendar.deleteEvent
- Revert CalendarManager.deleteEvent changes (validation at call sites is the proper fix)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: add error logging safeguard in CalendarManager.deleteEvent for empty bookingRefUid

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: move bookingRefUid check before getCalendar call

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: prevent storing booking references with empty uid when calendar event creation fails

Root cause fix: Change the check from 'if (createdEvent)' to 'if (createdEvent.createdEvent)'
in createAllCalendarEvents to prevent failed calendar events from being added to results.

This prevents empty uids from being stored in the database in the first place, which
was causing 500 errors when trying to delete non-existent calendar events later.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* test: update test to expect no calendar reference when calendar event creation fails

The root cause fix changes behavior so that failed calendar events no longer
create booking references with empty uids. This test now expects an empty
references array when calendar event creation fails, which is the correct
behavior that prevents 500 errors later when trying to delete non-existent
calendar events.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor: use createdEvent.success instead of createdEvent.createdEvent

Using success is more semantically clear since it explicitly checks the
success status rather than checking if the nested object exists.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor: use optional chaining for createdEvent?.success

Using optional chaining is safer in case createdEvent is somehow undefined.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* revert: remove root cause fix to preserve backfilling script compatibility

The root cause fix (checking createdEvent?.success instead of createdEvent)
would break the backfilling script that relies on booking references with
empty uids to identify which bookings need backfilling.

This PR now only includes defensive checks to prevent 500 errors when
deleting calendar events with empty uids.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* revert

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-09 08:42:14 -03:00

581 lines
20 KiB
TypeScript

import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import getCalendarsEvents, {
getCalendarsEventsWithTimezones,
} from "@calcom/features/calendars/lib/getCalendarsEvents";
import { getRichDescription, getUid } from "@calcom/lib/CalEventParser";
import { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import type {
CalendarEvent,
CalendarFetchMode,
CalendarServiceEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
SelectedCalendar,
} from "@calcom/types/Calendar";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
import { sortBy } from "lodash";
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
/**
* Process the calendar event by generating description and removing attendees if needed
*/
export const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
if (calEvent.seatsPerTimeSlot) {
calEvent.responses = null;
calEvent.userFieldsResponses = null;
calEvent.additionalNotes = null;
calEvent.customInputs = null;
}
// Generate the calendar event description
const calendarEvent: CalendarServiceEvent = {
...calEvent,
calendarDescription: getRichDescription(calEvent),
};
const isMeetLocationType = calEvent.location === MeetLocationType;
// Determine if the calendar event should include attendees
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
// Zoho Calendar requires at least one attendee, so don't empty attendees array for Zoho Calendar
const hasZohoCalendar = calEvent.destinationCalendar?.some((cal) => cal.integration === "zoho_calendar") ?? false;
if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType && !hasZohoCalendar) {
calendarEvent.attendees = [];
}
return calendarEvent;
};
type IntegrationWithCredentials = Awaited<
ReturnType<typeof getCalendarCredentials>
>[number]["integration"] & {
credentials?: CredentialPayload[];
credential?: CredentialPayload;
};
type CleanIntegration = Omit<IntegrationWithCredentials, "credentials" | "credential">;
/**
* Important function to prevent leaking credentials to the client
* @param appIntegration
* @returns App
*/
export const cleanIntegrationKeys = (appIntegration: IntegrationWithCredentials): CleanIntegration => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credentials, credential, ...rest } = appIntegration;
return rest;
};
export const getCalendarCredentials = (credentials: Array<CredentialForCalendarService>) => {
const calendarCredentials = getApps(credentials, true)
.filter((app) => app.type.endsWith("_calendar"))
.flatMap((app) => {
const credentials = app.credentials.flatMap((credential) => {
const calendar = () => getCalendar(credential, "slots");
return app.variant === "calendar" ? [{ integration: app, credential, calendar }] : [];
});
return credentials.length ? credentials : [];
});
return calendarCredentials;
};
export const getCalendarCredentialsWithoutDelegation = (credentials: CredentialPayload[]) => {
return getCalendarCredentials(buildNonDelegationCredentials(credentials));
};
export type ConnectedCalendar = Omit<IntegrationCalendar, "primary"> & {
primary: boolean | null;
isSelected: boolean;
readOnly: boolean;
credentialId: number;
delegationCredentialId: string | null;
};
export const getConnectedCalendars = async (
calendarCredentials: ReturnType<typeof getCalendarCredentials>,
selectedCalendars: { externalId: string }[],
destinationCalendarExternalId?: string
): Promise<{
connectedCalendars: {
integration: CleanIntegration;
calendars?: ConnectedCalendar[];
credentialId: number;
delegationCredentialId?: string | null;
error?: {
message: string;
};
primary?: ConnectedCalendar;
}[];
destinationCalendar: IntegrationCalendar | undefined;
}> => {
const connectedCalendars = await Promise.all(
calendarCredentials.map(async (item) => {
try {
const { integration, credential } = item;
const safeToSendIntegration = cleanIntegrationKeys(integration);
const calendar = await item.calendar;
// Don't leak credentials to the client
const credentialId = credential.id;
const delegationCredentialId = credential.delegatedToId ?? null;
if (!calendar) {
return {
integration: safeToSendIntegration,
credentialId,
delegationCredentialId,
};
}
const calendarInstance = await calendar();
if (!calendarInstance) {
return {
integration: safeToSendIntegration,
credentialId,
delegationCredentialId,
error: {
message: "Could not get calendar instance",
},
};
}
const cals = await calendarInstance.listCalendars();
const calendars: ConnectedCalendar[] = sortBy(
cals.map((cal: IntegrationCalendar) => {
return {
...cal,
readOnly: cal.readOnly || false,
primary: cal.primary || null,
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
credentialId,
delegationCredentialId,
};
}),
["primary"]
);
const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined);
if (!primary) {
return {
integration: safeToSendIntegration,
credentialId,
error: {
message: "No primary calendar found",
},
};
}
return {
integration: safeToSendIntegration,
credentialId,
delegationCredentialId,
primary,
calendars,
};
} catch (error) {
let errorMessage = "Could not get connected calendars";
// Here you can expect for specific errors
if (error instanceof Error) {
if (error.message === "invalid_grant") {
errorMessage = "Access token expired or revoked";
}
}
if (error instanceof CalendarAppDelegationCredentialError) {
errorMessage = error.message;
}
log.error("getConnectedCalendars failed", error, safeStringify({ credentialId: item.credential.id }));
return {
integration: cleanIntegrationKeys(item.integration),
credentialId: item.credential.id,
delegationCredentialId: item.credential.delegatedToId,
error: {
message: errorMessage,
},
};
}
})
);
let destinationCalendar: IntegrationCalendar | undefined;
if (destinationCalendarExternalId) {
for (const connectedCalendar of connectedCalendars) {
if (!("calendars" in connectedCalendar) || !connectedCalendar.calendars) {
continue;
}
const calendar = connectedCalendar.calendars.find(
(cal) => cal.externalId === destinationCalendarExternalId
);
if (calendar) {
destinationCalendar = {
...calendar,
primary: calendar.primary ?? undefined,
primaryEmail: connectedCalendar.primary?.email,
integrationTitle: connectedCalendar.integration?.title,
};
break;
}
}
}
return { connectedCalendars, destinationCalendar };
};
/**
* This function deduplicates credentials based on selected calendars
* It removes regular credentials for which corresponding delegation credentials exist which can be identified only associating them with selected calendars
*
* This is important to prevent unnecessary/duplicate calls to calendar APIs
*/
export const deduplicateCredentialsBasedOnSelectedCalendars = ({
credentials,
selectedCalendars,
}: {
credentials: CredentialForCalendarService[];
selectedCalendars: SelectedCalendar[];
}) => {
// Only proceed if we have credentials
if (credentials.length === 0) {
return credentials;
}
// Get the user email from the first credential
const userEmail = credentials[0].user?.email;
// If no email, we can't identify which credential is duplicate
if (!userEmail) {
return credentials;
}
// Check if there are delegation credentials for the same integration types
const delegationCredentials = credentials.filter((credential) => credential.delegatedToId);
// Find all selected calendars with externalId matching the user's email and having a regular credential
const selectedCalendarsWithUserEmailConnectedWithRegularCredential = selectedCalendars.filter(
(calendar) => calendar.externalId === userEmail && calendar.credentialId && calendar.credentialId > 0
);
// If no delegation credentials or no regular credentials connected selected calendars, return original credentials
if (
delegationCredentials.length === 0 ||
selectedCalendarsWithUserEmailConnectedWithRegularCredential.length === 0
) {
return credentials;
}
const deduplicatedCredentials = [...credentials];
// For each selected calendar with user email, check if a delegation credential exists for the same integration.
// If yes, we remove such a regular credential as that is a duplicate
const credentialIdsToRemove = selectedCalendarsWithUserEmailConnectedWithRegularCredential
.filter((calendar) =>
delegationCredentials.some((credential) => credential.type === calendar.integration)
)
.map((calendar) => calendar.credentialId);
// Remove the regular credentials that are now handled by delegation credentials
return deduplicatedCredentials.filter((credential) => !credentialIdsToRemove.includes(credential.id));
};
export const getBusyCalendarTimes = async (
/**
* withCredentials can possibly have duplicate credential in case DelegationCredential is enabled.
* There is no way to deduplicate that at the moment because a `credential` doesn't directly know to which external_id(or email it is connected to).
* So, there could be multiple credentials for the same user.
* 1. Delegated Credential - that fetches events for john@acme.com
* 2. Regular Credential - that fetches events for john@personal.com
*
*/
withCredentials: CredentialForCalendarService[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[],
mode?: CalendarFetchMode,
includeTimeZone?: boolean
) => {
let results: (EventBusyDate & { timeZone?: string })[][] = [];
const deduplicatedCredentials = deduplicateCredentialsBasedOnSelectedCalendars({
credentials: withCredentials,
selectedCalendars,
});
if (deduplicatedCredentials.length !== withCredentials.length) {
log.info(
"Deduplicated credentials and removed",
withCredentials.length - deduplicatedCredentials.length,
"duplicates. Total number of credentials now is",
deduplicatedCredentials.length
);
}
// 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 {
if (includeTimeZone) {
results = await getCalendarsEventsWithTimezones(
deduplicatedCredentials,
startDate,
endDate,
selectedCalendars
);
} else {
results = await getCalendarsEvents(
deduplicatedCredentials,
startDate,
endDate,
selectedCalendars,
mode ?? "slots"
);
}
} catch (e) {
log.warn(`Error getting calendar availability`, {
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.externalId),
error: safeStringify(e),
});
return { success: false, data: [{ start: startDate, end: endDate, source: "error-placeholder" }] };
}
return { success: true, data: results.reduce((acc, availability) => acc.concat(availability), []) };
};
export const createEvent = async (
credential: CredentialForCalendarService,
originalEvent: CalendarEvent,
externalId?: string
): Promise<EventResult<NewCalendarEventType>> => {
// Some calendar libraries may edit the original event so let's clone it
const formattedEvent = formatCalEvent(originalEvent);
const uid: string = getUid(formattedEvent.uid);
const calendar = await getCalendar(credential, "booking");
let success = true;
let calError: string | undefined;
log.debug(
"Creating calendar event",
safeStringify({
calEvent: getPiiFreeCalendarEvent(formattedEvent),
})
);
// Check if the disabledNotes flag is set to true
if (formattedEvent.hideCalendarNotes) {
formattedEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
}
const calEvent = processEvent(formattedEvent);
const externalCalendarIdWhenDelegationCredentialIsChosen = credential.delegatedToId
? externalId
: undefined;
// TODO: Surface success/error messages coming from apps to improve end user visibility
const creationResult = calendar
? await calendar
// Ideally we should pass externalId always, but let's start with DelegationCredential case first as in that case, CalendarService need to handle a special case for DelegationCredential to determine the selectedCalendar.
// Such logic shouldn't exist in CalendarService as it would be same for all calendar apps.
.createEvent(calEvent, credential.id, externalCalendarIdWhenDelegationCredentialIsChosen)
.catch(async (error: { code: number; calError: string }) => {
success = false;
/**
* There is a time when selectedCalendar externalId doesn't match witch certain credential
* so google returns 404.
* */
if (error?.code === 404) {
return undefined;
}
if (error?.calError) {
calError = error.calError;
}
log.error(
"createEvent failed",
safeStringify(error),
safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
);
// @TODO: This code will be off till we can investigate an error with it
//https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");
return undefined;
})
: undefined;
if (!creationResult) {
logger.error(
"createEvent failed",
safeStringify({
success,
uid,
creationResult,
originalEvent: getPiiFreeCalendarEvent(calEvent),
calError,
})
);
}
log.debug(
"Created calendar event",
safeStringify({
calEvent: getPiiFreeCalendarEvent(calEvent),
creationResult,
})
);
return {
appName: credential.appName || credential.appId || "",
type: credential.type,
success,
uid,
iCalUID: creationResult?.iCalUID || undefined,
createdEvent: creationResult,
originalEvent: calEvent,
calError,
calWarnings: creationResult?.additionalInfo?.calWarnings || [],
externalId,
credentialId: credential.id,
delegatedToId: credential.delegatedToId ?? undefined,
};
};
export const updateEvent = async (
credential: CredentialForCalendarService,
rawCalEvent: CalendarEvent,
bookingRefUid: string | null,
externalCalendarId: string | null
): Promise<EventResult<NewCalendarEventType>> => {
const formattedEvent = formatCalEvent(rawCalEvent);
if (formattedEvent.hideCalendarNotes) {
formattedEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
}
const calEvent = processEvent(formattedEvent);
const uid = getUid(calEvent.uid);
const calendar = await getCalendar(credential, "booking");
let success = false;
let calError: string | undefined;
let calWarnings: string[] | undefined = [];
log.debug(
"Updating calendar event",
safeStringify({
bookingRefUid,
calEvent: getPiiFreeCalendarEvent(calEvent),
})
);
if (bookingRefUid === "") {
log.error(
"updateEvent failed",
"bookingRefUid is empty",
safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
);
}
const updatedResult: NewCalendarEventType | NewCalendarEventType[] | undefined =
calendar && bookingRefUid
? await calendar
.updateEvent(bookingRefUid, calEvent, externalCalendarId)
.then((event: NewCalendarEventType | NewCalendarEventType[]) => {
success = true;
return event;
})
.catch(async (e: { calError: string }) => {
// @TODO: This code will be off till we can investigate an error with it
// @see https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");
log.error(
"updateEvent failed",
safeStringify({ e, calEvent: getPiiFreeCalendarEvent(calEvent) })
);
if (e?.calError) {
calError = e.calError;
}
return undefined;
})
: undefined;
if (!updatedResult) {
logger.error(
"updateEvent failed",
safeStringify({
success,
bookingRefUid,
credential: getPiiFreeCredential(credential),
originalEvent: getPiiFreeCalendarEvent(calEvent),
calError,
})
);
}
if (Array.isArray(updatedResult)) {
calWarnings = updatedResult.flatMap((res) => res.additionalInfo?.calWarnings ?? []);
} else {
calWarnings = updatedResult?.additionalInfo?.calWarnings || [];
}
return {
appName: credential.appName || credential.appId || "",
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
originalEvent: calEvent,
calError,
calWarnings,
};
};
export const deleteEvent = async ({
credential,
bookingRefUid,
event,
externalCalendarId,
}: {
credential: CredentialForCalendarService;
bookingRefUid: string;
event: CalendarEvent;
externalCalendarId?: string | null;
}): Promise<unknown> => {
if (!bookingRefUid) {
log.error(
"deleteEvent failed - bookingRefUid is empty, skipping calendar deletion to prevent malformed API request",
safeStringify({
credential: getPiiFreeCredential(credential),
event: getPiiFreeCalendarEvent(event),
})
);
return Promise.resolve({});
}
const calendar = await getCalendar(credential, "booking");
log.debug(
"Deleting calendar event",
safeStringify({
bookingRefUid,
event: getPiiFreeCalendarEvent(event),
})
);
if (calendar) {
return calendar.deleteEvent(bookingRefUid, event, externalCalendarId);
} else {
log.error(
"Could not do deleteEvent - No calendar adapter found",
safeStringify({
credential: getPiiFreeCredential(credential),
event,
})
);
}
return Promise.resolve({});
};