From 6272c881c8d7c91ea57cd1808a56e2edd9ef9c1a Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Fri, 15 Nov 2024 19:28:30 -0300 Subject: [PATCH] revert: "feat: Populate gCal calendar cache via webhooks (#11928)" (#17675) This reverts commit d294a74aada589d1e23abb43965426c99811dca2. --- .env.example | 4 - apps/web/app/api/calendar-cache/cron/route.ts | 7 - apps/web/cron-tester.ts | 7 +- apps/web/pages/api/availability/calendar.ts | 156 +++++++------- .../pages/api/cron/calendar-cache-cleanup.ts | 6 - apps/web/vercel.json | 4 - .../app-store/googlecalendar/api/callback.ts | 2 +- .../app-store/googlecalendar/api/index.ts | 1 - .../app-store/googlecalendar/api/webhook.ts | 41 ---- .../lib/CalendarService.test.ts | 108 +++------- .../googlecalendar/lib/CalendarService.ts | 201 ++++++++---------- .../lib/__mocks__/googleapis.ts | 17 +- .../app-store/tests/__mocks__/OAuthManager.ts | 4 +- packages/core/CalendarManager.ts | 2 +- packages/core/getCalendarsEvents.ts | 9 +- packages/features/calendar-cache/api/cron.ts | 62 ------ .../calendar-cache.repository.interface.ts | 14 -- .../calendar-cache.repository.mock.ts | 22 -- .../calendar-cache.repository.schema.ts | 9 - .../calendar-cache.repository.ts | 124 ----------- .../features/calendar-cache/calendar-cache.ts | 28 --- .../features/calendars/CalendarSwitch.tsx | 4 +- .../flags/features.repository.interface.ts | 3 - .../features/flags/features.repository.ts | 10 - packages/features/flags/hooks/index.ts | 23 +- packages/features/flags/server/utils.ts | 10 +- .../lib/server/defaultResponder.appDir.ts | 29 --- packages/lib/server/repository/google.ts | 10 - .../lib/server/repository/selectedCalendar.ts | 107 ---------- packages/lib/server/repository/user.ts | 16 -- .../migration.sql | 15 -- packages/prisma/schema.prisma | 18 +- .../sql/getSelectedCalendarsToWatch.sql | 24 --- .../server/routers/viewer/admin/_router.ts | 16 +- .../viewer/admin/toggleFeatureFlag.handler.ts | 36 ---- .../admin/toggleFeatureFlag.procedure.ts | 13 -- .../viewer/admin/toggleFeatureFlag.schema.ts | 8 - packages/types/Calendar.d.ts | 20 +- turbo.json | 2 - 39 files changed, 240 insertions(+), 952 deletions(-) delete mode 100644 apps/web/app/api/calendar-cache/cron/route.ts delete mode 100644 packages/app-store/googlecalendar/api/webhook.ts delete mode 100644 packages/features/calendar-cache/api/cron.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.interface.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.mock.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.schema.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.ts delete mode 100644 packages/lib/server/defaultResponder.appDir.ts delete mode 100644 packages/prisma/migrations/20240207222843_add_google_watched_calendars/migration.sql delete mode 100644 packages/prisma/sql/getSelectedCalendarsToWatch.sql delete mode 100644 packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts delete mode 100644 packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.procedure.ts delete mode 100644 packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.schema.ts diff --git a/.env.example b/.env.example index 90f60adcd7..a59043e802 100644 --- a/.env.example +++ b/.env.example @@ -129,10 +129,6 @@ GOOGLE_LOGIN_ENABLED=false # Needed to enable Google Calendar integration and Login with Google # @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials GOOGLE_API_CREDENTIALS= -# Token to verify incoming webhooks from Google Calendar -GOOGLE_WEBHOOK_TOKEN= -# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. -GOOGLE_WEBHOOK_URL= # Inbox to send user feedback SEND_FEEDBACK_EMAIL= diff --git a/apps/web/app/api/calendar-cache/cron/route.ts b/apps/web/app/api/calendar-cache/cron/route.ts deleted file mode 100644 index 1cc51cca79..0000000000 --- a/apps/web/app/api/calendar-cache/cron/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { GET } from "@calcom/features/calendar-cache/api/cron"; - -/** - * This runs each minute and we need fresh data each time - * @see https://nextjs.org/docs/app/building-your-application/caching#opting-out-2 - **/ -export const revalidate = 0; diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts index a155ed9555..4e548f4778 100644 --- a/apps/web/cron-tester.ts +++ b/apps/web/cron-tester.ts @@ -1,12 +1,9 @@ import { CronJob } from "cron"; -import dotEnv from "dotenv"; - -dotEnv.config({ path: "../../.env" }); async function fetchCron(endpoint: string) { const apiKey = process.env.CRON_API_KEY; - const res = await fetch(`http://localhost:3000/api${endpoint}?apiKey=${apiKey}`, { + const res = await fetch(`http://localhost:3000/api${endpoint}?${apiKey}`, { headers: { "Content-Type": "application/json", authorization: `Bearer ${process.env.CRON_SECRET}`, @@ -23,7 +20,7 @@ try { "*/5 * * * * *", async function () { await Promise.allSettled([ - fetchCron("/calendar-cache/cron"), + fetchCron("/tasks/cron"), // fetchCron("/cron/calVideoNoShowWebhookTriggers"), // // fetchCron("/tasks/cleanup"), diff --git a/apps/web/pages/api/availability/calendar.ts b/apps/web/pages/api/availability/calendar.ts index dee715c7f6..ce084da3cc 100644 --- a/apps/web/pages/api/availability/calendar.ts +++ b/apps/web/pages/api/availability/calendar.ts @@ -3,92 +3,98 @@ import { z } from "zod"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; -import { HttpError } from "@calcom/lib/http-error"; import notEmpty from "@calcom/lib/notEmpty"; -import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; -import { UserRepository } from "@calcom/lib/server/repository/user"; +import prisma from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; const selectedCalendarSelectSchema = z.object({ integration: z.string(), externalId: z.string(), - credentialId: z.coerce.number(), + credentialId: z.number().optional(), }); -/** Shared authentication middleware for GET, DELETE and POST requests */ -async function authMiddleware(req: CustomNextApiRequest) { - const session = await getServerSession({ req }); +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getServerSession({ req, res }); if (!session?.user?.id) { - throw new HttpError({ statusCode: 401, message: "Not authenticated" }); + res.status(401).json({ message: "Not authenticated" }); + return; } - const userWithCredentials = await UserRepository.findUserWithCredentials({ id: session.user.id }); - + const userWithCredentials = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + select: { + credentials: { + select: credentialForCalendarServiceSelect, + }, + timeZone: true, + id: true, + selectedCalendars: true, + }, + }); if (!userWithCredentials) { - throw new HttpError({ statusCode: 401, message: "Not authenticated" }); + res.status(401).json({ message: "Not authenticated" }); + return; + } + const { credentials, ...user } = userWithCredentials; + + if (req.method === "POST") { + const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body); + await prisma.selectedCalendar.upsert({ + where: { + userId_integration_externalId: { + userId: user.id, + integration, + externalId, + }, + }, + create: { + userId: user.id, + integration, + externalId, + credentialId, + }, + // already exists + update: {}, + }); + res.status(200).json({ message: "Calendar Selection Saved" }); + } + + if (req.method === "DELETE") { + const { integration, externalId } = selectedCalendarSelectSchema.parse(req.query); + await prisma.selectedCalendar.delete({ + where: { + userId_integration_externalId: { + userId: user.id, + externalId, + integration, + }, + }, + }); + + res.status(200).json({ message: "Calendar Selection Saved" }); + } + + if (req.method === "GET") { + const selectedCalendarIds = await prisma.selectedCalendar.findMany({ + where: { + userId: user.id, + }, + select: { + externalId: true, + }, + }); + + // get user's credentials + their connected integrations + const calendarCredentials = getCalendarCredentials(credentials); + // get all the connected integrations' calendars (from third party) + const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); + const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty); + const selectableCalendars = calendars.map((cal) => { + return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal }; + }); + res.status(200).json(selectableCalendars); } - req.userWithCredentials = userWithCredentials; - return userWithCredentials; } - -type CustomNextApiRequest = NextApiRequest & { - userWithCredentials?: Awaited>; -}; - -async function postHandler(req: CustomNextApiRequest) { - if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" }); - const user = req.userWithCredentials; - const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body); - await SelectedCalendarRepository.upsert({ - userId: user.id, - integration, - externalId, - credentialId, - }); - - return { message: "Calendar Selection Saved" }; -} - -async function deleteHandler(req: CustomNextApiRequest) { - if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" }); - const user = req.userWithCredentials; - const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.query); - const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId); - await calendarCacheRepository.unwatchCalendar({ calendarId: externalId }); - await SelectedCalendarRepository.delete({ - userId: user.id, - externalId, - integration, - }); - - return { message: "Calendar Selection Saved" }; -} - -async function getHandler(req: CustomNextApiRequest) { - if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" }); - const user = req.userWithCredentials; - const selectedCalendarIds = await SelectedCalendarRepository.findMany({ - where: { userId: user.id }, - select: { externalId: true }, - }); - // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(user.credentials); - // get all the connected integrations' calendars (from third party) - const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); - const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty); - const selectableCalendars = calendars.map((cal) => { - return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal }; - }); - return selectableCalendars; -} - -export default defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { - await authMiddleware(req); - return defaultHandler({ - GET: Promise.resolve({ default: defaultResponder(getHandler) }), - POST: Promise.resolve({ default: defaultResponder(postHandler) }), - DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }), - })(req, res); -}); diff --git a/apps/web/pages/api/cron/calendar-cache-cleanup.ts b/apps/web/pages/api/cron/calendar-cache-cleanup.ts index 63d49c458e..8959de0d11 100644 --- a/apps/web/pages/api/cron/calendar-cache-cleanup.ts +++ b/apps/web/pages/api/cron/calendar-cache-cleanup.ts @@ -3,12 +3,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import prisma from "@calcom/prisma"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const apiKey = req.headers.authorization || req.query.apiKey; - if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { - res.status(401).json({ message: "Not authenticated" }); - return; - } - const deleted = await prisma.calendarCache.deleteMany({ where: { // Delete all cache entries that expired before now diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 53551d4b5d..6e44699ba3 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -8,10 +8,6 @@ "path": "/api/tasks/cron", "schedule": "* * * * *" }, - { - "path": "/api/calendar-cache/cron", - "schedule": "* * * * *" - }, { "path": "/api/tasks/cleanup", "schedule": "0 0 * * *" diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index ef7a514c62..8e7754599a 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -105,7 +105,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { // Wrapping in a try/catch to reduce chance of race conditions- // also this improves performance for most of the happy-paths. try { - await GoogleRepository.upsertSelectedCalendar({ + await GoogleRepository.createSelectedCalendar({ credentialId: gcalCredential.id, externalId: selectedCalendarWhereUnique.externalId, userId: selectedCalendarWhereUnique.userId, diff --git a/packages/app-store/googlecalendar/api/index.ts b/packages/app-store/googlecalendar/api/index.ts index 567bbf7979..eb12c1b4ed 100644 --- a/packages/app-store/googlecalendar/api/index.ts +++ b/packages/app-store/googlecalendar/api/index.ts @@ -1,3 +1,2 @@ export { default as add } from "./add"; export { default as callback } from "./callback"; -export { default as webhook } from "./webhook"; diff --git a/packages/app-store/googlecalendar/api/webhook.ts b/packages/app-store/googlecalendar/api/webhook.ts deleted file mode 100644 index 6d4ab4903f..0000000000 --- a/packages/app-store/googlecalendar/api/webhook.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; - -import { getCalendar } from "../../_utils/getCalendar"; - -async function postHandler(req: NextApiRequest) { - if (req.headers["x-goog-channel-token"] !== process.env.GOOGLE_WEBHOOK_TOKEN) { - throw new HttpError({ statusCode: 403, message: "Invalid API key" }); - } - if (typeof req.headers["x-goog-channel-id"] !== "string") { - throw new HttpError({ statusCode: 403, message: "Missing Channel ID" }); - } - - const selectedCalendar = await SelectedCalendarRepository.findByGoogleChannelId( - req.headers["x-goog-channel-id"] - ); - - if (!selectedCalendar) { - throw new HttpError({ - statusCode: 200, - message: `No selected calendar found for googleChannelId: ${req.headers["x-goog-channel-id"]}`, - }); - } - const { credential } = selectedCalendar; - if (!credential) - throw new HttpError({ - statusCode: 200, - message: `No credential found for selected calendar for googleChannelId: ${req.headers["x-goog-channel-id"]}`, - }); - const { selectedCalendars } = credential; - const calendar = await getCalendar(credential); - await calendar?.fetchAvailabilityAndSetCache?.(selectedCalendars); - return { message: "ok" }; -} - -export default defaultHandler({ - POST: Promise.resolve({ default: defaultResponder(postHandler) }), -}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index dab13bb5be..595ceed342 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -5,11 +5,7 @@ import { googleapisMock, setCredentialsMock } from "./__mocks__/googleapis"; import { expect, test, vi } from "vitest"; import "vitest-fetch-mock"; -import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; - -import CalendarService from "./CalendarService"; - -vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token"); +import CalendarService, { getTimeMax, getTimeMin } from "./CalendarService"; vi.mock("@calcom/features/flags/server/utils", () => ({ getFeatureFlag: vi.fn().mockReturnValue(true), @@ -36,7 +32,7 @@ const getSampleCredential = () => { return { invalid: false, key: googleTestCredentialKey, - type: "google_calendar", + type: "test", }; }; @@ -46,22 +42,21 @@ const testSelectedCalendar = { externalId: "example@cal.com", }; -test("Calendar Cache is being read on cache HIT", async () => { +test("Calendar Cache is being read and updated", async () => { const credentialInDb1 = await createCredentialInDb(); const dateFrom1 = new Date().toISOString(); const dateTo1 = new Date().toISOString(); // Create cache - const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability( - credentialInDb1.id, - { - timeMin: dateFrom1, - timeMax: dateTo1, - items: [{ id: testSelectedCalendar.externalId }], - }, - JSON.parse( - JSON.stringify({ + await prismock.calendarCache.create({ + data: { + credentialId: credentialInDb1.id, + key: JSON.stringify({ + timeMin: getTimeMin(dateFrom1), + timeMax: getTimeMax(dateTo1), + items: [{ id: testSelectedCalendar.externalId }], + }), + value: { calendars: [ { busy: [ @@ -72,9 +67,10 @@ test("Calendar Cache is being read on cache HIT", async () => { ], }, ], - }) - ) - ); + }, + expiresAt: String(Date.now() + 10000), + }, + }); oAuthManagerMock.OAuthManager = defaultMockOAuthManager; const calendarService = new CalendarService(credentialInDb1); @@ -87,73 +83,29 @@ test("Calendar Cache is being read on cache HIT", async () => { end: "2023-12-01T19:00:00Z", }, ]); -}); -test("Calendar Cache is being ignored on cache MISS", async () => { - const calendarCache = await CalendarCache.init(null); - const credentialInDb = await createCredentialInDb(); - const dateFrom = new Date(Date.now()).toISOString(); + const credentialInDb2 = await createCredentialInDb(); + const dateFrom2 = new Date(Date.now()).toISOString(); // Tweak date so that it's a cache miss - const dateTo = new Date(Date.now() + 100000000).toISOString(); - const calendarService = new CalendarService(credentialInDb); + const dateTo2 = new Date(Date.now() + 100000000).toISOString(); + const calendarService2 = new CalendarService(credentialInDb2); // Test Cache Miss - await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar]); + await calendarService2.getAvailability(dateFrom2, dateTo2, [testSelectedCalendar]); - // Expect cache to be ignored in case of a MISS - const cachedAvailability = await calendarCache.getCachedAvailability(credentialInDb.id, { - timeMin: dateFrom, - timeMax: dateTo, - items: [{ id: testSelectedCalendar.externalId }], - }); - - expect(cachedAvailability).toBeNull(); -}); - -test("Calendar can be watched and unwatched", async () => { - const credentialInDb1 = await createCredentialInDb(); - oAuthManagerMock.OAuthManager = defaultMockOAuthManager; - const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); - await calendarCache.watchCalendar({ calendarId: testSelectedCalendar.externalId }); - const watchedCalendar = await prismock.selectedCalendar.findFirst({ + // Expect cache to be updated in case of a MISS + const calendarCache = await prismock.calendarCache.findFirst({ where: { - userId: credentialInDb1.userId!, - externalId: testSelectedCalendar.externalId, - integration: "google_calendar", - }, - }); - expect(watchedCalendar).toEqual({ - userId: 1, - integration: "google_calendar", - externalId: "example@cal.com", - credentialId: 1, - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - await calendarCache.unwatchCalendar({ calendarId: testSelectedCalendar.externalId }); - // There's a bug in prismock where upsert creates duplicate records so we need to acces the second element - const [, unWatchedCalendar] = await prismock.selectedCalendar.findMany({ - where: { - userId: credentialInDb1.userId!, - externalId: testSelectedCalendar.externalId, - integration: "google_calendar", + credentialId: credentialInDb2.id, + key: JSON.stringify({ + timeMin: getTimeMin(dateFrom2), + timeMax: getTimeMax(dateTo2), + items: [{ id: testSelectedCalendar.externalId }], + }), }, }); - expect(unWatchedCalendar).toEqual({ - userId: 1, - integration: "google_calendar", - externalId: "example@cal.com", - credentialId: 1, - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - }); + expect(calendarCache?.value).toEqual({ calendars: [] }); }); test("`updateTokenObject` should update credential in DB as well as myGoogleAuth", async () => { diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index e5e328f957..3cab66b0a9 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -3,12 +3,10 @@ import type { Prisma } from "@prisma/client"; import type { calendar_v3 } from "googleapis"; import { google } from "googleapis"; import { RRule } from "rrule"; -import { v4 as uuid } from "uuid"; 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 { getFeatureFlag } from "@calcom/features/flags/server/utils"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import type CalendarService from "@calcom/lib/CalendarService"; import { @@ -21,7 +19,6 @@ import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import { getAllCalendars } from "@calcom/lib/google"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { GoogleRepository } from "@calcom/lib/server/repository/google"; import prisma from "@calcom/prisma"; import type { Calendar, @@ -42,16 +39,39 @@ import { metadata } from "../_metadata"; import { getGoogleAppKeys } from "./getGoogleAppKeys"; const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] }); - interface GoogleCalError extends Error { code?: number; } -const MS_PER_DAY = 24 * 60 * 60 * 1000; -const ONE_MONTH_IN_MS = 30 * MS_PER_DAY; -// eslint-disable-next-line turbo/no-undeclared-env-vars -- GOOGLE_WEBHOOK_URL only for local testing -const GOOGLE_WEBHOOK_URL_BASE = process.env.GOOGLE_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL; -const GOOGLE_WEBHOOK_URL = `${GOOGLE_WEBHOOK_URL_BASE}/api/integrations/googlecalendar/webhook`; +const ONE_MINUTE_MS = 60 * 1000; +const CACHING_TIME = ONE_MINUTE_MS; + +/** Expand the start date to the start of the month */ +export 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 */ +export function getTimeMax(timeMax: string) { + const dateMax = new Date(timeMax); + return new Date(dateMax.getFullYear(), dateMax.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(); +} + +/** + * Enable or disable the expanded cache + * TODO: Make this configurable + * */ +const ENABLE_EXPANDED_CACHE = true; + +/** + * By expanding the cache to whole months, we can save round trips to the third party APIs. + * In this case we already have the data in the database, so we can just return it. + */ +function handleMinMax(min: string, max: string) { + if (!ENABLE_EXPANDED_CACHE) return { timeMin: min, timeMax: max }; + return { timeMin: getTimeMin(min), timeMax: getTimeMax(max) }; +} export default class GoogleCalendarService implements Calendar { private integrationName = ""; @@ -495,23 +515,70 @@ export default class GoogleCalendarService implements Calendar { } } - async fetchAvailability(requestBody: FreeBusyArgs): Promise { + async getCacheOrFetchAvailability(args: { + timeMin: string; + timeMax: string; + items: { id: string }[]; + }): Promise { const calendar = await this.authedCalendar(); - const apiResponse = await this.oAuthManagerInstance.request( - async () => new AxiosLikeResponseToFetchResponse(await calendar.freebusy.query({ requestBody })) - ); - return apiResponse.json; - } - - async getCacheOrFetchAvailability(args: FreeBusyArgs): Promise { - const { timeMin, timeMax, items } = args; + const calendarCacheEnabled = await getFeatureFlag(prisma, "calendar-cache"); let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {}; - 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; + if (!calendarCacheEnabled) { + const { timeMin, timeMax, items } = args; + ({ json: freeBusyResult } = await this.oAuthManagerInstance.request( + async () => + new AxiosLikeResponseToFetchResponse( + await calendar.freebusy.query({ + requestBody: { timeMin, timeMax, items }, + }) + ) + )); } else { - freeBusyResult = await this.fetchAvailability({ timeMin, timeMax, items }); + const { timeMin: _timeMin, timeMax: _timeMax, items } = args; + const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax); + const key = JSON.stringify({ timeMin, timeMax, items }); + const cached = await prisma.calendarCache.findUnique({ + where: { + credentialId_key: { + credentialId: this.credential.id, + key, + }, + expiresAt: { gte: new Date(Date.now()) }, + }, + }); + + if (cached) { + freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; + } else { + ({ json: freeBusyResult } = await this.oAuthManagerInstance.request( + async () => + new AxiosLikeResponseToFetchResponse( + await calendar.freebusy.query({ + requestBody: { timeMin, timeMax, items }, + }) + ) + )); + + // Skipping await to respond faster + await prisma.calendarCache.upsert({ + where: { + credentialId_key: { + credentialId: this.credential.id, + key, + }, + }, + update: { + value: JSON.parse(JSON.stringify(freeBusyResult)), + expiresAt: new Date(Date.now() + CACHING_TIME), + }, + create: { + value: JSON.parse(JSON.stringify(freeBusyResult)), + credentialId: this.credential.id, + key, + expiresAt: new Date(Date.now() + CACHING_TIME), + }, + }); + } } if (!freeBusyResult.calendars) return null; @@ -629,92 +696,6 @@ export default class GoogleCalendarService implements Calendar { throw error; } } - - async watchCalendar({ calendarId }: { calendarId: string }) { - if (!process.env.GOOGLE_WEBHOOK_TOKEN) { - log.warn("GOOGLE_WEBHOOK_TOKEN is not set, skipping watching calendar"); - return; - } - const calendar = await this.authedCalendar(); - const res = await calendar.events.watch({ - // Calendar identifier. To retrieve calendar IDs call the calendarList.list method. If you want to access the primary calendar of the currently logged in user, use the "primary" keyword. - calendarId, - requestBody: { - // A UUID or similar unique string that identifies this channel. - id: uuid(), - type: "web_hook", - address: GOOGLE_WEBHOOK_URL, - token: process.env.GOOGLE_WEBHOOK_TOKEN, - params: { - // The time-to-live in seconds for the notification channel. Default is 604800 seconds. - ttl: `${Math.round(ONE_MONTH_IN_MS / 1000)}`, - }, - }, - }); - const response = res.data; - await GoogleRepository.upsertSelectedCalendar({ - userId: this.credential.userId!, - externalId: calendarId, - credentialId: this.credential.id, - googleChannelId: response?.id, - googleChannelKind: response?.kind, - googleChannelResourceId: response?.resourceId, - googleChannelResourceUri: response?.resourceUri, - googleChannelExpiration: response?.expiration, - }); - - return res.data; - } - async unwatchCalendar({ calendarId }: { calendarId: string }) { - const credentialId = this.credential.id; - const sc = await prisma.selectedCalendar.findFirst({ - where: { - credentialId, - externalId: calendarId, - }, - }); - // Delete the calendar cache to force a fresh cache - await prisma.calendarCache.deleteMany({ where: { credentialId } }); - const calendar = await this.authedCalendar(); - await calendar.channels - .stop({ - requestBody: { - resourceId: sc?.googleChannelResourceId, - id: sc?.googleChannelId, - }, - }) - .catch((err) => { - console.warn(JSON.stringify(err)); - }); - await GoogleRepository.upsertSelectedCalendar({ - userId: this.credential.userId!, - externalId: calendarId, - credentialId: this.credential.id, - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - }); - } - - async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise { - const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability(this.credential.id, args, JSON.parse(JSON.stringify(data))); - } - - 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(), - /** Expand the end date to the end of the month */ - timeMax: new Date(date.getFullYear(), date.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(), - items: selectedCalendars.map((sc) => ({ id: sc.externalId })), - }; - const data = await this.fetchAvailability(parsedArgs); - await this.setAvailabilityInCache(parsedArgs, data); - } } class MyGoogleAuth extends google.auth.OAuth2 { diff --git a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts index 9e0f09b670..6c8d556fde 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts @@ -19,22 +19,7 @@ const setCredentialsMock = vi.fn(); googleapisMock.google = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - calendar: vi.fn().mockReturnValue({ - channels: { - stop: vi.fn().mockResolvedValue(undefined), - }, - events: { - watch: vi.fn().mockResolvedValue({ - data: { - kind: "api#channel", - id: "mock-channel-id", - resourceId: "mock-resource-id", - resourceUri: "mock-resource-uri", - expiration: "1111111111", - }, - }), - }, - }), + calendar: vi.fn().mockReturnValue({}), auth: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/app-store/tests/__mocks__/OAuthManager.ts b/packages/app-store/tests/__mocks__/OAuthManager.ts index e210d9b860..82c51f11fd 100644 --- a/packages/app-store/tests/__mocks__/OAuthManager.ts +++ b/packages/app-store/tests/__mocks__/OAuthManager.ts @@ -1,12 +1,12 @@ import { beforeEach, vi } from "vitest"; -import { mockClear, mockDeep } from "vitest-mock-extended"; +import { mockReset, mockDeep } from "vitest-mock-extended"; import type * as OAuthManager from "../../_utils/oauth/OAuthManager"; vi.mock("../../_utils/oauth/OAuthManager", () => oAuthManagerMock); beforeEach(() => { - mockClear(oAuthManagerMock); + mockReset(oAuthManagerMock); }); const oAuthManagerMock = mockDeep({ diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 8aedaaafbf..6e7c2f567d 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -1,3 +1,4 @@ +import type { SelectedCalendar } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { sortBy } from "lodash"; @@ -14,7 +15,6 @@ import type { EventBusyDate, IntegrationCalendar, NewCalendarEventType, - SelectedCalendar, } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import type { EventResult } from "@calcom/types/EventManager"; diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index da641577b8..f8700ddcf9 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -1,9 +1,11 @@ +import type { SelectedCalendar } from "@prisma/client"; + import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import logger from "@calcom/lib/logger"; import { getPiiFreeCredential, getPiiFreeSelectedCalendar } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { performance } from "@calcom/lib/server/perfObserver"; -import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar"; +import type { EventBusyDate } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] }); @@ -28,10 +30,7 @@ const getCalendarsEvents = async ( /** We just pass the calendars that matched the credential type, * TODO: Migrate credential type or appId */ - const passedSelectedCalendars = 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 passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type); if (!passedSelectedCalendars.length) return []; /** We extract external Ids so we don't cache too much */ diff --git a/packages/features/calendar-cache/api/cron.ts b/packages/features/calendar-cache/api/cron.ts deleted file mode 100644 index 6d301a8b5e..0000000000 --- a/packages/features/calendar-cache/api/cron.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { NextRequest } from "next/server"; - -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server/defaultResponder.appDir"; -import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; - -import { CalendarCache } from "../calendar-cache"; - -const validateRequest = (req: NextRequest) => { - const searchParams = req.nextUrl.searchParams; - const apiKey = searchParams.get("apiKey") || req.headers.get("authorization"); - if (!apiKey || ![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(apiKey)) { - throw new HttpError({ statusCode: 401, message: "Unauthorized" }); - } -}; - -const handleCalendarsToUnwatch = async () => { - const calendarsToUnwatch = await SelectedCalendarRepository.getNextBatchToUnwatch(); - const result = await Promise.allSettled( - calendarsToUnwatch.map(async (sc) => { - if (!sc.credentialId) return; - const cc = await CalendarCache.initFromCredentialId(sc.credentialId); - await cc.unwatchCalendar({ calendarId: sc.externalId }); - }) - ); - - return result; -}; -const handleCalendarsToWatch = async () => { - const calendarsToWatch = await SelectedCalendarRepository.getNextBatchToWatch(); - const result = await Promise.allSettled( - calendarsToWatch.map(async (sc) => { - if (!sc.credentialId) return; - const cc = await CalendarCache.initFromCredentialId(sc.credentialId); - await cc.watchCalendar({ calendarId: sc.externalId }); - }) - ); - - return result; -}; - -// This cron is used to activate and renew calendar subcriptions -export const GET = defaultResponder(async (request: NextRequest) => { - validateRequest(request); - const [watchedResult, unwatchedResult] = await Promise.all([ - handleCalendarsToWatch(), - handleCalendarsToUnwatch(), - ]); - - // TODO: Credentials can be installed on a whole team, check for selected calendars on the team - return { - succeededAt: new Date().toISOString(), - watched: { - successful: watchedResult.filter((x) => x.status === "fulfilled").length, - failed: watchedResult.filter((x) => x.status === "rejected").length, - }, - unwatched: { - successful: unwatchedResult.filter((x) => x.status === "fulfilled").length, - failed: unwatchedResult.filter((x) => x.status === "rejected").length, - }, - }; -}); diff --git a/packages/features/calendar-cache/calendar-cache.repository.interface.ts b/packages/features/calendar-cache/calendar-cache.repository.interface.ts deleted file mode 100644 index df203c9bb9..0000000000 --- a/packages/features/calendar-cache/calendar-cache.repository.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { CalendarCache, Prisma } from "@prisma/client"; - -export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] }; - -export interface ICalendarCacheRepository { - watchCalendar(args: { calendarId: string }): Promise; - unwatchCalendar(args: { calendarId: string }): Promise; - upsertCachedAvailability( - credentialId: number, - args: FreeBusyArgs, - value: Prisma.JsonNullValueInput | Prisma.InputJsonValue - ): Promise; - getCachedAvailability(credentialId: number, args: FreeBusyArgs): Promise; -} diff --git a/packages/features/calendar-cache/calendar-cache.repository.mock.ts b/packages/features/calendar-cache/calendar-cache.repository.mock.ts deleted file mode 100644 index 98e303645c..0000000000 --- a/packages/features/calendar-cache/calendar-cache.repository.mock.ts +++ /dev/null @@ -1,22 +0,0 @@ -import logger from "@calcom/lib/logger"; - -import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface"; - -const log = logger.getSubLogger({ prefix: ["CalendarCacheRepositoryMock"] }); - -export class CalendarCacheRepositoryMock implements ICalendarCacheRepository { - async watchCalendar() { - log.info(`Skipping watchCalendar due to calendar-cache being disabled`); - } - async upsertCachedAvailability() { - log.info(`Skipping upsertCachedAvailability due to calendar-cache being disabled`); - } - async getCachedAvailability() { - log.info(`Skipping getCachedAvailability due to calendar-cache being disabled`); - return null; - } - - async unwatchCalendar() { - log.info(`Skipping unwatchCalendar due to calendar-cache being disabled`); - } -} diff --git a/packages/features/calendar-cache/calendar-cache.repository.schema.ts b/packages/features/calendar-cache/calendar-cache.repository.schema.ts deleted file mode 100644 index e367bb0cdd..0000000000 --- a/packages/features/calendar-cache/calendar-cache.repository.schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod"; - -export const watchCalendarSchema = z.object({ - kind: z.literal("api#channel"), - id: z.string(), - resourceId: z.string(), - resourceUri: z.string(), - expiration: z.string(), -}); diff --git a/packages/features/calendar-cache/calendar-cache.repository.ts b/packages/features/calendar-cache/calendar-cache.repository.ts deleted file mode 100644 index a9c5785388..0000000000 --- a/packages/features/calendar-cache/calendar-cache.repository.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Prisma } from "@prisma/client"; - -import logger from "@calcom/lib/logger"; -import prisma from "@calcom/prisma"; -import type { Calendar } from "@calcom/types/Calendar"; - -import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface"; -import { watchCalendarSchema } from "./calendar-cache.repository.schema"; - -const log = logger.getSubLogger({ prefix: ["CalendarCacheRepository"] }); - -/** Enable or disable the expanded cache. Enabled by default. */ -const ENABLE_EXPANDED_CACHE = process.env.ENABLE_EXPANDED_CACHE !== "0"; -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); - const key = JSON.stringify({ timeMin, timeMax, items }); - return key; -} - -/** - * By expanding the cache to whole months, we can save round trips to the third party APIs. - * In this case we already have the data in the database, so we can just return it. - */ -function handleMinMax(min: string, max: string) { - if (!ENABLE_EXPANDED_CACHE) return { timeMin: min, timeMax: max }; - return { timeMin: getTimeMin(min), timeMax: getTimeMax(max) }; -} - -type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] }; - -export class CalendarCacheRepository implements ICalendarCacheRepository { - calendar: Calendar | null; - constructor(calendar: Calendar | null = null) { - this.calendar = calendar; - } - async watchCalendar(args: { calendarId: string }) { - const { calendarId } = args; - if (typeof this.calendar?.watchCalendar !== "function") { - log.info( - '[handleWatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method' - ); - return; - } - const response = await this.calendar?.watchCalendar({ calendarId }); - const parsedResponse = watchCalendarSchema.safeParse(response); - if (!parsedResponse.success) { - log.info( - "[handleWatchCalendar] Received invalid response from calendar.watchCalendar, skipping watching calendar" - ); - return; - } - - return parsedResponse.data; - } - - async unwatchCalendar(args: { calendarId: string }) { - const { calendarId } = args; - if (typeof this.calendar?.unwatchCalendar !== "function") { - log.info( - '[unwatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method' - ); - return; - } - const response = await this.calendar?.unwatchCalendar({ calendarId }); - return response; - } - - async getCachedAvailability(credentialId: number, args: FreeBusyArgs) { - const key = parseKeyForCache(args); - log.info("Getting cached availability", key); - const cached = await prisma.calendarCache.findUnique({ - where: { - credentialId_key: { - credentialId, - key, - }, - expiresAt: { gte: new Date(Date.now()) }, - }, - }); - return cached; - } - async upsertCachedAvailability( - credentialId: number, - args: FreeBusyArgs, - value: Prisma.JsonNullValueInput | Prisma.InputJsonValue - ) { - const key = parseKeyForCache(args); - await prisma.calendarCache.upsert({ - where: { - credentialId_key: { - credentialId, - key, - }, - }, - update: { - value, - expiresAt: new Date(Date.now() + CACHING_TIME), - }, - create: { - value, - credentialId, - key, - expiresAt: new Date(Date.now() + CACHING_TIME), - }, - }); - } -} diff --git a/packages/features/calendar-cache/calendar-cache.ts b/packages/features/calendar-cache/calendar-cache.ts deleted file mode 100644 index 5ab9c9567f..0000000000 --- a/packages/features/calendar-cache/calendar-cache.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import prisma from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import type { Calendar } from "@calcom/types/Calendar"; - -import { CalendarCacheRepository } from "./calendar-cache.repository"; -import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface"; -import { CalendarCacheRepositoryMock } from "./calendar-cache.repository.mock"; - -export class CalendarCache { - static async initFromCredentialId(credentialId: number): Promise { - const credential = await prisma.credential.findUnique({ - where: { id: credentialId }, - select: credentialForCalendarServiceSelect, - }); - const calendar = await getCalendar(credential); - return await CalendarCache.init(calendar); - } - static async init(calendar: Calendar | null): Promise { - const featureRepo = new FeaturesRepository(); - const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally( - "calendar-cache" - ); - if (isCalendarCacheEnabledGlobally) return new CalendarCacheRepository(calendar); - return new CalendarCacheRepositoryMock(); - } -} diff --git a/packages/features/calendars/CalendarSwitch.tsx b/packages/features/calendars/CalendarSwitch.tsx index 5db0ccb22a..e990c97bb7 100644 --- a/packages/features/calendars/CalendarSwitch.tsx +++ b/packages/features/calendars/CalendarSwitch.tsx @@ -28,8 +28,6 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { const body = { integration: type, externalId: externalId, - // new URLSearchParams does not accept numbers - credentialId: String(credentialId), }; if (isOn) { @@ -38,7 +36,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(body), + body: JSON.stringify({ ...body, credentialId }), }); if (!res.ok) { diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index 1447061cc8..18eb2eb3d0 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -1,7 +1,4 @@ -import type { AppFlags } from "./config"; - export interface IFeaturesRepository { - checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise; checkIfUserHasFeature(userId: number, slug: string): Promise; checkIfTeamHasFeature(teamId: number, slug: string): Promise; checkIfTeamOrUserHasFeature(args: { teamId?: number; userId?: number }, slug: string): Promise; diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 5fca481bfa..dddfe2bb73 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -2,19 +2,9 @@ import { captureException } from "@sentry/nextjs"; import db from "@calcom/prisma"; -import type { AppFlags } from "./config"; import type { IFeaturesRepository } from "./features.repository.interface"; -import { getFeatureFlag } from "./server/utils"; export class FeaturesRepository implements IFeaturesRepository { - async checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags) { - try { - return await getFeatureFlag(db, slug); - } catch (err) { - captureException(err); - throw err; - } - } async checkIfTeamHasFeature(teamId: number, slug: string) { try { const teamFeature = await db.teamFeatures.findUnique({ diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index f952163948..c59f9bf496 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -1,26 +1,9 @@ import type { AppFlags } from "@calcom/features/flags/config"; import { trpc } from "@calcom/trpc/react"; -const initialData: AppFlags = { - organizations: false, - teams: false, - "calendar-cache": false, - emails: false, - insights: false, - webhooks: false, - workflows: false, - "email-verification": false, - "google-workspace-directory": false, - "disable-signup": false, - attributes: false, - "organizer-request-email-v2": false, -}; - -if (process.env.NEXT_PUBLIC_IS_E2E) { - initialData.organizations = true; - initialData.teams = true; -} - +const initialData: Partial = process.env.NEXT_PUBLIC_IS_E2E + ? { organizations: true, teams: true } + : {}; export function useFlags(): Partial { const query = trpc.viewer.features.map.useQuery(undefined, { initialData, diff --git a/packages/features/flags/server/utils.ts b/packages/features/flags/server/utils.ts index b666defa55..001a814087 100644 --- a/packages/features/flags/server/utils.ts +++ b/packages/features/flags/server/utils.ts @@ -2,21 +2,15 @@ import type { PrismaClient } from "@calcom/prisma"; import type { AppFlags } from "../config"; -// This is a temporary cache to avoid hitting the database on every lambda invocation -let TEMP_CACHE: AppFlags | null = null; - export async function getFeatureFlagMap(prisma: PrismaClient) { - // If we've already fetched the flags, return them - if (TEMP_CACHE) return TEMP_CACHE; const flags = await prisma.feature.findMany({ orderBy: { slug: "asc" }, cacheStrategy: { swr: 300, ttl: 300 }, }); - TEMP_CACHE = flags.reduce((acc, flag) => { + return flags.reduce((acc, flag) => { acc[flag.slug as keyof AppFlags] = flag.enabled; return acc; - }, {} as AppFlags); - return TEMP_CACHE; + }, {} as Partial); } interface CacheEntry { diff --git a/packages/lib/server/defaultResponder.appDir.ts b/packages/lib/server/defaultResponder.appDir.ts deleted file mode 100644 index 0cec43c664..0000000000 --- a/packages/lib/server/defaultResponder.appDir.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -import monitorCallbackAsync from "@calcom/core/sentryWrapper"; - -import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown"; - -type Handle = (req: NextRequest) => Promise; - -/** Variant to use in App Router API routes. Allows us to get type inference from API handler responses */ -export const defaultResponder = - (f: Handle) => - async (req: NextRequest) => { - try { - const result = await monitorCallbackAsync(f, req); - if (result) { - return NextResponse.json(result); - } - } catch (err) { - const error = getServerErrorFromUnknown(err); - // we don't want to report Bad Request errors to Sentry / console - if (!(error.statusCode >= 400 && error.statusCode < 500)) { - console.error(err); - const captureException = (await import("@sentry/nextjs")).captureException; - captureException(err); - } - return NextResponse.json({ message: error.message, url: error.url, method: error.method }); - } - }; diff --git a/packages/lib/server/repository/google.ts b/packages/lib/server/repository/google.ts index 571a1b5fa7..681e5f595a 100644 --- a/packages/lib/server/repository/google.ts +++ b/packages/lib/server/repository/google.ts @@ -1,4 +1,3 @@ -import type { Prisma } from "@prisma/client"; import type { Credentials } from "google-auth-library"; import { CredentialRepository } from "./credential"; @@ -30,15 +29,6 @@ export class GoogleRepository { }); } - static async upsertSelectedCalendar( - data: Omit - ) { - return await SelectedCalendarRepository.upsert({ - ...data, - integration: "google_calendar", - }); - } - static async findGoogleMeetCredential({ userId }: { userId: number }) { return await CredentialRepository.findFirstByUserIdAndType({ userId, diff --git a/packages/lib/server/repository/selectedCalendar.ts b/packages/lib/server/repository/selectedCalendar.ts index d35fd5de91..253f492fa5 100644 --- a/packages/lib/server/repository/selectedCalendar.ts +++ b/packages/lib/server/repository/selectedCalendar.ts @@ -1,7 +1,4 @@ -import type { Prisma } from "@prisma/client"; - import { prisma } from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; type SelectedCalendarCreateInput = { credentialId: number; @@ -18,108 +15,4 @@ export class SelectedCalendarRepository { }, }); } - static async upsert(data: Prisma.SelectedCalendarUncheckedCreateInput) { - return await prisma.selectedCalendar.upsert({ - where: { - userId_integration_externalId: { - userId: data.userId, - integration: data.integration, - externalId: data.externalId, - }, - }, - create: { ...data }, - update: { ...data }, - }); - } - /** Retrieve calendars that need to be watched */ - static async getNextBatchToWatch(limit = 100) { - // Get selected calendars from users that belong to a team that has calendar cache enabled - const oneDayInMS = 24 * 60 * 60 * 1000; - const tomorrowTimestamp = String(new Date().getTime() + oneDayInMS); - const nextBatch = await prisma.selectedCalendar.findMany({ - take: limit, - where: { - user: { - teams: { - some: { - team: { - features: { - some: { - featureId: "calendar-cache", - }, - }, - }, - }, - }, - }, - // RN we only support google calendar subscriptions for now - integration: "google_calendar", - OR: [ - // Either is a calendar pending to be watched - { googleChannelExpiration: null }, - // Or is a calendar that is about to expire - { googleChannelExpiration: { lt: tomorrowTimestamp } }, - ], - }, - }); - return nextBatch; - } - /** 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", - }, - }, - }, - }, - }, - }, - // RN we only support google calendar subscriptions for now - integration: "google_calendar", - googleChannelExpiration: { not: null }, - }, - }); - return nextBatch; - } - static async delete(data: Prisma.SelectedCalendarUncheckedCreateInput) { - return await prisma.selectedCalendar.delete({ - where: { - userId_integration_externalId: { - userId: data.userId, - externalId: data.externalId, - integration: data.integration, - }, - }, - }); - } - static async findMany(args: Prisma.SelectedCalendarFindManyArgs) { - return await prisma.selectedCalendar.findMany(args); - } - static async findByGoogleChannelId(googleChannelId: string) { - return await prisma.selectedCalendar.findUnique({ - where: { - googleChannelId, - }, - select: { - credential: { - select: { - ...credentialForCalendarServiceSelect, - selectedCalendars: { - orderBy: { - externalId: "asc", - }, - }, - }, - }, - }, - }); - } } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index b4148b57a2..2023176772 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -9,7 +9,6 @@ import prisma from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import type { User as UserType } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { userMetadata } from "@calcom/prisma/zod-utils"; import type { UpId, UserProfile } from "@calcom/types/UserProfile"; @@ -748,19 +747,4 @@ export class UserRepository { }, }); } - static async findUserWithCredentials({ id }: { id: number }) { - return await prisma.user.findUnique({ - where: { - id, - }, - select: { - credentials: { - select: credentialForCalendarServiceSelect, - }, - timeZone: true, - id: true, - selectedCalendars: true, - }, - }); - } } diff --git a/packages/prisma/migrations/20240207222843_add_google_watched_calendars/migration.sql b/packages/prisma/migrations/20240207222843_add_google_watched_calendars/migration.sql deleted file mode 100644 index 09783c4b8d..0000000000 --- a/packages/prisma/migrations/20240207222843_add_google_watched_calendars/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[googleChannelId]` on the table `SelectedCalendar` will be added. If there are existing duplicate values, this will fail. - -*/ --- AlterTable -ALTER TABLE "SelectedCalendar" ADD COLUMN "googleChannelExpiration" TEXT, -ADD COLUMN "googleChannelId" TEXT, -ADD COLUMN "googleChannelKind" TEXT, -ADD COLUMN "googleChannelResourceId" TEXT, -ADD COLUMN "googleChannelResourceUri" TEXT; - --- CreateIndex -CREATE UNIQUE INDEX "SelectedCalendar_googleChannelId_key" ON "SelectedCalendar"("googleChannelId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 51a136c9f7..728c5e834d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -682,18 +682,12 @@ model Availability { } model SelectedCalendar { - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - integration String - externalId String - credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) - credentialId Int? - // Used to identify a watched calendar channel in Google Calendar - googleChannelId String? @unique - googleChannelKind String? - googleChannelResourceId String? - googleChannelResourceUri String? - googleChannelExpiration String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + credentialId Int? @@id([userId, integration, externalId]) @@index([userId]) diff --git a/packages/prisma/sql/getSelectedCalendarsToWatch.sql b/packages/prisma/sql/getSelectedCalendarsToWatch.sql deleted file mode 100644 index d44f31e50d..0000000000 --- a/packages/prisma/sql/getSelectedCalendarsToWatch.sql +++ /dev/null @@ -1,24 +0,0 @@ -SELECT - TO_TIMESTAMP(sc."googleChannelExpiration"::bigint / 1000 - 86400)::date as "humanReadableExpireDate", - sc.* -FROM - "SelectedCalendar" sc - LEFT JOIN "users" AS u ON u.id = sc. "userId" - LEFT JOIN "Membership" AS m ON m. "userId" = u.id - LEFT JOIN "Team" AS t ON t.id = m."teamId" - LEFT JOIN "TeamFeatures" AS tf ON tf. "teamId" = t.id -WHERE - -- Only get calendars for teams where cache is enabled - tf."featureId" = 'calendar-cache' - -- We currently only support google watchers - AND sc."integration" = 'google_calendar' - AND ( - -- Either is a calendar pending to be watched - sc."googleChannelExpiration" IS NULL - OR ( - -- Or is a calendar that is about to expire - sc."googleChannelExpiration" IS NOT NULL - -- We substract one day in senconds to renew a day before expiration - AND TO_TIMESTAMP(sc."googleChannelExpiration"::bigint / 1000 - 86400)::date < CURRENT_TIMESTAMP - ) - ); diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts index d208a16b33..a99df03afb 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -1,12 +1,13 @@ +import { z } from "zod"; + import { authedAdminProcedure } from "../../../procedures/authedProcedure"; -import { importHandler, router } from "../../../trpc"; +import { router, importHandler } from "../../../trpc"; import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema"; import { ZListMembersSchema } from "./listPaginated.schema"; import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema"; import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema"; import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema"; import { ZSetSMSLockState } from "./setSMSLockState.schema"; -import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure"; const NAMESPACE = "admin"; @@ -31,7 +32,16 @@ export const adminRouter = router({ ); return handler(opts); }), - toggleFeatureFlag, + toggleFeatureFlag: authedAdminProcedure + .input(z.object({ slug: z.string(), enabled: z.boolean() })) + .mutation(({ ctx, input }) => { + const { prisma, user } = ctx; + const { slug, enabled } = input; + return prisma.feature.update({ + where: { slug }, + data: { enabled, updatedBy: user.id }, + }); + }), removeTwoFactor: authedAdminProcedure.input(ZAdminRemoveTwoFactor).mutation(async (opts) => { const handler = await importHandler( namespaced("removeTwoFactor"), diff --git a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts deleted file mode 100644 index ac3f34c588..0000000000 --- a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import logger from "@calcom/lib/logger"; -import type { PrismaClient } from "@calcom/prisma"; - -import type { TrpcSessionUser } from "../../../trpc"; -import type { TAdminToggleFeatureFlagSchema } from "./toggleFeatureFlag.schema"; - -type GetOptions = { - ctx: { - user: NonNullable; - prisma: PrismaClient; - }; - input: TAdminToggleFeatureFlagSchema; -}; - -export const toggleFeatureFlagHandler = async (opts: GetOptions) => { - const { ctx, input } = opts; - const { prisma, user } = ctx; - const { slug, enabled } = input; - await handleFeatureToggle(opts); - return prisma.feature.update({ - where: { slug }, - data: { enabled, updatedBy: user.id }, - }); -}; - -export default toggleFeatureFlagHandler; - -async function handleFeatureToggle({ ctx, input }: GetOptions) { - const { prisma } = ctx; - const { slug, enabled } = input; - // If we're disabling the calendar cache, clear it - if (slug === "calendar-cache" && enabled === false) { - logger.info("Clearing calendar cache"); - await prisma.calendarCache.deleteMany(); - } -} diff --git a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.procedure.ts b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.procedure.ts deleted file mode 100644 index ad6a4e5835..0000000000 --- a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.procedure.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { authedAdminProcedure } from "../../../procedures/authedProcedure"; -import { importHandler } from "../../../trpc"; -import { ZAdminToggleFeatureFlagSchema } from "./toggleFeatureFlag.schema"; - -export const toggleFeatureFlag = authedAdminProcedure - .input(ZAdminToggleFeatureFlagSchema) - .mutation(async (opts) => { - const handler = await importHandler( - "admin.toggleFeatureFlag", - () => import("./toggleFeatureFlag.handler") - ); - return handler(opts); - }); diff --git a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.schema.ts b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.schema.ts deleted file mode 100644 index 4871619dd0..0000000000 --- a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const ZAdminToggleFeatureFlagSchema = z.object({ - slug: z.string(), - enabled: z.boolean(), -}); - -export type TAdminToggleFeatureFlagSchema = z.infer; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 03b57c8dad..0f904b9cdf 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -1,9 +1,4 @@ -import type { - BookingSeat, - DestinationCalendar, - Prisma, - SelectedCalendar as _SelectedCalendar, -} from "@prisma/client"; +import type { BookingSeat, DestinationCalendar, Prisma, SelectedCalendar } from "@prisma/client"; import type { Dayjs } from "dayjs"; import type { calendar_v3 } from "googleapis"; import type { Time } from "ical.js"; @@ -236,7 +231,7 @@ export interface AdditionalInformation { hangoutLink?: string; } -export interface IntegrationCalendar extends Ensure, "externalId"> { +export interface IntegrationCalendar extends Ensure, "externalId"> { primary?: boolean; name?: string; readOnly?: boolean; @@ -264,13 +259,7 @@ export interface Calendar { selectedCalendars: IntegrationCalendar[] ): Promise; - fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise; - listCalendars(event?: CalendarEvent): Promise; - - watchCalendar?(options: { calendarId: string }): Promise; - - unwatchCalendar?(options: { calendarId: string }): Promise; } /** @@ -279,8 +268,3 @@ export interface Calendar { type Class = new (...args: Args) => I; export type CalendarClass = Class; - -export type SelectedCalendar = Pick< - _SelectedCalendar, - "userId" | "integration" | "externalId" | "credentialId" ->; diff --git a/turbo.json b/turbo.json index 1eeaefcd2a..ecf66c006b 100644 --- a/turbo.json +++ b/turbo.json @@ -318,7 +318,6 @@ "EMAIL_SERVER_PORT", "EMAIL_SERVER_USER", "EMAIL_SERVER", - "ENABLE_EXPANDED_CACHE", "EXCHANGE_DEFAULT_EWS_URL", "FORMBRICKS_FEEDBACK_SURVEY_ID", "AVATARAPI_USERNAME", @@ -327,7 +326,6 @@ "GITHUB_API_REPO_TOKEN", "GOOGLE_API_CREDENTIALS", "GOOGLE_LOGIN_ENABLED", - "GOOGLE_WEBHOOK_TOKEN", "HEROKU_APP_NAME", "HUBSPOT_CLIENT_ID", "HUBSPOT_CLIENT_SECRET",