revert: "feat: Populate gCal calendar cache via webhooks (#11928)"" (#17682)

This commit is contained in:
Omar López
2024-11-18 19:48:33 +00:00
committed by GitHub
parent 50df22928b
commit 7c0ee9f186
38 changed files with 920 additions and 240 deletions
+4
View File
@@ -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=
+5 -2
View File
@@ -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"),
+75 -81
View File
@@ -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
+4
View File
@@ -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 -1
View File
@@ -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";
+5 -4
View File
@@ -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({
+20 -3
View File
@@ -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,
+8 -2
View File
@@ -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 {
+10
View File
@@ -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",
},
},
},
},
},
});
}
}
+16
View File
@@ -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");
+12 -6
View File
@@ -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>;
+18 -2
View File
@@ -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"
>;
+2
View File
@@ -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",