diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 85c3633228..0e92b59a12 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -1,7 +1,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { EventType } from "@prisma/client"; import { useRouter } from "next/router"; -import { useReducer, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useReducer, useState } from "react"; import { Toaster } from "react-hot-toast"; import { FormattedNumber, IntlProvider } from "react-intl"; import { z } from "zod"; @@ -52,6 +52,7 @@ const useSlots = ({ usernameList, timeZone, duration, + enabled = true, }: { eventTypeId: number; eventTypeSlug: string; @@ -60,6 +61,7 @@ const useSlots = ({ usernameList: string[]; timeZone?: string; duration?: string; + enabled?: boolean; }) => { const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery( { @@ -72,19 +74,14 @@ const useSlots = ({ duration, }, { - enabled: !!startTime && !!endTime, + enabled: !!startTime && !!endTime && enabled, + refetchInterval: 3000, + trpc: { context: { skipBatch: true } }, } ); - const [cachedSlots, setCachedSlots] = useState["slots"]>({}); - - useEffect(() => { - if (data?.slots) { - setCachedSlots((c) => ({ ...c, ...data?.slots })); - } - }, [data]); // The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state. - return { slots: cachedSlots, isLoading: isLoading || isPaused }; + return { slots: data?.slots || {}, isLoading: isLoading || isPaused }; }; const SlotPicker = ({ @@ -146,16 +143,7 @@ const SlotPicker = ({ }, [router.isReady, month, date, duration, timeZone]); const { i18n, isLocaleReady } = useLocale(); - const { slots: _1 } = useSlots({ - eventTypeId: eventType.id, - eventTypeSlug: eventType.slug, - usernameList: users, - startTime: selectedDate?.startOf("day"), - endTime: selectedDate?.endOf("day"), - timeZone, - duration, - }); - const { slots: _2, isLoading } = useSlots({ + const { slots: monthSlots, isLoading } = useSlots({ eventTypeId: eventType.id, eventTypeSlug: eventType.slug, usernameList: users, @@ -167,8 +155,25 @@ const SlotPicker = ({ timeZone, duration, }); + const { slots: selectedDateSlots, isLoading: _isLoadingSelectedDateSlots } = useSlots({ + eventTypeId: eventType.id, + eventTypeSlug: eventType.slug, + usernameList: users, + startTime: selectedDate?.startOf("day"), + endTime: selectedDate?.endOf("day"), + timeZone, + duration, + /** Prevent refetching is we already have this data from month slots */ + enabled: !!selectedDate, + }); - const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]); + /** Hide skeleton if we have the slot loaded in the month query */ + const isLoadingSelectedDateSlots = (() => { + if (!selectedDate) return _isLoadingSelectedDateSlots; + if (!!selectedDateSlots[selectedDate.format("YYYY-MM-DD")]) return false; + if (!!monthSlots[selectedDate.format("YYYY-MM-DD")]) return false; + return false; + })(); return ( <> @@ -178,7 +183,7 @@ const SlotPicker = ({ "mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]", selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4" )} - includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)} + includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)} locale={isLocaleReady ? i18n.language : "en"} selected={selectedDate} onChange={(newDate) => { @@ -193,8 +198,12 @@ const SlotPicker = ({
{ + webpack: (config, { webpack, buildId }) => { config.plugins.push( new CopyWebpackPlugin({ patterns: [ @@ -126,6 +125,8 @@ const nextConfig = { }) ); + config.plugins.push(new webpack.DefinePlugin({ "process.env.BUILD_ID": JSON.stringify(buildId) })); + config.resolve.fallback = { ...config.resolve.fallback, // if you miss it, all the other options in fallback, specified // by next.js will be dropped. Doesn't make much sense, but how it is diff --git a/apps/web/pages/[user]/calendar-cache/[month].tsx b/apps/web/pages/[user]/calendar-cache/[month].tsx new file mode 100644 index 0000000000..40badfe363 --- /dev/null +++ b/apps/web/pages/[user]/calendar-cache/[month].tsx @@ -0,0 +1,52 @@ +/** + * This page is empty for the user, it is used only to take advantage of the + * caching system that NextJS uses SSG pages. + * TODO: Redirect to user profile on browser + */ +import { GetStaticPaths, GetStaticProps } from "next"; +import { z } from "zod"; + +import { getCachedResults } from "@calcom/core"; +import dayjs from "@calcom/dayjs"; +import prisma from "@calcom/prisma"; + +const CalendarCache = () =>
; + +const paramsSchema = z.object({ user: z.string(), month: z.string() }); +export const getStaticProps: GetStaticProps< + { results: Awaited> }, + { user: string } +> = async (context) => { + const { user: username, month } = paramsSchema.parse(context.params); + const user = await prisma.user.findUnique({ + where: { + username, + }, + select: { + id: true, + username: true, + credentials: true, + selectedCalendars: true, + }, + }); + const startDate = ( + dayjs(month, "YYYY-MM").isSame(dayjs(), "month") ? dayjs() : dayjs(month, "YYYY-MM") + ).startOf("day"); + const endDate = startDate.endOf("month"); + const results = user?.credentials + ? await getCachedResults(user?.credentials, startDate.format(), endDate.format(), user?.selectedCalendars) + : []; + return { + props: { results, date: new Date().toISOString() }, + revalidate: 1, + }; +}; + +export const getStaticPaths: GetStaticPaths = () => { + return { + paths: [], + fallback: "blocking", + }; +}; + +export default CalendarCache; diff --git a/apps/web/pages/api/availability/calendar.ts b/apps/web/pages/api/availability/calendar.ts index 87d45f2630..5969073896 100644 --- a/apps/web/pages/api/availability/calendar.ts +++ b/apps/web/pages/api/availability/calendar.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import notEmpty from "@calcom/lib/notEmpty"; +import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache"; import prisma from "@calcom/prisma"; import { getSession } from "@lib/auth"; @@ -65,6 +66,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(200).json({ message: "Calendar Selection Saved" }); } + if (["DELETE", "POST"].includes(req.method)) { + await revalidateCalendarCache(res.revalidate, `${session?.user?.username}`); + } + if (req.method === "GET") { const selectedCalendarIds = await prisma.selectedCalendar.findMany({ where: { diff --git a/apps/web/pages/api/integrations/[...args].ts b/apps/web/pages/api/integrations/[...args].ts index 8e216f89f8..b1598dfd3a 100644 --- a/apps/web/pages/api/integrations/[...args].ts +++ b/apps/web/pages/api/integrations/[...args].ts @@ -4,6 +4,7 @@ import type { Session } from "next-auth"; import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath"; import { getSession } from "@calcom/lib/auth"; import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; +import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache"; import prisma from "@calcom/prisma"; import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler"; @@ -53,7 +54,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { try { /* Absolute path didn't work */ const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers; - const handlerKey = deriveAppDictKeyFromType(appName, handlerMap); const handlers = await handlerMap[handlerKey as keyof typeof handlerMap]; const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler; @@ -63,8 +63,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (typeof handler === "function") { await handler(req, res); + if (appName.includes("calendar") && req.session?.user?.username) { + await revalidateCalendarCache(res.revalidate, req.session?.user?.username); + } } else { await defaultIntegrationAddHandler({ user: req.session?.user, ...handler }); + if (handler.appType.includes("calendar") && req.session?.user?.username) { + await revalidateCalendarCache(res.revalidate, req.session?.user?.username); + } redirectUrl = handler.redirect?.url || getInstalledAppPath(handler); res.json({ url: redirectUrl, newTab: handler.redirect?.newTab }); } diff --git a/apps/web/pages/api/revalidate-calendar-cache/[username].ts b/apps/web/pages/api/revalidate-calendar-cache/[username].ts new file mode 100644 index 0000000000..1c37729466 --- /dev/null +++ b/apps/web/pages/api/revalidate-calendar-cache/[username].ts @@ -0,0 +1,27 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache"; + +const querySchema = z.object({ + username: z.string(), +}); + +/** + * This endpoint revalidates users calendar cache several months ahead + * Can be used as webhook + * @param req + * @param res + * @returns + */ + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { username } = querySchema.parse(req.query); + try { + await revalidateCalendarCache(res.revalidate, username); + + return res.status(200).json({ revalidated: true }); + } catch (err) { + return res.status(500).send({ message: "Error revalidating" }); + } +} diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index eca9ea8850..173bca9ee1 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -1,11 +1,12 @@ import { SelectedCalendar } from "@prisma/client"; -import { createHash } from "crypto"; import _ from "lodash"; -import cache from "memory-cache"; +import * as process from "process"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import getApps from "@calcom/app-store/utils"; +import dayjs from "@calcom/dayjs"; import { getUid } from "@calcom/lib/CalEventParser"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; import type { CalendarEvent, EventBusyDate, NewCalendarEventType } from "@calcom/types/Calendar"; @@ -113,40 +114,27 @@ const cleanIntegrationKeys = ( return rest; }; -const CACHING_TIME = 30_000; // 30 seconds - -const getCachedResults = async ( +// 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 = 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 { id, type, appId } = calendarCredentials[i]; + 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); /** We extract external Ids so we don't cache too much */ const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId); - /** We create a unique hash key based on the input data */ - const cacheKey = JSON.stringify({ id, selectedCalendarIds, dateFrom, dateTo }); - const cacheHashedKey = createHash("md5").update(cacheKey).digest("hex"); - /** Check if we already have cached data and return */ - const cachedAvailability = cache.get(cacheHashedKey); - - if (cachedAvailability) { - log.debug(`Cache HIT: Calendar Availability for key: ${cacheKey}`); - return cachedAvailability; - } - log.debug(`Cache MISS: Calendar Availability for key ${cacheKey}`); /** 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); @@ -156,12 +144,8 @@ const getCachedResults = async ( "eventBusyDatesStart", "eventBusyDatesEnd" ); - const availability = eventBusyDates.map((a) => ({ ...a, source: `${appId}` })); - /** We save the availability to a few seconds so recurrent calls are nearly instant */ - - cache.put(cacheHashedKey, availability, CACHING_TIME); - return availability; + return eventBusyDates.map((a) => ({ ...a, source: `${appId}` })); }); const awaitedResults = await Promise.all(results); performance.mark("getBusyCalendarTimesEnd"); @@ -173,17 +157,49 @@ const getCachedResults = async ( return awaitedResults; }; +/** + * This function fetch the json file that NextJS generates and uses to hydrate the static page on browser. + * If for some reason NextJS still doesn't generate this file, it will wait until it finishes generating it. + * On development environment it takes a long time because Next must compiles the whole page. + * @param username + * @param month A string representing year and month using YYYY-MM format + */ +const getNextCache = async (username: string, month: string): Promise => { + let localCache: EventBusyDate[][] = []; + try { + const { NODE_ENV } = process.env; + const cacheDir = `${NODE_ENV === "development" ? NODE_ENV : process.env.BUILD_ID}`; + const baseUrl = `${WEBAPP_URL}/_next/data/${cacheDir}/en`; + console.log(`${baseUrl}/${username}/calendar-cache/${month}.json?user=${username}&month=${month}`); + localCache = await fetch( + `${baseUrl}/${username}/calendar-cache/${month}.json?user=${username}&month=${month}` + ) + .then((r) => r.json()) + .then((json) => json?.pageProps?.results); + } catch (e) { + log.warn(e); + } + return localCache; +}; + export const getBusyCalendarTimes = async ( + username: string, withCredentials: CredentialPayload[], dateFrom: string, - dateTo: string, - selectedCalendars: SelectedCalendar[] + dateTo: string ) => { let results: EventBusyDate[][] = []; - try { - results = await getCachedResults(withCredentials, dateFrom, dateTo, selectedCalendars); - } catch (error) { - log.warn(error); + if (dayjs(dateFrom).isSame(dayjs(dateTo), "month")) { + results = await getNextCache(username, dayjs(dateFrom).format("YYYY-MM")); + } else { + // if dateFrom and dateTo is from different months get cache by each month + const monthsOfDiff = dayjs(dateTo).diff(dayjs(dateFrom), "month"); + const months: string[] = [dayjs(dateFrom).format("YYYY-MM")]; + for (let i = 1; i <= monthsOfDiff; i++) { + months.push(dayjs(dateFrom).add(i, "month").format("YYYY-MM")); + } + const data: EventBusyDate[][][] = await Promise.all(months.map((month) => getNextCache(username, month))); + results = data.flat(1); } return results.reduce((acc, availability) => acc.concat(availability), []); }; diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index a1f5dfc19b..cd176aa6a3 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -10,20 +10,20 @@ import type { EventBusyDetails } from "@calcom/types/Calendar"; export async function getBusyTimes(params: { credentials: Credential[]; userId: number; + username: string; eventTypeId?: number; startTime: string; beforeEventBuffer?: number; afterEventBuffer?: number; endTime: string; - selectedCalendars: SelectedCalendar[]; }) { const { credentials, userId, + username, eventTypeId, startTime, endTime, - selectedCalendars, beforeEventBuffer, afterEventBuffer, } = params; @@ -129,7 +129,7 @@ export async function getBusyTimes(params: { performance.mark("prismaBookingGetEnd"); performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd"); if (credentials?.length > 0) { - const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars); + const calendarBusyTimes = await getBusyCalendarTimes(username, credentials, startTime, endTime); busyTimes.push( ...calendarBusyTimes.map((value) => ({ ...value, diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 79b1fd400a..1006bb8213 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -121,8 +121,7 @@ export async function getUserAvailability( if (username) where.username = username; if (userId) where.id = userId; - let user: User | null = initialData?.user || null; - if (!user) user = await getUser(where); + const user = initialData?.user || (await getUser(where)); if (!user) throw new HttpError({ statusCode: 404, message: "No user found" }); let eventType: EventType | null = initialData?.eventType || null; @@ -137,15 +136,13 @@ export async function getUserAvailability( const bookingLimits = parseBookingLimit(eventType?.bookingLimits); - const { selectedCalendars, ...currentUser } = user; - const busyTimes = await getBusyTimes({ - credentials: currentUser.credentials, + credentials: user.credentials, startTime: dateFrom.toISOString(), endTime: dateTo.toISOString(), eventTypeId, - userId: currentUser.id, - selectedCalendars, + userId: user.id, + username: `${user.username}`, beforeEventBuffer, afterEventBuffer, }); @@ -222,8 +219,8 @@ export async function getUserAvailability( } } - const userSchedule = currentUser.schedules.filter( - (schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId + const userSchedule = user.schedules.filter( + (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId )[0]; const schedule = @@ -233,17 +230,16 @@ export async function getUserAvailability( ...userSchedule, availability: userSchedule?.availability.map((a) => ({ ...a, - userId: currentUser.id, + userId: user.id, })), }; const startGetWorkingHours = performance.now(); - const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone; + const timeZone = schedule.timeZone || eventType?.timeZone || user.timeZone; const availability = - schedule.availability || - (eventType?.availability.length ? eventType.availability : currentUser.availability); + schedule.availability || (eventType?.availability.length ? eventType.availability : user.availability); const workingHours = getWorkingHours({ timeZone }, availability); diff --git a/packages/lib/server/revalidateCalendarCache.ts b/packages/lib/server/revalidateCalendarCache.ts new file mode 100644 index 0000000000..ce580f2d7f --- /dev/null +++ b/packages/lib/server/revalidateCalendarCache.ts @@ -0,0 +1,18 @@ +import { NextApiResponse } from "next"; + +import dayjs from "@calcom/dayjs"; + +export const revalidateCalendarCache = ( + revalidate: NextApiResponse["revalidate"], + username: string, + monthsToRevalidate = 4 +): Promise => { + return Promise.all( + new Array(monthsToRevalidate).fill(0).map((_, index): Promise => { + const date = dayjs().add(index, "month").format("YYYY-MM"); + const url = `/${username}/calendar-cache/${date}`; + console.log("revalidating", url); + return revalidate(url); + }) + ); +}; diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 8a1bd58ee7..c71138daa9 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -1131,6 +1131,11 @@ const loggedInViewerRouter = router({ id: id, }, }); + // Revalidate user calendar cache. + if (credential.app?.slug.includes("calendar")) { + const baseURL = process.env.VERCEL_URL || process.env.NEXT_PUBLIC_WEBAPP_URL; + await fetch(`${baseURL}/api/revalidate-calendar-cache/${ctx?.user?.username}`); + } }), bookingUnconfirmedCount: authedProcedure.query(async ({ ctx }) => { const { prisma, user } = ctx; diff --git a/turbo.json b/turbo.json index 6c397ea6db..2c951ac9ab 100644 --- a/turbo.json +++ b/turbo.json @@ -239,6 +239,7 @@ "$NEXTAUTH_SECRET", "$NEXTAUTH_URL", "$NODE_ENV", + "$BUILD_ID", "$PLAYWRIGHT_HEADLESS", "$PLAYWRIGHT_TEST_BASE_URL", "$PRISMA_FIELD_ENCRYPTION_KEY",