From 2eb385af481fd944727c082e4223e65689069407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Tue, 17 Dec 2024 09:54:55 -0700 Subject: [PATCH] feat: allows forcing/skipping calendar cache serving (#18224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allows forcing/skipping calendar cache serving Signed-off-by: Omar López * Update features.repository.ts * Added to HNB * type fixes * Update packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql Co-authored-by: Keith Williams * Update packages/prisma/zod-utils.ts Co-authored-by: Keith Williams * Update selectedCalendar.ts --------- Signed-off-by: Omar López Co-authored-by: Keith Williams --- .../googlecalendar/lib/CalendarService.ts | 70 +++++++++++-------- packages/core/CalendarManager.ts | 57 +++------------ packages/core/getBusyTimes.ts | 5 +- packages/core/getCalendarsEvents.test.ts | 25 +++++-- packages/core/getCalendarsEvents.ts | 10 ++- packages/core/getUserAvailability.ts | 4 ++ .../booking-to-mutation-input-mapper.tsx | 3 + .../features/bookings/lib/handleNewBooking.ts | 15 ++-- .../handleNewBooking/ensureAvailableUsers.ts | 4 +- .../calendar-cache.repository.ts | 13 +--- .../calendar-cache/lib/datesForCache.ts | 16 +++++ .../calendar-cache/lib/getShouldServeCache.ts | 8 +++ packages/features/flags/config.ts | 1 + .../flags/features.repository.interface.ts | 1 + .../flags/features.repository.mock.ts | 3 + .../features/flags/features.repository.ts | 11 +++ packages/features/flags/hooks/index.ts | 1 + .../schedules/lib/use-schedule/useSchedule.ts | 3 + .../lib/server/repository/selectedCalendar.ts | 58 +++++++++++---- .../migration.sql | 9 +++ packages/prisma/zod-utils.ts | 2 + .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 19 +++-- packages/types/Calendar.d.ts | 3 +- 24 files changed, 217 insertions(+), 125 deletions(-) create mode 100644 packages/features/calendar-cache/lib/datesForCache.ts create mode 100644 packages/features/calendar-cache/lib/getShouldServeCache.ts create mode 100644 packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index c4a7b46996..6e43f151dc 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -10,6 +10,7 @@ import { MeetLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface"; +import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import type CalendarService from "@calcom/lib/CalendarService"; import { @@ -510,16 +511,22 @@ export default class GoogleCalendarService implements Calendar { return apiResponse.json; } - async getCacheOrFetchAvailability(args: FreeBusyArgs): Promise { - const { timeMin, timeMax, items } = args; - let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {}; + async getFreeBusyResult( + args: FreeBusyArgs, + shouldServeCache?: boolean + ): Promise { + if (shouldServeCache === false) return await this.fetchAvailability(args); const calendarCache = await CalendarCache.init(null); const cached = await calendarCache.getCachedAvailability(this.credential.id, args); - if (cached) { - freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; - } else { - freeBusyResult = await this.fetchAvailability({ timeMin, timeMax, items }); - } + if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; + return await this.fetchAvailability(args); + } + + async getCacheOrFetchAvailability( + args: FreeBusyArgs, + shouldServeCache?: boolean + ): Promise { + const freeBusyResult = await this.getFreeBusyResult(args, shouldServeCache); if (!freeBusyResult.calendars) return null; const result = Object.values(freeBusyResult.calendars).reduce((c, i) => { @@ -537,7 +544,8 @@ export default class GoogleCalendarService implements Calendar { async getAvailability( dateFrom: string, dateTo: string, - selectedCalendars: IntegrationCalendar[] + selectedCalendars: IntegrationCalendar[], + shouldServeCache?: boolean ): Promise { this.log.debug("Getting availability", safeStringify({ dateFrom, dateTo, selectedCalendars })); const calendar = await this.authedCalendar(); @@ -563,11 +571,14 @@ export default class GoogleCalendarService implements Calendar { // /freebusy from google api only allows a date range of 90 days if (diff <= 90) { - const freeBusyData = await this.getCacheOrFetchAvailability({ - timeMin: dateFrom, - timeMax: dateTo, - items: calsIds.map((id) => ({ id })), - }); + const freeBusyData = await this.getCacheOrFetchAvailability( + { + timeMin: dateFrom, + timeMax: dateTo, + items: calsIds.map((id) => ({ id })), + }, + shouldServeCache + ); if (!freeBusyData) throw new Error("No response from google calendar"); return freeBusyData; @@ -583,11 +594,14 @@ export default class GoogleCalendarService implements Calendar { if (endDate.isAfter(originalEndDate)) endDate = originalEndDate; busyData.push( - ...((await this.getCacheOrFetchAvailability({ - timeMin: startDate.format(), - timeMax: endDate.format(), - items: calsIds.map((id) => ({ id })), - })) || []) + ...((await this.getCacheOrFetchAvailability( + { + timeMin: startDate.format(), + timeMax: endDate.format(), + items: calsIds.map((id) => ({ id })), + }, + shouldServeCache + )) || []) ); startDate = endDate.add(1, "minutes"); @@ -672,12 +686,7 @@ export default class GoogleCalendarService implements Calendar { } async unwatchCalendar({ calendarId }: { calendarId: string }) { const credentialId = this.credential.id; - const sc = await prisma.selectedCalendar.findFirst({ - where: { - credentialId, - externalId: calendarId, - }, - }); + const sc = await SelectedCalendarRepository.findByExternalId(credentialId, calendarId); // Delete the calendar cache to force a fresh cache await prisma.calendarCache.deleteMany({ where: { credentialId } }); const calendar = await this.authedCalendar(); @@ -699,6 +708,12 @@ export default class GoogleCalendarService implements Calendar { googleChannelResourceUri: null, googleChannelExpiration: null, }); + // Populate the cache back for the remaining calendars, if any + const remainingCalendars = + sc?.credential?.selectedCalendars.filter((sc) => sc.externalId !== calendarId) || []; + if (remainingCalendars.length > 0) { + await this.fetchAvailabilityAndSetCache(remainingCalendars); + } } async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise { @@ -707,12 +722,11 @@ export default class GoogleCalendarService implements Calendar { } async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) { - const date = new Date(); const parsedArgs = { /** Expand the start date to the start of the month */ - timeMin: new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0).toISOString(), + timeMin: getTimeMax(), /** Expand the end date to the end of the month */ - timeMax: new Date(date.getFullYear(), date.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(), + timeMax: getTimeMin(), items: selectedCalendars.map((sc) => ({ id: sc.externalId })), }; const data = await this.fetchAvailability(parsedArgs); diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 8aedaaafbf..0db329a22c 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -8,7 +8,6 @@ import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { performance } from "@calcom/lib/server/perfObserver"; import type { CalendarEvent, EventBusyDate, @@ -136,51 +135,6 @@ const cleanIntegrationKeys = ( return rest; }; -// here I will fetch the page json file. -export const getCachedResults = async ( - withCredentials: CredentialPayload[], - dateFrom: string, - dateTo: string, - selectedCalendars: SelectedCalendar[] -): Promise => { - const calendarCredentials = withCredentials.filter((credential) => credential.type.endsWith("_calendar")); - const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential))); - performance.mark("getBusyCalendarTimesStart"); - 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, appId } = calendarCredentials[i]; - /** We just pass the calendars that matched the credential type, - * TODO: Migrate credential type or appId - */ - const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type); - if (!passedSelectedCalendars.length) return []; - /** 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"); - const eventBusyDates = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars); - performance.mark("eventBusyDatesEnd"); - performance.measure( - `[getAvailability for ${selectedCalendarIds.join(", ")}][$1]'`, - "eventBusyDatesStart", - "eventBusyDatesEnd" - ); - - return eventBusyDates.map((a: object) => ({ ...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" - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return awaitedResults as any; -}; - /** * Get months between given dates * @returns ["2023-04", "2024-05"] @@ -203,7 +157,8 @@ export const getBusyCalendarTimes = async ( withCredentials: CredentialPayload[], dateFrom: string, dateTo: string, - selectedCalendars: SelectedCalendar[] + selectedCalendars: SelectedCalendar[], + shouldServeCache?: boolean ) => { let results: EventBusyDate[][] = []; // const months = getMonths(dateFrom, dateTo); @@ -212,7 +167,13 @@ export const getBusyCalendarTimes = async ( 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).endOf("month").add(14, "hours").format(); - results = await getCalendarsEvents(withCredentials, startDate, endDate, selectedCalendars); + results = await getCalendarsEvents( + withCredentials, + startDate, + endDate, + selectedCalendars, + shouldServeCache + ); } catch (e) { log.warn(safeStringify(e)); } diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 8e40ab1a95..f8444f447e 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -44,6 +44,7 @@ export async function getBusyTimes(params: { })[] | null; bypassBusyCalendarTimes: boolean; + shouldServeCache?: boolean; }) { const { credentials, @@ -60,6 +61,7 @@ export async function getBusyTimes(params: { rescheduleUid, duration, bypassBusyCalendarTimes = false, + shouldServeCache, } = params; logger.silly( @@ -243,7 +245,8 @@ export async function getBusyTimes(params: { credentials, startTime, endTime, - selectedCalendars + selectedCalendars, + shouldServeCache ); const endConnectedCalendarsGet = performance.now(); logger.debug( diff --git a/packages/core/getCalendarsEvents.test.ts b/packages/core/getCalendarsEvents.test.ts index 5179baab80..0f14d300b1 100644 --- a/packages/core/getCalendarsEvents.test.ts +++ b/packages/core/getCalendarsEvents.test.ts @@ -127,7 +127,12 @@ describe("getCalendarsEvents", () => { [selectedCalendar] ); - expect(getAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [selectedCalendar]); + expect(getAvailabilitySpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedCalendar], + undefined + ); expect(result).toEqual([ availability.map((av) => ({ ...av, @@ -190,12 +195,18 @@ describe("getCalendarsEvents", () => { [selectedGoogleCalendar, selectedOfficeCalendar] ); - expect(getGoogleAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [ - selectedGoogleCalendar, - ]); - expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [ - selectedOfficeCalendar, - ]); + expect(getGoogleAvailabilitySpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedGoogleCalendar], + undefined + ); + expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedOfficeCalendar], + undefined + ); expect(result).toEqual([ googleAvailability.map((av) => ({ ...av, diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index da641577b8..e98927cfc3 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -11,7 +11,8 @@ const getCalendarsEvents = async ( withCredentials: CredentialPayload[], dateFrom: string, dateTo: string, - selectedCalendars: SelectedCalendar[] + selectedCalendars: SelectedCalendar[], + shouldServeCache?: boolean ): Promise => { const calendarCredentials = withCredentials .filter((credential) => credential.type.endsWith("_calendar")) @@ -45,7 +46,12 @@ const getCalendarsEvents = async ( selectedCalendars: passedSelectedCalendars.map(getPiiFreeSelectedCalendar), }) ); - const eventBusyDates = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars); + const eventBusyDates = await c.getAvailability( + dateFrom, + dateTo, + passedSelectedCalendars, + shouldServeCache + ); performance.mark("eventBusyDatesEnd"); performance.measure( `[getAvailability for ${selectedCalendarIds.join(", ")}][$1]'`, diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index c87bf5e367..c3fc904d36 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -45,6 +45,7 @@ const availabilitySchema = z withSource: z.boolean().optional(), returnDateOverrides: z.boolean(), bypassBusyCalendarTimes: z.boolean().optional(), + shouldServeCache: z.boolean().optional(), }) .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); @@ -245,6 +246,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA duration?: number; returnDateOverrides: boolean; bypassBusyCalendarTimes: boolean; + shouldServeCache?: boolean; }, initialData?: { user?: GetUser; @@ -279,6 +281,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA duration, returnDateOverrides, bypassBusyCalendarTimes = false, + shouldServeCache, } = availabilitySchema.parse(query); if (!dateFrom.isValid() || !dateTo.isValid()) { @@ -393,6 +396,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA duration, currentBookings: initialData?.currentBookings, bypassBusyCalendarTimes, + shouldServeCache, }); const detailedBusyTimes: EventBusyDetails[] = [ diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index 465ca3aae6..df1eddd867 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -53,6 +53,8 @@ export const mapBookingToMutationInput = ({ const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true"; const reroutingFormResponses = searchParams.get("cal.reroutingFormResponses"); const isBookingDryRun = searchParams.get("cal.isBookingDryRun") === "true"; + const _cacheParam = searchParams?.get("cal.cache"); + const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined; return { ...values, @@ -83,6 +85,7 @@ export const mapBookingToMutationInput = ({ // In case of rerouting, the form responses are actually the responses that we need to update. reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined, _isDryRun: isBookingDryRun, + _shouldServeCache, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index e1eaddc916..1de6226ae9 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -32,6 +32,7 @@ import getICalUID from "@calcom/emails/lib/getICalUID"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; +import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; import { allowDisablingAttendeeConfirmationEmails, @@ -393,6 +394,7 @@ async function handler( reroutingFormResponses, routingFormResponseId, _isDryRun: isDryRun = false, + _shouldServeCache, ...reqBody } = bookingData; @@ -414,6 +416,7 @@ async function handler( const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user); if (!eventType) throw new HttpError({ statusCode: 404, message: "event_type_not_found" }); + const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id); const isTeamEventType = !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType); @@ -569,7 +572,8 @@ async function handler( timeZone: reqBody.timeZone, originalRescheduledBooking, }, - loggerWithEventDetails + loggerWithEventDetails, + shouldServeCache ); } } else { @@ -581,7 +585,8 @@ async function handler( timeZone: reqBody.timeZone, originalRescheduledBooking, }, - loggerWithEventDetails + loggerWithEventDetails, + shouldServeCache ); } } @@ -596,7 +601,8 @@ async function handler( timeZone: reqBody.timeZone, originalRescheduledBooking, }, - loggerWithEventDetails + loggerWithEventDetails, + shouldServeCache ); const luckyUserPool: IsFixedAwareUser[] = []; const fixedUserPool: IsFixedAwareUser[] = []; @@ -696,7 +702,8 @@ async function handler( timeZone: reqBody.timeZone, originalRescheduledBooking, }, - loggerWithEventDetails + loggerWithEventDetails, + shouldServeCache ); } // if no error, then lucky user is available for the next slots diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index b1d6a50a7f..63cd5c6196 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -52,7 +52,8 @@ export async function ensureAvailableUsers( users: IsFixedAwareUser[]; }, input: { dateFrom: string; dateTo: string; timeZone: string; originalRescheduledBooking?: BookingType }, - loggerWithEventDetails: Logger + loggerWithEventDetails: Logger, + shouldServeCache?: boolean // ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists ): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> { const availableUsers: IsFixedAwareUser[] = []; @@ -91,6 +92,7 @@ export async function ensureAvailableUsers( beforeEventBuffer: eventType.beforeEventBuffer, afterEventBuffer: eventType.afterEventBuffer, bypassBusyCalendarTimes: false, + shouldServeCache, }, initialData: { eventType, diff --git a/packages/features/calendar-cache/calendar-cache.repository.ts b/packages/features/calendar-cache/calendar-cache.repository.ts index a9c5785388..e6a0418549 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.ts @@ -6,6 +6,7 @@ import type { Calendar } from "@calcom/types/Calendar"; import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface"; import { watchCalendarSchema } from "./calendar-cache.repository.schema"; +import { getTimeMax, getTimeMin } from "./lib/datesForCache"; const log = logger.getSubLogger({ prefix: ["CalendarCacheRepository"] }); @@ -15,18 +16,6 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; const ONE_MONTH_IN_MS = 30 * MS_PER_DAY; const CACHING_TIME = ONE_MONTH_IN_MS; -/** Expand the start date to the start of the month */ -function getTimeMin(timeMin: string) { - const dateMin = new Date(timeMin); - return new Date(dateMin.getFullYear(), dateMin.getMonth(), 1, 0, 0, 0, 0).toISOString(); -} - -/** Expand the end date to the end of the month */ -function getTimeMax(timeMax: string) { - const dateMax = new Date(timeMax); - return new Date(dateMax.getFullYear(), dateMax.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(); -} - export function parseKeyForCache(args: FreeBusyArgs): string { const { timeMin: _timeMin, timeMax: _timeMax, items } = args; const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax); diff --git a/packages/features/calendar-cache/lib/datesForCache.ts b/packages/features/calendar-cache/lib/datesForCache.ts new file mode 100644 index 0000000000..2b90066371 --- /dev/null +++ b/packages/features/calendar-cache/lib/datesForCache.ts @@ -0,0 +1,16 @@ +/** Expand the start date to the beginning of the current month */ +export const getTimeMin = (timeMin?: string) => { + const date = timeMin ? new Date(timeMin) : new Date(); + date.setDate(1); + date.setHours(0, 0, 0, 0); + return date.toISOString(); +}; + +/** Expand the end date to the end of the next month */ +export function getTimeMax(timeMax?: string) { + const date = timeMax ? new Date(timeMax) : new Date(); + date.setMonth(date.getMonth() + 2); + date.setDate(0); + date.setHours(0, 0, 0, 0); + return date.toISOString(); +} diff --git a/packages/features/calendar-cache/lib/getShouldServeCache.ts b/packages/features/calendar-cache/lib/getShouldServeCache.ts new file mode 100644 index 0000000000..d28f77abff --- /dev/null +++ b/packages/features/calendar-cache/lib/getShouldServeCache.ts @@ -0,0 +1,8 @@ +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; + +export async function getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) { + if (typeof shouldServeCache === "boolean") return shouldServeCache; + if (!teamId) return undefined; + const featureRepo = new FeaturesRepository(); + return await featureRepo.checkIfTeamHasFeature(teamId, "calendar-cache-serve"); +} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index e271a6ea5a..a03ca9ed31 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -4,6 +4,7 @@ **/ export type AppFlags = { "calendar-cache": boolean; + "calendar-cache-serve": boolean; emails: boolean; insights: boolean; teams: boolean; diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index f7119884aa..60fa9a7f9d 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -3,4 +3,5 @@ import type { AppFlags } from "./config"; export interface IFeaturesRepository { checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise; checkIfUserHasFeature(userId: number, slug: string): Promise; + checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags): Promise; } diff --git a/packages/features/flags/features.repository.mock.ts b/packages/features/flags/features.repository.mock.ts index f6fa7f0440..9f860d24f3 100644 --- a/packages/features/flags/features.repository.mock.ts +++ b/packages/features/flags/features.repository.mock.ts @@ -4,6 +4,9 @@ export class MockFeaturesRepository implements IFeaturesRepository { async checkIfUserHasFeature(userId: number, slug: string) { return slug === "mock-feature"; } + async checkIfTeamHasFeature(team: number, slug: string) { + return slug === "mock-feature"; + } async checkIfFeatureIsEnabledGlobally() { return true; } diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index aa03d2b571..8fc6b96126 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -65,4 +65,15 @@ export class FeaturesRepository implements IFeaturesRepository { throw err; } } + async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags) { + try { + const teamFeature = await db.teamFeatures.findUnique({ + where: { teamId_featureId: { teamId, featureId } }, + }); + return !!teamFeature; + } catch (err) { + captureException(err); + throw err; + } + } } diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index c32e6b682a..1b248362ef 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -5,6 +5,7 @@ const initialData: AppFlags = { organizations: false, teams: false, "calendar-cache": false, + "calendar-cache-serve": false, emails: false, insights: false, webhooks: false, diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 592995f009..e8fb2e6021 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -48,6 +48,8 @@ export const useSchedule = ({ const searchParams = useSearchParams(); const routedTeamMemberIds = searchParams ? getRoutedTeamMemberIdsFromSearchParams(searchParams) : null; const skipContactOwner = searchParams ? searchParams.get("cal.skipContactOwner") === "true" : false; + const _cacheParam = searchParams?.get("cal.cache"); + const shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined; const input = { isTeamEvent, @@ -68,6 +70,7 @@ export const useSchedule = ({ teamMemberEmail, routedTeamMemberIds, skipContactOwner, + shouldServeCache, }; const options = { diff --git a/packages/lib/server/repository/selectedCalendar.ts b/packages/lib/server/repository/selectedCalendar.ts index e40a4fb087..7c851a0c71 100644 --- a/packages/lib/server/repository/selectedCalendar.ts +++ b/packages/lib/server/repository/selectedCalendar.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@prisma/client"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { prisma } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -59,26 +60,32 @@ export class SelectedCalendarRepository { } /** Retrieve calendars that are being watched but shouldn't be anymore */ static async getNextBatchToUnwatch(limit = 100) { - const nextBatch = await prisma.selectedCalendar.findMany({ - take: limit, - where: { - user: { - teams: { - every: { - team: { - features: { - none: { - featureId: "calendar-cache", - }, + const where: Prisma.SelectedCalendarWhereInput = { + // RN we only support google calendar subscriptions for now + integration: "google_calendar", + googleChannelExpiration: { not: null }, + }; + const featureRepo = new FeaturesRepository(); + const calendarCache = await featureRepo.checkIfFeatureIsEnabledGlobally("calendar-cache"); + // If calendar cache is disabled globally, we skip team features and unwatch all subscriptions + if (!calendarCache) { + where.user = { + teams: { + every: { + team: { + features: { + none: { + featureId: "calendar-cache", }, }, }, }, }, - // RN we only support google calendar subscriptions for now - integration: "google_calendar", - googleChannelExpiration: { not: null }, - }, + }; + } + const nextBatch = await prisma.selectedCalendar.findMany({ + take: limit, + where, }); return nextBatch; } @@ -115,4 +122,25 @@ export class SelectedCalendarRepository { }, }); } + static async findByExternalId(credentialId: number, externalId: string) { + return await prisma.selectedCalendar.findFirst({ + where: { + credentialId, + externalId, + }, + select: { + googleChannelResourceId: true, + googleChannelId: true, + credential: { + select: { + selectedCalendars: { + orderBy: { + externalId: "asc", + }, + }, + }, + }, + }, + }); + } } diff --git a/packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql b/packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql new file mode 100644 index 0000000000..f9ff335987 --- /dev/null +++ b/packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'calendar-cache-serve', + false, + 'Whether to serve calendar cache by default or not on a team/user basis.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 000492f319..85654da270 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -265,6 +265,8 @@ export const bookingCreateBodySchema = z.object({ * Used to identify if the booking is a dry run. */ _isDryRun: z.boolean().optional(), + /** Whether to override the cache */ + _shouldServeCache: z.boolean().optional(), }); export const requiredCustomInputSchema = z.union([ diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 2732064a1f..79e36b064c 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -29,6 +29,7 @@ export const getScheduleSchema = z skipContactOwner: z.boolean().nullish(), _enableTroubleshooter: z.boolean().optional(), _bypassCalendarBusyTimes: z.boolean().optional(), + _shouldServeCache: z.boolean().optional(), }) .transform((val) => { // Need this so we can pass a single username in the query string form public API diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 200162f6db..f0c241d723 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -5,26 +5,27 @@ import { v4 as uuid } from "uuid"; import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability"; import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes"; -import type { CurrentSeats, IFromUser, IToUser, GetAvailabilityUser } from "@calcom/core/getUserAvailability"; +import type { CurrentSeats, GetAvailabilityUser, IFromUser, IToUser } from "@calcom/core/getUserAvailability"; import { getUsersAvailability } from "@calcom/core/getUserAvailability"; import monitorCallbackAsync, { monitorCallbackSync } from "@calcom/core/sentryWrapper"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; +import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { findQualifiedHosts } from "@calcom/lib/bookings/findQualifiedHosts"; import { - getRoutedHostsWithContactOwnerAndFixedHosts, findMatchingHostsWithEventSegment, + getRoutedHostsWithContactOwnerAndFixedHosts, } from "@calcom/lib/bookings/getRoutedUsers"; -import { shouldIgnoreContactOwner, isRerouting } from "@calcom/lib/bookings/routing/utils"; +import { isRerouting, shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants"; import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { - isTimeOutOfBounds, calculatePeriodLimits, + isTimeOutOfBounds, isTimeViolatingFutureLimit, } from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; @@ -33,8 +34,7 @@ import { UserRepository } from "@calcom/lib/server/repository/user"; import getSlots from "@calcom/lib/slots"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; import { PeriodType, Prisma } from "@calcom/prisma/client"; -import { SchedulingType } from "@calcom/prisma/enums"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, rrSegmentQueryValueSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; @@ -430,6 +430,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise>, null>; @@ -1036,6 +1041,7 @@ const calculateHostsAndAvailabilities = async ({ startTime: ReturnType; endTime: Dayjs; bypassBusyCalendarTimes: boolean; + shouldServeCache?: boolean; }) => { const routedTeamMemberIds = input.routedTeamMemberIds ?? null; if ( @@ -1150,6 +1156,7 @@ const calculateHostsAndAvailabilities = async ({ duration: input.duration || 0, returnDateOverrides: false, bypassBusyCalendarTimes, + shouldServeCache, }, initialData: { eventType, diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index a48350bfd3..d2bd0195c7 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -262,7 +262,8 @@ export interface Calendar { getAvailability( dateFrom: string, dateTo: string, - selectedCalendars: IntegrationCalendar[] + selectedCalendars: IntegrationCalendar[], + shouldServeCache?: boolean ): Promise; fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise;