Files
calendar/packages/features/calendars/lib/getCalendarsEvents.ts
T
Volnei MunhozGitHubVolnei Munhozcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
a62eb2bdac refactor: replace shouldServeCache with mode parameter for calendar cache control (#26539)
* refactor: replace shouldServeCache with mode parameter for calendar cache control

Replace the boolean shouldServeCache parameter with a new CalendarFetchMode type
that can have values 'slots', 'overlay', and 'booking'. This provides better
control over when to serve cache vs relay on calendar providers.

- 'slots' mode: Check feature flags and use cache when available (for getting
  actual calendar availability)
- 'overlay' mode: Don't use cache (for overlay calendar availability)
- 'booking' mode: Don't use cache (for booking confirmation)
- undefined: Same as 'slots' for backwards compatibility

The cache decision logic is now centralized in getCalendar.ts based on the mode
parameter.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: update CalendarService.test.ts to use mode parameter

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: use shared GetAvailabilityParams type across all calendar services

- Import GetAvailabilityParams and GetAvailabilityWithTimeZonesParams from @calcom/types/Calendar
- Replace inline type definitions with shared types in all calendar service implementations
- Update BaseCalendarService, CalendarCacheWrapper, and CalendarTelemetryWrapper
- Ensures consistent typing and follows DRY principles

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add missing IntegrationCalendar import and update mock getAvailability signature

- Add IntegrationCalendar back to sendgrid CalendarService imports
- Update bookingScenario mock getAvailability to use typed params object
- Add listCalendars method to mock Calendar object

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: update test files to use typed params object for getAvailability methods

- Update CalendarCacheWrapper.test.ts to call getAvailability/getAvailabilityWithTimeZones with params object
- Update getCalendarsEvents.test.ts toHaveBeenCalledWith assertions to expect params object
- All 32 tests now pass

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: consolidate to single GetAvailabilityParams type for both getAvailability methods

- Remove GetAvailabilityWithTimeZonesParams, use GetAvailabilityParams for both methods
- Add mode parameter to getAvailabilityWithTimeZones calls
- Update wrapper classes to pass mode through to underlying calendar
- Update test files to include mode in getAvailabilityWithTimeZones calls and assertions

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: use EventBusyDate with optional timeZone for getAvailabilityWithTimeZones

- Add optional timeZone field to EventBusyDate type
- Update getAvailabilityWithTimeZones return type to use EventBusyDate[]
- Update Google Calendar service and wrapper classes to use the new type

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: type errors and remove unused calendar watching methods

- Fix type errors in CalendarCacheWrapper.ts (convert null to undefined for timeZone)
- Fix type errors in getCalendarsEvents.ts (ensure timeZone is always present)
- Remove unused watchCalendar/unwatchCalendar from Calendar interface
- Remove unused startWatchingCalendarsInGoogle/stopWatchingCalendarsInGoogle from GoogleCalendarService
- Remove unused imports (uuid, uniqueBy, GOOGLE_WEBHOOK_URL, ONE_MONTH_IN_MS)

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: remove watchCalendar/unwatchCalendar from CalendarTelemetryWrapper

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: remove verbose JSDoc param comments from wrapper classes

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: make mode parameter required in getCalendar

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add required mode parameter to all getCalendar callers

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: provide default mode value in getBusyCalendarTimes

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add mode parameter to remaining getCalendar callers

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add mode parameter to vital and wipemycalother reschedule

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add mode parameter to credential-sync API endpoint

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: add 'none' mode to CalendarFetchMode and use as default

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* test: add mode parameter to getCalendarsEvents test calls

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Update packages/app-store/delegationCredential.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-08 09:07:41 -03:00

279 lines
11 KiB
TypeScript

import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { getPiiFreeSelectedCalendar, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import type { CalendarFetchMode, EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
// only for Google Calendar for now
export const getCalendarsEventsWithTimezones = async (
withCredentials: CredentialForCalendarService[],
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 calendarAndCredentialPairs = await Promise.all(
calendarCredentials.map(async (credential) => {
const calendar = await getCalendar(credential, "slots");
return [calendar, credential] as const;
})
);
const calendars = calendarAndCredentialPairs.map(([calendar]) => calendar);
const calendarToCredentialMap = new Map(calendarAndCredentialPairs);
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];
const credential = calendarToCredentialMap.get(c);
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = credential
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
: 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));
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
// We want to fallback to primary calendar when no selectedCalendars are passed
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
const allowFallbackToPrimary = isADelegationCredential;
if (!passedSelectedCalendars.length) {
if (!isADelegationCredential) {
// It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected.
// https://github.com/calcom/cal.com/issues/8929
log.error(
`No selected calendars for non DWD credential: Skipping getAvailability call for credential ${credential?.id}`
);
return [];
}
// For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members
// This is also, similar to how Google Calendar connect flow(through /googlecalendar/api/callback) sets the primary calendar as the selected calendar automatically.
log.info("Allowing getAvailability even without any selected calendars for Delegation Credential");
}
/** We extract external Ids so we don't cache too much */
const eventBusyDates =
(await c.getAvailabilityWithTimeZones?.({
dateFrom,
dateTo,
selectedCalendars: passedSelectedCalendars,
mode: "slots",
fallbackToPrimary: allowFallbackToPrimary,
})) || [];
return eventBusyDates.map((event) => ({
...event,
timeZone: event.timeZone || "UTC",
}));
});
const awaitedResults = await Promise.all(results);
return awaitedResults;
};
const getCalendarsEvents = async (
withCredentials: CredentialForCalendarService[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[],
mode: CalendarFetchMode
): Promise<EventBusyDate[][]> => {
const calendarCredentials = withCredentials
.filter((credential) => credential.type.endsWith("_calendar"))
// filter out invalid credentials - these won't work.
.filter((credential) => !credential.invalid);
const calendarAndCredentialPairs = await Promise.all(
calendarCredentials.map(async (credential) => {
const calendar = await getCalendar(credential, mode);
return [calendar, credential] as const;
})
);
const calendars = calendarAndCredentialPairs.map(([calendar]) => calendar);
const calendarToCredentialMap = new Map(calendarAndCredentialPairs);
performance.mark("getBusyCalendarTimesStart");
const results = calendars.map(async (calendarService, i) => {
/** Filter out nulls */
if (!calendarService) return [];
/** We rely on the index so we can match credentials with calendars */
const { type, appId } = calendarCredentials[i];
const credential = calendarToCredentialMap.get(calendarService);
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
// Important to have them unique so that
const passedSelectedCalendars = credential
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
: 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));
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
// We want to fallback to primary calendar when no selectedCalendars are passed
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
const allowFallbackToPrimary = isADelegationCredential;
if (!passedSelectedCalendars.length) {
if (!isADelegationCredential) {
// It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected.
// https://github.com/calcom/cal.com/issues/8929
log.error(
`No selected calendars for non DWD credential: Skipping getAvailability call for credential ${credential?.id}`
);
return [];
}
// For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members
// This is also, similar to how Google Calendar connect flow(through /googlecalendar/api/callback) sets the primary calendar as the selected calendar automatically.
log.info("Allowing getAvailability even without any selected calendars for Delegation Credential");
}
/** We extract external Ids so we don't cache too much */
const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId);
/** If we don't then we actually fetch external calendars (which can be very slow) */
performance.mark("eventBusyDatesStart");
log.debug(
`Getting availability for`,
safeStringify({
calendarService: calendarService.constructor.name,
selectedCalendars: passedSelectedCalendars.map(getPiiFreeSelectedCalendar),
})
);
const eventBusyDates = await calendarService.getAvailability({
dateFrom,
dateTo,
selectedCalendars: passedSelectedCalendars,
mode,
fallbackToPrimary: allowFallbackToPrimary,
});
performance.mark("eventBusyDatesEnd");
performance.measure(
`[getAvailability for ${selectedCalendarIds.join(", ")}][$1]'`,
"eventBusyDatesStart",
"eventBusyDatesEnd"
);
return eventBusyDates.map((a) => ({
...a,
source: `${appId}`,
}));
});
const awaitedResults = await Promise.all(results);
performance.mark("getBusyCalendarTimesEnd");
performance.measure(
`getBusyCalendarTimes took $1 for creds ${calendarCredentials.map((cred) => cred.id)}`,
"getBusyCalendarTimesStart",
"getBusyCalendarTimesEnd"
);
log.debug(
"Result",
safeStringify({
calendarCredentials: calendarCredentials.map(getPiiFreeCredential),
selectedCalendars: selectedCalendars.map(getPiiFreeSelectedCalendar),
calendarEvents: awaitedResults,
})
);
return awaitedResults;
};
export default getCalendarsEvents;
/**
* Extract server URL from CalDAV calendar externalId
*/
function getServerUrlFromCalendarExternalId(externalId: string): string | null {
try {
const url = new URL(externalId);
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
/**
* Extract server URL from CalDAV credential
*/
function getServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
try {
if (credential.type !== "caldav_calendar") {
return null;
}
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
if (!decryptedData.url) {
return null;
}
const url = new URL(decryptedData.url);
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
/**
* Filter selected calendars for the specific credential, handling CalDAV server URL matching
*/
export function filterSelectedCalendarsForCredential(
selectedCalendars: SelectedCalendar[],
credential: CredentialForCalendarService
): SelectedCalendar[] {
const { type } = credential;
// For all other calendar types, use the existing logic
if (type !== "caldav_calendar") {
return selectedCalendars.filter((sc) => sc.integration === type);
}
const credentialServerUrl = getServerUrlFromCredential(credential);
if (!credentialServerUrl) {
log.warn("Could not extract server URL from CalDAV credential", {
credentialId: credential.id,
});
return [];
}
return selectedCalendars.filter((sc) => {
if (sc.integration !== type) {
return false;
}
const calendarServerUrl = getServerUrlFromCalendarExternalId(sc.externalId);
if (!calendarServerUrl) {
log.warn("Could not extract server URL from calendar externalId", {
externalId: sc.externalId,
integration: sc.integration,
});
return false;
}
const matches = credentialServerUrl === calendarServerUrl;
if (!matches) {
log.debug("CalDAV calendar server URL does not match credential server URL", {
credentialId: credential.id,
credentialServerUrl,
calendarServerUrl,
calendarExternalId: sc.externalId,
});
}
return matches;
});
}