import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; import logger from "@calcom/lib/logger"; import { withSpan } from "@calcom/lib/sentryWrapper"; import type { Calendar, CalendarEvent, CalendarServiceEvent, EventBusyDate, IntegrationCalendar, NewCalendarEventType, SelectedCalendarEventTypeIds, } from "@calcom/types/Calendar"; const log = logger.getSubLogger({ prefix: ["CachedCalendarWrapper"] }); /** * A wrapper to load cache from database and cache it. * * @see Calendar */ export class CalendarCacheWrapper implements Calendar { constructor( private deps: { originalCalendar: Calendar; calendarCacheEventRepository: ICalendarCacheEventRepository; } ) {} getCredentialId?(): number { return this.deps.originalCalendar.getCredentialId ? this.deps.originalCalendar.getCredentialId() : -1; } createEvent( event: CalendarServiceEvent, credentialId: number, externalCalendarId?: string ): Promise { return this.deps.originalCalendar.createEvent(event, credentialId, externalCalendarId); } updateEvent( uid: string, event: CalendarServiceEvent, externalCalendarId?: string | null ): Promise { return this.deps.originalCalendar.updateEvent(uid, event, externalCalendarId); } deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise { return this.deps.originalCalendar.deleteEvent(uid, event, externalCalendarId); } /** * Retrieves availability combining cache and live sources. * * - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache. * - Calendars **without** one of them → fetched directly from the original calendar. * - Results are merged into a single array. * * @param dateFrom - Start date (ISO string) * @param dateTo - End date (ISO string) * @param selectedCalendars - List of calendars to retrieve availability from * @returns Combined array of busy date ranges */ async getAvailability( dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[] ): Promise { return withSpan( { name: "CalendarCacheWrapper.getAvailability", op: "calendar.cache.internal.getAvailability", attributes: { calendarCount: selectedCalendars?.length ?? 0, }, }, async (span) => { log.debug("getAvailability (mixed cache + original)", { dateFrom, dateTo, calendarIds: selectedCalendars.map((c) => c.id), calendarCount: selectedCalendars.length, }); if (!selectedCalendars?.length) return []; const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt); const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt); const results: EventBusyDate[] = []; // Fetch from cache for synced calendars if (withSync.length) { const cacheStartTime = performance.now(); const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id)); const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( ids, new Date(dateFrom), new Date(dateTo) ); const cacheDurationMs = performance.now() - cacheStartTime; results.push(...cached); span.setAttribute("cachedCalendarCount", withSync.length); span.setAttribute("cacheFetchDurationMs", cacheDurationMs); span.setAttribute("cachedEventsCount", cached.length); log.info("Calendar cache fetch completed", { cachedCalendarCount: withSync.length, cacheFetchDurationMs: cacheDurationMs, cachedEventsCount: cached.length, }); } // Fetch from original calendar for unsynced ones if (withoutSync.length) { const originalStartTime = performance.now(); const original = await this.deps.originalCalendar.getAvailability(dateFrom, dateTo, withoutSync); const originalDurationMs = performance.now() - originalStartTime; results.push(...original); span.setAttribute("originalCalendarCount", withoutSync.length); span.setAttribute("originalFetchDurationMs", originalDurationMs); span.setAttribute("originalEventsCount", original.length); log.info("Original calendar fetch completed", { originalCalendarCount: withoutSync.length, originalFetchDurationMs: originalDurationMs, originalEventsCount: original.length, }); } span.setAttribute("cacheUsed", withSync.length > 0); span.setAttribute("totalEventsCount", results.length); return results; } ); } /** * Retrieves availability with time zones, combining cache and live data. * * - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache. * - Calendars **without** one of them → fetched directly from the original calendar. * - Results are merged into a single array with `{ start, end, timeZone }` format. * * @param dateFrom - Start date (ISO string) * @param dateTo - End date (ISO string) * @param selectedCalendars - List of calendars to retrieve availability from * @returns Combined array of time-zone-aware availability ranges */ async getAvailabilityWithTimeZones( dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[] ): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> { return withSpan( { name: "CalendarCacheWrapper.getAvailabilityWithTimeZones", op: "calendar.cache.internal.getAvailabilityWithTimeZones", attributes: { calendarCount: selectedCalendars?.length ?? 0, }, }, async (span) => { log.debug("getAvailabilityWithTimeZones (mixed cache + original)", { dateFrom, dateTo, calendarIds: selectedCalendars.map((c) => c.id), calendarCount: selectedCalendars.length, }); if (!selectedCalendars?.length) return []; const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt); const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt); const results: { start: Date | string; end: Date | string; timeZone: string }[] = []; // Fetch from cache for synced calendars if (withSync.length) { const cacheStartTime = performance.now(); const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id)); const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( ids, new Date(dateFrom), new Date(dateTo) ); const cacheDurationMs = performance.now() - cacheStartTime; results.push( ...cached.map(({ start, end, timeZone }) => ({ start, end, timeZone: timeZone || "UTC", })) ); span.setAttribute("cachedCalendarCount", withSync.length); span.setAttribute("cacheFetchDurationMs", cacheDurationMs); span.setAttribute("cachedEventsCount", cached.length); log.info("Calendar cache fetch with timezones completed", { cachedCalendarCount: withSync.length, cacheFetchDurationMs: cacheDurationMs, cachedEventsCount: cached.length, }); } // Fetch from original calendar for unsynced ones if (withoutSync.length) { const originalStartTime = performance.now(); const original = await this.deps.originalCalendar.getAvailabilityWithTimeZones?.( dateFrom, dateTo, withoutSync ); const originalDurationMs = performance.now() - originalStartTime; if (original?.length) results.push(...original); span.setAttribute("originalCalendarCount", withoutSync.length); span.setAttribute("originalFetchDurationMs", originalDurationMs); span.setAttribute("originalEventsCount", original?.length ?? 0); log.info("Original calendar fetch with timezones completed", { originalCalendarCount: withoutSync.length, originalFetchDurationMs: originalDurationMs, originalEventsCount: original?.length ?? 0, }); } span.setAttribute("cacheUsed", withSync.length > 0); span.setAttribute("totalEventsCount", results.length); return results; } ); } fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise { return this.deps.originalCalendar.fetchAvailabilityAndSetCache?.(selectedCalendars) || Promise.resolve(); } listCalendars(event?: CalendarEvent): Promise { return this.deps.originalCalendar.listCalendars(event); } testDelegationCredentialSetup?(): Promise { return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false); } watchCalendar?(options: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds; }): Promise { return this.deps.originalCalendar.watchCalendar?.(options) || Promise.resolve(); } unwatchCalendar?(options: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds; }): Promise { return this.deps.originalCalendar.unwatchCalendar?.(options) || Promise.resolve(); } }