diff --git a/.env.example b/.env.example index 1e3bb1a73910d7..d9acc38cfae4ec 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,10 @@ 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/cron-tester.ts b/apps/web/cron-tester.ts index 4e548f47782da2..a155ed9555c3bc 100644 --- a/apps/web/cron-tester.ts +++ b/apps/web/cron-tester.ts @@ -1,9 +1,12 @@ 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}`, { + const res = await fetch(`http://localhost:3000/api${endpoint}?apiKey=${apiKey}`, { headers: { "Content-Type": "application/json", authorization: `Bearer ${process.env.CRON_SECRET}`, @@ -20,7 +23,7 @@ try { "*/5 * * * * *", async function () { await Promise.allSettled([ - fetchCron("/tasks/cron"), + fetchCron("/calendar-cache/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 ce084da3cc77be..dee715c7f61e39 100644 --- a/apps/web/pages/api/availability/calendar.ts +++ b/apps/web/pages/api/availability/calendar.ts @@ -3,98 +3,92 @@ 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 prisma from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import { UserRepository } from "@calcom/lib/server/repository/user"; const selectedCalendarSelectSchema = z.object({ integration: z.string(), externalId: z.string(), - credentialId: z.number().optional(), + credentialId: z.coerce.number(), }); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getServerSession({ req, res }); +/** Shared authentication middleware for GET, DELETE and POST requests */ +async function authMiddleware(req: CustomNextApiRequest) { + const session = await getServerSession({ req }); if (!session?.user?.id) { - res.status(401).json({ message: "Not authenticated" }); - return; + throw new HttpError({ statusCode: 401, message: "Not authenticated" }); } - const userWithCredentials = await prisma.user.findUnique({ - where: { - id: session.user.id, - }, - select: { - credentials: { - select: credentialForCalendarServiceSelect, - }, - timeZone: true, - id: true, - selectedCalendars: true, - }, - }); + const userWithCredentials = await UserRepository.findUserWithCredentials({ id: session.user.id }); + if (!userWithCredentials) { - res.status(401).json({ message: "Not authenticated" }); - return; + throw new HttpError({ statusCode: 401, message: "Not authenticated" }); } - const { credentials, ...user } = userWithCredentials; + req.userWithCredentials = userWithCredentials; + return 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" }); - } +type CustomNextApiRequest = NextApiRequest & { + userWithCredentials?: Awaited>; +}; - if (req.method === "DELETE") { - const { integration, externalId } = selectedCalendarSelectSchema.parse(req.query); - await prisma.selectedCalendar.delete({ - where: { - userId_integration_externalId: { - userId: user.id, - externalId, - integration, - }, - }, - }); +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, + }); - res.status(200).json({ message: "Calendar Selection Saved" }); - } + return { message: "Calendar Selection Saved" }; +} - if (req.method === "GET") { - const selectedCalendarIds = await prisma.selectedCalendar.findMany({ - where: { - userId: user.id, - }, - select: { - externalId: true, - }, - }); +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, + }); - // 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); - } + 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/calendar-cache/cron.ts b/apps/web/pages/api/calendar-cache/cron.ts new file mode 100644 index 00000000000000..73b6e95dcd4c5d --- /dev/null +++ b/apps/web/pages/api/calendar-cache/cron.ts @@ -0,0 +1 @@ +export { default } from "@calcom/features/calendar-cache/api/cron"; diff --git a/apps/web/pages/api/cron/calendar-cache-cleanup.ts b/apps/web/pages/api/cron/calendar-cache-cleanup.ts index 8959de0d118b13..63d49c458eddcb 100644 --- a/apps/web/pages/api/cron/calendar-cache-cleanup.ts +++ b/apps/web/pages/api/cron/calendar-cache-cleanup.ts @@ -3,6 +3,12 @@ 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 6e44699ba35202..53551d4b5dfdce 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -8,6 +8,10 @@ "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 8e7754599ac2f1..ef7a514c62bab0 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.createSelectedCalendar({ + await GoogleRepository.upsertSelectedCalendar({ 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 eb12c1b4ed2c4f..567bbf79794bf8 100644 --- a/packages/app-store/googlecalendar/api/index.ts +++ b/packages/app-store/googlecalendar/api/index.ts @@ -1,2 +1,3 @@ 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 new file mode 100644 index 00000000000000..6d4ab4903fb892 --- /dev/null +++ b/packages/app-store/googlecalendar/api/webhook.ts @@ -0,0 +1,41 @@ +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 595ceed3427c42..dab13bb5be9df2 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -5,7 +5,11 @@ import { googleapisMock, setCredentialsMock } from "./__mocks__/googleapis"; import { expect, test, vi } from "vitest"; import "vitest-fetch-mock"; -import CalendarService, { getTimeMax, getTimeMin } from "./CalendarService"; +import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; + +import CalendarService from "./CalendarService"; + +vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token"); vi.mock("@calcom/features/flags/server/utils", () => ({ getFeatureFlag: vi.fn().mockReturnValue(true), @@ -32,7 +36,7 @@ const getSampleCredential = () => { return { invalid: false, key: googleTestCredentialKey, - type: "test", + type: "google_calendar", }; }; @@ -42,21 +46,22 @@ const testSelectedCalendar = { externalId: "example@cal.com", }; -test("Calendar Cache is being read and updated", async () => { +test("Calendar Cache is being read on cache HIT", async () => { const credentialInDb1 = await createCredentialInDb(); const dateFrom1 = new Date().toISOString(); const dateTo1 = new Date().toISOString(); // Create cache - await prismock.calendarCache.create({ - data: { - credentialId: credentialInDb1.id, - key: JSON.stringify({ - timeMin: getTimeMin(dateFrom1), - timeMax: getTimeMax(dateTo1), - items: [{ id: testSelectedCalendar.externalId }], - }), - value: { + const calendarCache = await CalendarCache.init(null); + await calendarCache.upsertCachedAvailability( + credentialInDb1.id, + { + timeMin: dateFrom1, + timeMax: dateTo1, + items: [{ id: testSelectedCalendar.externalId }], + }, + JSON.parse( + JSON.stringify({ calendars: [ { busy: [ @@ -67,10 +72,9 @@ test("Calendar Cache is being read and updated", async () => { ], }, ], - }, - expiresAt: String(Date.now() + 10000), - }, - }); + }) + ) + ); oAuthManagerMock.OAuthManager = defaultMockOAuthManager; const calendarService = new CalendarService(credentialInDb1); @@ -83,29 +87,73 @@ test("Calendar Cache is being read and updated", async () => { end: "2023-12-01T19:00:00Z", }, ]); +}); - const credentialInDb2 = await createCredentialInDb(); - const dateFrom2 = new Date(Date.now()).toISOString(); +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(); // Tweak date so that it's a cache miss - const dateTo2 = new Date(Date.now() + 100000000).toISOString(); - const calendarService2 = new CalendarService(credentialInDb2); + const dateTo = new Date(Date.now() + 100000000).toISOString(); + const calendarService = new CalendarService(credentialInDb); // Test Cache Miss - await calendarService2.getAvailability(dateFrom2, dateTo2, [testSelectedCalendar]); + await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar]); - // Expect cache to be updated in case of a MISS - const calendarCache = await prismock.calendarCache.findFirst({ + // 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({ where: { - credentialId: credentialInDb2.id, - key: JSON.stringify({ - timeMin: getTimeMin(dateFrom2), - timeMax: getTimeMax(dateTo2), - items: [{ id: testSelectedCalendar.externalId }], - }), + 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", }, }); - expect(calendarCache?.value).toEqual({ calendars: [] }); + expect(unWatchedCalendar).toEqual({ + userId: 1, + integration: "google_calendar", + externalId: "example@cal.com", + credentialId: 1, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + }); }); 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 3cab66b0a9e9f1..e5e328f957ba37 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -3,10 +3,12 @@ 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 { getFeatureFlag } from "@calcom/features/flags/server/utils"; +import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; +import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import type CalendarService from "@calcom/lib/CalendarService"; import { @@ -19,6 +21,7 @@ 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, @@ -39,39 +42,16 @@ 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 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) }; -} +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`; export default class GoogleCalendarService implements Calendar { private integrationName = ""; @@ -515,70 +495,23 @@ export default class GoogleCalendarService implements Calendar { } } - async getCacheOrFetchAvailability(args: { - timeMin: string; - timeMax: string; - items: { id: string }[]; - }): Promise { + async fetchAvailability(requestBody: FreeBusyArgs): Promise { const calendar = await this.authedCalendar(); - const calendarCacheEnabled = await getFeatureFlag(prisma, "calendar-cache"); + 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; let freeBusyResult: 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 }, - }) - ) - )); + 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 { - 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), - }, - }); - } + freeBusyResult = await this.fetchAvailability({ timeMin, timeMax, items }); } if (!freeBusyResult.calendars) return null; @@ -696,6 +629,92 @@ 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 6c8d556fded850..9e0f09b670cc69 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts @@ -19,7 +19,22 @@ const setCredentialsMock = vi.fn(); googleapisMock.google = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - calendar: vi.fn().mockReturnValue({}), + 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", + }, + }), + }, + }), 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 82c51f11fd6aad..e210d9b860a304 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 { mockReset, mockDeep } from "vitest-mock-extended"; +import { mockClear, mockDeep } from "vitest-mock-extended"; import type * as OAuthManager from "../../_utils/oauth/OAuthManager"; vi.mock("../../_utils/oauth/OAuthManager", () => oAuthManagerMock); beforeEach(() => { - mockReset(oAuthManagerMock); + mockClear(oAuthManagerMock); }); const oAuthManagerMock = mockDeep({ diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 6e7c2f567dbf87..8aedaaafbf9d22 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -1,4 +1,3 @@ -import type { SelectedCalendar } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { sortBy } from "lodash"; @@ -15,6 +14,7 @@ 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 f8700ddcf97f83..da641577b88df8 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -1,11 +1,9 @@ -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 } from "@calcom/types/Calendar"; +import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] }); @@ -30,7 +28,10 @@ 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); + 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)); 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 new file mode 100644 index 00000000000000..2a8869779ccde7 --- /dev/null +++ b/packages/features/calendar-cache/api/cron.ts @@ -0,0 +1,65 @@ +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 { CalendarCache } from "../calendar-cache"; + +const validateRequest = (req: NextApiRequest) => { + const apiKey = String(req.query.apiKey) || req.headers.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 +const handler = defaultResponder(async (request: NextApiRequest) => { + 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, + }, + }; +}); + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/features/calendar-cache/calendar-cache.repository.interface.ts b/packages/features/calendar-cache/calendar-cache.repository.interface.ts new file mode 100644 index 00000000000000..df203c9bb997da --- /dev/null +++ b/packages/features/calendar-cache/calendar-cache.repository.interface.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000000000..98e303645cefdf --- /dev/null +++ b/packages/features/calendar-cache/calendar-cache.repository.mock.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 00000000000000..e367bb0cddff24 --- /dev/null +++ b/packages/features/calendar-cache/calendar-cache.repository.schema.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000000000..a9c5785388cc23 --- /dev/null +++ b/packages/features/calendar-cache/calendar-cache.repository.ts @@ -0,0 +1,124 @@ +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 new file mode 100644 index 00000000000000..5ab9c9567f1a4e --- /dev/null +++ b/packages/features/calendar-cache/calendar-cache.ts @@ -0,0 +1,28 @@ +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 e990c97bb7db23..5db0ccb22ab036 100644 --- a/packages/features/calendars/CalendarSwitch.tsx +++ b/packages/features/calendars/CalendarSwitch.tsx @@ -28,6 +28,8 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { const body = { integration: type, externalId: externalId, + // new URLSearchParams does not accept numbers + credentialId: String(credentialId), }; if (isOn) { @@ -36,7 +38,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ ...body, credentialId }), + body: JSON.stringify(body), }); if (!res.ok) { diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index 18eb2eb3d0e360..1447061cc80b66 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -1,4 +1,7 @@ +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 dddfe2bb738ae0..5fca481bfa8b2c 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -2,9 +2,19 @@ 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 c59f9bf496e68d..f952163948c5de 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -1,9 +1,26 @@ import type { AppFlags } from "@calcom/features/flags/config"; import { trpc } from "@calcom/trpc/react"; -const initialData: Partial = process.env.NEXT_PUBLIC_IS_E2E - ? { organizations: true, teams: true } - : {}; +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; +} + 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 001a8140871018..b666defa552a2d 100644 --- a/packages/features/flags/server/utils.ts +++ b/packages/features/flags/server/utils.ts @@ -2,15 +2,21 @@ 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 }, }); - return flags.reduce((acc, flag) => { + TEMP_CACHE = flags.reduce((acc, flag) => { acc[flag.slug as keyof AppFlags] = flag.enabled; return acc; - }, {} as Partial); + }, {} as AppFlags); + return TEMP_CACHE; } interface CacheEntry { diff --git a/packages/lib/server/repository/google.ts b/packages/lib/server/repository/google.ts index 681e5f595a80f6..571a1b5fa76434 100644 --- a/packages/lib/server/repository/google.ts +++ b/packages/lib/server/repository/google.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@prisma/client"; import type { Credentials } from "google-auth-library"; import { CredentialRepository } from "./credential"; @@ -29,6 +30,15 @@ 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 253f492fa58f5f..d35fd5de91989f 100644 --- a/packages/lib/server/repository/selectedCalendar.ts +++ b/packages/lib/server/repository/selectedCalendar.ts @@ -1,4 +1,7 @@ +import type { Prisma } from "@prisma/client"; + import { prisma } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; type SelectedCalendarCreateInput = { credentialId: number; @@ -15,4 +18,108 @@ 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 4ef71a4799c6b7..ccb0d0e00e5df0 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -9,6 +9,7 @@ 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"; @@ -740,4 +741,19 @@ 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 new file mode 100644 index 00000000000000..09783c4b8dfd9c --- /dev/null +++ b/packages/prisma/migrations/20240207222843_add_google_watched_calendars/migration.sql @@ -0,0 +1,15 @@ +/* + 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 b0af5935d6b151..509690a2682f6a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -686,12 +686,18 @@ 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? + 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? @@id([userId, integration, externalId]) @@index([userId]) diff --git a/packages/prisma/sql/getSelectedCalendarsToWatch.sql b/packages/prisma/sql/getSelectedCalendarsToWatch.sql new file mode 100644 index 00000000000000..d44f31e50dfc5f --- /dev/null +++ b/packages/prisma/sql/getSelectedCalendarsToWatch.sql @@ -0,0 +1,24 @@ +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 a99df03afb11e0..d208a16b339890 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -1,13 +1,12 @@ -import { z } from "zod"; - import { authedAdminProcedure } from "../../../procedures/authedProcedure"; -import { router, importHandler } from "../../../trpc"; +import { importHandler, router } 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"; @@ -32,16 +31,7 @@ export const adminRouter = router({ ); return handler(opts); }), - 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 }, - }); - }), + toggleFeatureFlag, 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 new file mode 100644 index 00000000000000..ac3f34c5888cd3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 00000000000000..ad6a4e5835e57b --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.procedure.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000000000..4871619dd07e6d --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.schema.ts @@ -0,0 +1,8 @@ +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 0f904b9cdf92e1..03b57c8dad97b3 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -1,4 +1,9 @@ -import type { BookingSeat, DestinationCalendar, Prisma, SelectedCalendar } from "@prisma/client"; +import type { + BookingSeat, + DestinationCalendar, + Prisma, + SelectedCalendar as _SelectedCalendar, +} from "@prisma/client"; import type { Dayjs } from "dayjs"; import type { calendar_v3 } from "googleapis"; import type { Time } from "ical.js"; @@ -231,7 +236,7 @@ export interface AdditionalInformation { hangoutLink?: string; } -export interface IntegrationCalendar extends Ensure, "externalId"> { +export interface IntegrationCalendar extends Ensure, "externalId"> { primary?: boolean; name?: string; readOnly?: boolean; @@ -259,7 +264,13 @@ export interface Calendar { selectedCalendars: IntegrationCalendar[] ): Promise; + fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise; + listCalendars(event?: CalendarEvent): Promise; + + watchCalendar?(options: { calendarId: string }): Promise; + + unwatchCalendar?(options: { calendarId: string }): Promise; } /** @@ -268,3 +279,8 @@ 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 ecf66c006bedc5..1eeaefcd2a23a3 100644 --- a/turbo.json +++ b/turbo.json @@ -318,6 +318,7 @@ "EMAIL_SERVER_PORT", "EMAIL_SERVER_USER", "EMAIL_SERVER", + "ENABLE_EXPANDED_CACHE", "EXCHANGE_DEFAULT_EWS_URL", "FORMBRICKS_FEEDBACK_SURVEY_ID", "AVATARAPI_USERNAME", @@ -326,6 +327,7 @@ "GITHUB_API_REPO_TOKEN", "GOOGLE_API_CREDENTIALS", "GOOGLE_LOGIN_ENABLED", + "GOOGLE_WEBHOOK_TOKEN", "HEROKU_APP_NAME", "HUBSPOT_CLIENT_ID", "HUBSPOT_CLIENT_SECRET",