This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<ReturnType<typeof authMiddleware>>;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/calendar-cache/api/cron";
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"path": "/api/tasks/cron",
|
||||
"schedule": "* * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/calendar-cache/cron",
|
||||
"schedule": "* * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/tasks/cleanup",
|
||||
"schedule": "0 0 * * *"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
export { default as webhook } from "./webhook";
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<EventBusyDate[] | null> {
|
||||
async fetchAvailability(requestBody: FreeBusyArgs): Promise<calendar_v3.Schema$FreeBusyResponse> {
|
||||
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<EventBusyDate[] | null> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof OAuthManager>({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
@@ -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<any>;
|
||||
unwatchCalendar(args: { calendarId: string }): Promise<any>;
|
||||
upsertCachedAvailability(
|
||||
credentialId: number,
|
||||
args: FreeBusyArgs,
|
||||
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue
|
||||
): Promise<void>;
|
||||
getCachedAvailability(credentialId: number, args: FreeBusyArgs): Promise<CalendarCache | null>;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<ICalendarCacheRepository> {
|
||||
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<ICalendarCacheRepository> {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally(
|
||||
"calendar-cache"
|
||||
);
|
||||
if (isCalendarCacheEnabledGlobally) return new CalendarCacheRepository(calendar);
|
||||
return new CalendarCacheRepositoryMock();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AppFlags } from "./config";
|
||||
|
||||
export interface IFeaturesRepository {
|
||||
checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise<boolean>;
|
||||
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
|
||||
checkIfTeamHasFeature(teamId: number, slug: string): Promise<boolean>;
|
||||
checkIfTeamOrUserHasFeature(args: { teamId?: number; userId?: number }, slug: string): Promise<boolean>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
const initialData: Partial<AppFlags> = 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<AppFlags> {
|
||||
const query = trpc.viewer.features.map.useQuery(undefined, {
|
||||
initialData,
|
||||
|
||||
@@ -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<AppFlags>);
|
||||
}, {} as AppFlags);
|
||||
return TEMP_CACHE;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
|
||||
@@ -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<Prisma.SelectedCalendarUncheckedCreateInput, "integration">
|
||||
) {
|
||||
return await SelectedCalendarRepository.upsert({
|
||||
...data,
|
||||
integration: "google_calendar",
|
||||
});
|
||||
}
|
||||
|
||||
static async findGoogleMeetCredential({ userId }: { userId: number }) {
|
||||
return await CredentialRepository.findFirstByUserIdAndType({
|
||||
userId,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<TrpcSessionUser>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZAdminToggleFeatureFlagSchema = z.object({
|
||||
slug: z.string(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type TAdminToggleFeatureFlagSchema = z.infer<typeof ZAdminToggleFeatureFlagSchema>;
|
||||
Vendored
+18
-2
@@ -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<Partial<SelectedCalendar>, "externalId"> {
|
||||
export interface IntegrationCalendar extends Ensure<Partial<_SelectedCalendar>, "externalId"> {
|
||||
primary?: boolean;
|
||||
name?: string;
|
||||
readOnly?: boolean;
|
||||
@@ -259,7 +264,13 @@ export interface Calendar {
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<EventBusyDate[]>;
|
||||
|
||||
fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
|
||||
|
||||
listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]>;
|
||||
|
||||
watchCalendar?(options: { calendarId: string }): Promise<unknown>;
|
||||
|
||||
unwatchCalendar?(options: { calendarId: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,3 +279,8 @@ export interface Calendar {
|
||||
type Class<I, Args extends any[] = any[]> = new (...args: Args) => I;
|
||||
|
||||
export type CalendarClass = Class<Calendar, [CredentialPayload]>;
|
||||
|
||||
export type SelectedCalendar = Pick<
|
||||
_SelectedCalendar,
|
||||
"userId" | "integration" | "externalId" | "credentialId"
|
||||
>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user