diff --git a/.env.example b/.env.example
index a59043e802..90f60adcd7 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/app/api/calendar-cache/cron/route.ts b/apps/web/app/api/calendar-cache/cron/route.ts
new file mode 100644
index 0000000000..1cc51cca79
--- /dev/null
+++ b/apps/web/app/api/calendar-cache/cron/route.ts
@@ -0,0 +1,7 @@
+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/app/settings/(admin-layout)/admin/calendar-cache/page.tsx b/apps/web/app/settings/(admin-layout)/admin/calendar-cache/page.tsx
new file mode 100644
index 0000000000..3c226adb6d
--- /dev/null
+++ b/apps/web/app/settings/(admin-layout)/admin/calendar-cache/page.tsx
@@ -0,0 +1,11 @@
+import { _generateMetadata } from "app/_utils";
+
+export const generateMetadata = async () =>
+ await _generateMetadata(
+ (t) => t("admin"),
+ () => ""
+ );
+
+const Page = () =>
Calendar cache index
;
+
+export default Page;
diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts
index 4e548f4778..a155ed9555 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 ce084da3cc..dee715c7f6 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;
- }
- 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);
+ throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
+ 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 8959de0d11..63d49c458e 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 6e44699ba3..53551d4b5d 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 8e7754599a..ef7a514c62 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 eb12c1b4ed..567bbf7979 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 0000000000..6d4ab4903f
--- /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 595ceed342..dab13bb5be 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 3cab66b0a9..e5e328f957 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 6c8d556fde..9e0f09b670 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 82c51f11fd..e210d9b860 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 6e7c2f567d..8aedaaafbf 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 f8700ddcf9..da641577b8 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 0000000000..6d301a8b5e
--- /dev/null
+++ b/packages/features/calendar-cache/api/cron.ts
@@ -0,0 +1,62 @@
+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
new file mode 100644
index 0000000000..df203c9bb9
--- /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 0000000000..98e303645c
--- /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 0000000000..e367bb0cdd
--- /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 0000000000..a9c5785388
--- /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 0000000000..5ab9c9567f
--- /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 e990c97bb7..5db0ccb22a 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 18eb2eb3d0..1447061cc8 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 dddfe2bb73..5fca481bfa 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 c59f9bf496..f952163948 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 001a814087..b666defa55 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/defaultResponder.appDir.ts b/packages/lib/server/defaultResponder.appDir.ts
new file mode 100644
index 0000000000..0cec43c664
--- /dev/null
+++ b/packages/lib/server/defaultResponder.appDir.ts
@@ -0,0 +1,29 @@
+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 681e5f595a..571a1b5fa7 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 253f492fa5..d35fd5de91 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 2023176772..b4148b57a2 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";
@@ -747,4 +748,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 0000000000..09783c4b8d
--- /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 728c5e834d..51a136c9f7 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -682,12 +682,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 0000000000..d44f31e50d
--- /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 a99df03afb..d208a16b33 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 0000000000..ac3f34c588
--- /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 0000000000..ad6a4e5835
--- /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 0000000000..4871619dd0
--- /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 0f904b9cdf..03b57c8dad 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 ecf66c006b..1eeaefcd2a 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",