* 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>
279 lines
11 KiB
TypeScript
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;
|
|
});
|
|
}
|