refactor: Remove all code related to the old cache system (#25284)

* chore: Remove all code related to the old cache system

* Removed some redundant tests, some type fixes

* Further type fixes

* More type fixes re. tests

* Next iteration, couple of fixes remaining

* Remove cache from CredentialActionsDropdown

* Fix tests by mocking credential, instead of db queries

* Remove Cache DI wiring from v2

* Make sure apiv2 build passes

* Remove another cache cron

* Remove old tokens for calendar-cache v1
This commit is contained in:
Alex van Andel
2025-11-20 18:02:18 +02:00
committed by GitHub
parent d1184f65e2
commit ae7fd0cae2
45 changed files with 58 additions and 2765 deletions
-15
View File
@@ -1,15 +0,0 @@
Possible issue:
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
- Delete Credentials when DelegationCredential is disabled
TO Test
- New members beyond the batch size are processed
@@ -10,7 +10,6 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
@@ -35,7 +34,6 @@ import { Module } from "@nestjs/common";
PrismaFeaturesRepository,
PrismaMembershipRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
UserAvailabilityService,
BusyTimesService,
@@ -4,7 +4,6 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { HashedLinkService } from "@/lib/services/hashed-link.service";
@@ -22,7 +21,6 @@ import { Module } from "@nestjs/common";
PrismaHostRepository,
PrismaOOORepository,
PrismaUserRepository,
CacheService,
CheckBookingAndDurationLimitsService,
CheckBookingLimitsService,
HashedLinkService,
@@ -8,7 +8,6 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
@@ -34,7 +33,6 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
featuresRepository: PrismaFeaturesRepository,
qualifiedHostsService: QualifiedHostsService,
checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService,
userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService,
noSlotsNotificationService: NoSlotsNotificationService
@@ -50,7 +48,6 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
userRepo: userRepository,
redisClient: redisService,
checkBookingLimitsService,
cacheService,
userAvailabilityService,
busyTimesService,
qualifiedHostsService,
@@ -1,11 +0,0 @@
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { Injectable } from "@nestjs/common";
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
@Injectable()
export class CacheService extends BaseCacheService {
constructor(featuresRepository: PrismaFeaturesRepository) {
super({ featuresRepository });
}
}
@@ -1,6 +1,5 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { HashedLinkService } from "@/lib/services/hashed-link.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
@@ -13,7 +12,6 @@ import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class RegularBookingService extends BaseRegularBookingService {
constructor(
cacheService: CacheService,
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
prismaWriteService: PrismaWriteService,
bookingRepository: PrismaBookingRepository,
@@ -22,7 +20,6 @@ export class RegularBookingService extends BaseRegularBookingService {
userRepository: PrismaUserRepository
) {
super({
cacheService,
checkBookingAndDurationLimitsService,
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
bookingRepository,
@@ -5,7 +5,6 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import {
getCalendarCredentials,
getConnectedCalendars,
@@ -92,15 +91,9 @@ async function deleteHandler(req: NextRequest) {
const user = await authMiddleware();
const searchParams = Object.fromEntries(req.nextUrl.searchParams.entries());
const { integration, externalId, credentialId, eventTypeId } =
const { integration, externalId, eventTypeId } =
selectedCalendarSelectSchema.parse(searchParams);
const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId);
await calendarCacheRepository.unwatchCalendar({
calendarId: externalId,
eventTypeIds: [eventTypeId ?? null],
});
await SelectedCalendarRepository.delete({
where: {
userId: user.id,
@@ -1,26 +0,0 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import prisma from "@calcom/prisma";
async function postHandler(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
expiresAt: {
lte: new Date(Date.now()),
},
},
});
return NextResponse.json({ ok: true, count: deleted.count });
}
export const POST = defaultResponderForAppDir(postHandler);
-1
View File
@@ -24,7 +24,6 @@ try {
async function () {
await Promise.allSettled([
fetchCron("/cron/calendar-subscriptions"),
// fetchCron("/calendar-cache/cron"),
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
fetchCron("/tasks/cron"),
]);
@@ -1 +0,0 @@
export { default } from "@calcom/features/calendar-cache/api/cron";
@@ -2,8 +2,6 @@ import { expect } from "@playwright/test";
import type { Page, Browser, Route, Response } from "@playwright/test";
import type { z } from "zod";
import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache";
import { prisma } from "@calcom/prisma";
import type { Team, EventType, User } from "@calcom/prisma/client";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
@@ -65,10 +63,7 @@ test.describe("Booking Race Condition Prevention", () => {
await setupGoogleCalendarCredentials(teamMembers);
await createIdenticalBookingHistories(teamMembers, teamEvent.id);
const { selectedDate, selectedDateISO } = await getDynamicBookingDate(page, org, team, teamEvent);
const { targetHost, calendarCacheHits } = await setupCalendarCache(teamMembers, selectedDateISO);
await enableCalendarCacheFeatures(team.id);
const { selectedDate } = await getDynamicBookingDate(page, org, team, teamEvent);
const { firstResponse, secondResponse } = await performConcurrentBookings(
page,
@@ -226,82 +221,6 @@ async function getDynamicBookingDate(
};
}
async function setupCalendarCache(teamMembers: User[], selectedDateISO: string) {
const cacheTimeRange = {
timeMin: getTimeMin(selectedDateISO),
timeMax: getTimeMax(selectedDateISO),
};
const credentials = await prisma.credential.findMany({
where: {
userId: { in: teamMembers.map((m) => m.id) },
type: "google_calendar",
},
});
const calendarCacheRepo = new CalendarCacheRepository(null);
const targetHost = teamMembers[0];
const calendarCacheHits: string[] = [];
for (let i = 0; i < credentials.length; i++) {
const credential = credentials[i];
const member = teamMembers[i];
const cacheArgs = {
timeMin: cacheTimeRange.timeMin,
timeMax: cacheTimeRange.timeMax,
items: [{ id: member.email! }],
};
const availabilityData = {
kind: "calendar#freeBusy",
calendars: {
[member.email!]: {
busy:
member.id === targetHost.id
? []
: [
{
start: `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`,
end: `${selectedDateISO.slice(0, 10)}T08:30:00.000Z`,
},
],
},
},
};
await calendarCacheRepo.upsertCachedAvailability({
credentialId: credential.id,
userId: member.id,
args: cacheArgs,
value: availabilityData,
});
calendarCacheHits.push(`${member.email}-${credential.id}`);
}
return { targetHost, calendarCacheHits };
}
async function enableCalendarCacheFeatures(teamId: number) {
await prisma.teamFeatures.createMany({
data: [
{
teamId,
featureId: "calendar-cache",
assignedAt: new Date(),
assignedBy: "race-condition-test",
},
{
teamId,
featureId: "calendar-cache-serve",
assignedAt: new Date(),
assignedBy: "race-condition-test",
},
],
});
}
async function mockGoogleCalendarAPI(page: Page, selectedDateISO: string) {
const busyStart = `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`;
const busyEnd = `${selectedDateISO.slice(0, 10)}T09:00:00.000Z`;
-8
View File
@@ -1,9 +1,5 @@
{
"crons": [
{
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
},
{
"path": "/api/cron/calendar-subscriptions",
"schedule": "*/5 * * * *"
@@ -20,10 +16,6 @@
"path": "/api/tasks/cron",
"schedule": "* * * * *"
},
{
"path": "/api/calendar-cache/cron",
"schedule": "* * * * *"
},
{
"path": "/api/tasks/cleanup",
"schedule": "0 0 * * *"
@@ -615,41 +615,6 @@ export async function findUniqueDelegationCalendarCredential({
return dwdCredential;
}
/**
* CredentialForCalendarCache is different from CredentialForCalendarService in the sense that CredentialForCalendarCache.id is greater than 0 and CredentialForCalendarService.id is -1
* Thus it is a Credential from DB and and also a Delegation User Credential(when CredentialForCalendarCache.delegatedTo is not null)
*/
export async function getCredentialForCalendarCache({ credentialId }: { credentialId: number }) {
const credential = await CredentialRepository.findByIdIncludeDelegationCredential({
id: credentialId,
});
let credentialForCalendarService;
if (credential?.delegationCredential) {
if (!credential.userId) {
throw new Error(`Credential ${credentialId} doesn't have a user`);
}
const delegationCredential = await findUniqueDelegationCalendarCredential({
userId: credential.userId,
delegationCredentialId: credential.delegationCredential.id,
});
if (!delegationCredential) {
credentialForCalendarService = null;
} else {
// We prepare a credential that is in-db(in contrast with an in-memory credential used elsewhere where we generate CredentialForCalendarService)
credentialForCalendarService = {
...delegationCredential,
id: credential.id,
};
}
} else {
credentialForCalendarService = buildNonDelegationCredential(credential);
}
return credentialForCalendarService;
}
/**
* It includes in-memory DelegationCredential credentials as well.
*/
@@ -1,3 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as webhook } from "./webhook";
@@ -1,52 +0,0 @@
import type { NextApiRequest } from "next";
import { getCredentialForCalendarCache } from "@calcom/app-store/delegationCredential";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { getCalendar } from "../../_utils/getCalendar";
const log = logger.getSubLogger({ prefix: ["GoogleCalendarWebhook"] });
async function postHandler(req: NextApiRequest) {
const channelToken = req.headers["x-goog-channel-token"];
const channelId = req.headers["x-goog-channel-id"];
log.debug("postHandler", safeStringify({ channelToken, channelId }));
if (channelToken !== process.env.GOOGLE_WEBHOOK_TOKEN) {
throw new HttpError({ statusCode: 403, message: "Invalid API key" });
}
if (typeof channelId !== "string") {
throw new HttpError({ statusCode: 403, message: "Missing Channel ID" });
}
// There could be multiple selected calendars for the same googleChannelId for different eventTypes and same user
// Every such record has their googleChannel related fields set which are same
// So, it is enough to get the first selected calendar for this googleChannelId
// Further code gets all the selected calendars for this calendar's credential
const selectedCalendar = await SelectedCalendarRepository.findFirstByGoogleChannelId(channelId);
if (!selectedCalendar) {
log.info("postHandler", `No selected calendar found for googleChannelId: ${channelId}`);
return { message: "ok" };
}
const { credential } = selectedCalendar;
if (!credential) {
log.info("postHandler", `No credential found for selected calendar for googleChannelId: ${channelId}`);
return { message: "ok" };
}
const { selectedCalendars } = credential;
const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id });
const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache);
await calendarServiceForCalendarCache?.fetchAvailabilityAndSetCache?.(selectedCalendars);
return { message: "ok" };
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});
@@ -5,16 +5,12 @@ import { RRule } from "rrule";
import { v4 as uuid } from "uuid";
import { MeetLocationType } from "@calcom/app-store/constants";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { uniqueBy } from "@calcom/lib/array";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type {
Calendar,
@@ -30,6 +26,8 @@ import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Creden
import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse";
import { CalendarAuth } from "./CalendarAuth";
type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] };
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] });
interface GoogleCalError extends Error {
@@ -38,20 +36,12 @@ interface GoogleCalError extends Error {
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ONE_MONTH_IN_MS = 30 * MS_PER_DAY;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- GOOGLE_WEBHOOK_URL only for local testing
const GOOGLE_WEBHOOK_URL_BASE = process.env.GOOGLE_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_WEBHOOK_URL = `${GOOGLE_WEBHOOK_URL_BASE}/api/integrations/googlecalendar/webhook`;
const isGaxiosResponse = (error: unknown): error is GaxiosResponse<calendar_v3.Schema$Event> =>
typeof error === "object" && !!error && error.hasOwnProperty("config");
type GoogleChannelProps = {
kind?: string | null;
id?: string | null;
resourceId?: string | null;
resourceUri?: string | null;
expiration?: string | null;
};
typeof error === "object" && !!error && Object.prototype.hasOwnProperty.call(error, "config");
export default class GoogleCalendarService implements Calendar {
private integrationName = "";
@@ -196,7 +186,7 @@ export default class GoogleCalendarService implements Calendar {
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!calEvent.seatsPerTimeSlot ? calEvent.seatsShowAttendees : true,
guestsCanSeeOtherGuests: calEvent.seatsPerTimeSlot ? calEvent.seatsShowAttendees : true,
iCalUID: calEvent.iCalUID,
};
if (calEvent.hideCalendarEventDetails) {
@@ -355,7 +345,7 @@ export default class GoogleCalendarService implements Calendar {
reminders: {
useDefault: true,
},
guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
guestsCanSeeOtherGuests: event.seatsPerTimeSlot ? event.seatsShowAttendees : true,
};
if (event.location) {
@@ -464,26 +454,7 @@ export default class GoogleCalendarService implements Calendar {
async getFreeBusyResult(
args: FreeBusyArgs,
shouldServeCache?: boolean
): Promise<calendar_v3.Schema$FreeBusyResponse> {
if (!shouldServeCache) return await this.fetchAvailability(args);
const calendarCache = await CalendarCache.init(null);
const cached = await calendarCache.getCachedAvailability({
credentialId: this.credential.id,
userId: this.credential.userId,
args: {
// Expand the start date to the start of the month to increase cache hits
timeMin: getTimeMin(args.timeMin),
// Expand the end date to the end of the month to increase cache hits
timeMax: getTimeMax(args.timeMax),
items: args.items,
},
});
if (cached) {
log.debug("[Cache Hit] Returning cached freebusy result", safeStringify({ cached, args }));
return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
}
log.debug("[Cache Miss] Fetching freebusy result", safeStringify({ args }));
return await this.fetchAvailability(args);
}
@@ -500,11 +471,10 @@ export default class GoogleCalendarService implements Calendar {
return validCals[0];
}
async getCacheOrFetchAvailability(
async getFreeBusyData(
args: FreeBusyArgs,
shouldServeCache?: boolean
): Promise<(EventBusyDate & { id: string })[] | null> {
const freeBusyResult = await this.getFreeBusyResult(args, shouldServeCache);
const freeBusyResult = await this.getFreeBusyResult(args);
if (!freeBusyResult.calendars) return null;
const result = Object.entries(freeBusyResult.calendars).reduce((c, [id, i]) => {
@@ -567,7 +537,7 @@ export default class GoogleCalendarService implements Calendar {
try {
const calIdsWithTimeZone = await getCalIdsWithTimeZone();
const calIds = calIdsWithTimeZone.map((calIdWithTimeZone) => ({ id: calIdWithTimeZone.id }));
const freeBusyData = await this.getCacheOrFetchAvailability({
const freeBusyData = await this.getFreeBusyData({
timeMin: dateFrom,
timeMax: dateTo,
items: calIds,
@@ -611,44 +581,6 @@ export default class GoogleCalendarService implements Calendar {
);
}
/**
* Attempts to get availability from cache
*/
private async tryGetAvailabilityFromCache(
timeMin: string,
timeMax: string,
calendarIds: string[]
): Promise<EventBusyDate[] | null> {
try {
const calendarCache = await CalendarCache.init(null);
const cached = await calendarCache.getCachedAvailability({
credentialId: this.credential.id,
userId: this.credential.userId,
args: {
// Expand the start date to the start of the month to increase cache hits
timeMin: getTimeMin(timeMin),
// Expand the end date to the end of the month to increase cache hits
timeMax: getTimeMax(timeMax),
items: calendarIds.map((id) => ({ id })),
},
});
if (cached) {
this.log.debug(
"[Cache Hit] Returning cached availability result",
safeStringify({ timeMin, timeMax, calendarIds })
);
const freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
return this.convertFreeBusyToEventBusyDates(freeBusyResult);
}
return null;
} catch (error) {
this.log.debug("Cache check failed, proceeding with API call", safeStringify(error));
return null;
}
}
/**
* Gets calendar IDs for the request, either from selected calendars or fallback logic
*/
@@ -677,7 +609,6 @@ export default class GoogleCalendarService implements Calendar {
calendarIds: string[],
dateFrom: string,
dateTo: string,
shouldServeCache?: boolean
): Promise<EventBusyDate[]> {
// More efficient date difference calculation using native Date objects
// Use Math.floor to match dayjs diff behavior (truncates, doesn't round up)
@@ -688,13 +619,12 @@ export default class GoogleCalendarService implements Calendar {
// Google API only allows a date range of 90 days for /freebusy
if (diff <= 90) {
const freeBusyData = await this.getCacheOrFetchAvailability(
const freeBusyData = await this.getFreeBusyData(
{
timeMin: dateFrom,
timeMax: dateTo,
items: calendarIds.map((id) => ({ id })),
},
shouldServeCache
}
);
if (!freeBusyData) throw new Error("No response from google calendar");
@@ -717,13 +647,12 @@ export default class GoogleCalendarService implements Calendar {
currentEndTime = originalEndTime;
}
const chunkData = await this.getCacheOrFetchAvailability(
const chunkData = await this.getFreeBusyData(
{
timeMin: new Date(currentStartTime).toISOString(),
timeMax: new Date(currentEndTime).toISOString(),
items: calendarIds.map((id) => ({ id })),
},
shouldServeCache
}
);
if (chunkData) {
@@ -740,7 +669,6 @@ export default class GoogleCalendarService implements Calendar {
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[],
shouldServeCache?: boolean,
/**
* If true, we will fallback to the primary calendar if no valid selected calendars are found
*/
@@ -757,23 +685,9 @@ export default class GoogleCalendarService implements Calendar {
return [];
}
// Try cache first when we have selected calendar IDs
if (selectedCalendarIds.length > 0 && shouldServeCache !== false) {
const cachedResult = await this.tryGetAvailabilityFromCache(dateFrom, dateTo, selectedCalendarIds);
if (cachedResult) {
return cachedResult;
}
}
// Cache miss - proceed with API calls
this.log.debug(
"[Cache Miss] Proceeding with Google API calls",
safeStringify({ selectedCalendarIds, fallbackToPrimary })
);
try {
const calendarIds = await this.getCalendarIds(selectedCalendarIds, fallbackToPrimary);
return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo, shouldServeCache);
return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo);
} catch (error) {
this.log.error(
"There was an error getting availability from google calendar: ",
@@ -824,213 +738,6 @@ export default class GoogleCalendarService implements Calendar {
return !!cals.data.items;
}
/**
* calendarId is the externalId for the SelectedCalendar
* It doesn't check if the subscription has expired or not.
* It just creates a new subscription.
*/
async watchCalendar({
calendarId,
eventTypeIds,
}: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}) {
log.debug("watchCalendar", safeStringify({ calendarId, eventTypeIds }));
if (!process.env.GOOGLE_WEBHOOK_TOKEN) {
log.warn("GOOGLE_WEBHOOK_TOKEN is not set, skipping watching calendar");
return;
}
const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({
where: {
credentialId: this.credential.id,
externalId: calendarId,
integration: this.integrationName,
googleChannelId: {
not: null,
},
},
});
const otherCalendarsWithSameSubscription = allCalendarsWithSubscription.filter(
(sc) => !eventTypeIds?.includes(sc.eventTypeId)
);
let googleChannelProps: GoogleChannelProps = otherCalendarsWithSameSubscription.length
? {
kind: otherCalendarsWithSameSubscription[0].googleChannelKind,
id: otherCalendarsWithSameSubscription[0].googleChannelId,
resourceId: otherCalendarsWithSameSubscription[0].googleChannelResourceId,
resourceUri: otherCalendarsWithSameSubscription[0].googleChannelResourceUri,
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
}
: {};
if (!otherCalendarsWithSameSubscription.length) {
try {
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
} catch (error) {
this.log.error(`Failed to watch calendar ${calendarId}`, safeStringify(error));
throw error;
}
} else {
logger.info(
`Calendar ${calendarId} is already being watched for event types ${otherCalendarsWithSameSubscription.map(
(sc) => sc.eventTypeId
)}. So, not watching again and instead reusing the existing channel`
);
}
// FIXME: We shouldn't create SelectedCalendar, we should only update if exists
await this.upsertSelectedCalendarsForEventTypeIds(
{
externalId: calendarId,
googleChannelId: googleChannelProps.id,
googleChannelKind: googleChannelProps.kind,
googleChannelResourceId: googleChannelProps.resourceId,
googleChannelResourceUri: googleChannelProps.resourceUri,
googleChannelExpiration: googleChannelProps.expiration,
},
eventTypeIds
);
return googleChannelProps;
}
/**
* GoogleChannel subscription is only stopped when all selectedCalendars are un-watched.
*/
async unwatchCalendar({
calendarId,
eventTypeIds,
}: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}) {
const credentialId = this.credential.id;
const eventTypeIdsToBeUnwatched = eventTypeIds;
const calendarsWithSameCredentialId = await SelectedCalendarRepository.findMany({
where: {
credentialId,
},
});
const calendarWithSameExternalId = calendarsWithSameCredentialId.filter(
(sc) => sc.externalId === calendarId && sc.integration === this.integrationName
);
const calendarsWithSameExternalIdThatAreBeingWatched = calendarWithSameExternalId.filter(
(sc) => !!sc.googleChannelId
);
// Except those requested to be un-watched, other calendars are still being watched
const calendarsWithSameExternalIdToBeStillWatched = calendarsWithSameExternalIdThatAreBeingWatched.filter(
(sc) => !eventTypeIdsToBeUnwatched.includes(sc.eventTypeId)
);
if (calendarsWithSameExternalIdToBeStillWatched.length) {
logger.info(
`There are other ${calendarsWithSameExternalIdToBeStillWatched.length} calendars with the same externalId_credentialId. Not unwatching. Just removing the channelId from this selected calendar`
);
// CalendarCache still need to exist
// We still need to keep the subscription
// Just remove the google channel related fields from this selected calendar
await this.upsertSelectedCalendarsForEventTypeIds(
{
externalId: calendarId,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
},
eventTypeIdsToBeUnwatched
);
return;
}
const allChannelsForThisCalendarBeingUnwatched = calendarsWithSameExternalIdThatAreBeingWatched.map(
(sc) => ({
googleChannelResourceId: sc.googleChannelResourceId,
googleChannelId: sc.googleChannelId,
})
);
// Delete the calendar cache to force a fresh cache
await prisma.calendarCache.deleteMany({ where: { credentialId } });
await this.stopWatchingCalendarsInGoogle(allChannelsForThisCalendarBeingUnwatched);
await this.upsertSelectedCalendarsForEventTypeIds(
{
externalId: calendarId,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
},
eventTypeIdsToBeUnwatched
);
// Populate the cache back for the remaining calendars, if any
const remainingCalendars =
calendarsWithSameCredentialId.filter(
(sc) => sc.externalId !== calendarId && sc.integration === this.integrationName
) || [];
if (remainingCalendars.length > 0) {
await this.fetchAvailabilityAndSetCache(remainingCalendars);
}
}
async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise<void> {
log.debug("setAvailabilityInCache", safeStringify({ args, data }));
const calendarCache = await CalendarCache.init(null);
await calendarCache.upsertCachedAvailability({
credentialId: this.credential.id,
userId: this.credential.userId,
args,
value: JSON.parse(JSON.stringify(data)),
});
}
async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) {
this.log.debug("fetchAvailabilityAndSetCache", safeStringify({ selectedCalendars }));
const selectedCalendarsPerEventType = new Map<
SelectedCalendarEventTypeIds[number],
IntegrationCalendar[]
>();
// TODO: Should be done outside of CalendarService as it is applicable to all Apps' CalendarServices
selectedCalendars.reduce((acc, selectedCalendar) => {
const eventTypeId = selectedCalendar.eventTypeId ?? null;
const mapValue = selectedCalendarsPerEventType.get(eventTypeId);
if (mapValue) {
mapValue.push(selectedCalendar);
} else {
acc.set(eventTypeId, [selectedCalendar]);
}
return acc;
}, selectedCalendarsPerEventType);
for (const [_eventTypeId, selectedCalendars] of Array.from(selectedCalendarsPerEventType.entries())) {
const parsedArgs = {
/** Expand the start date to the start of the month to increase cache hits */
timeMin: getTimeMin(),
/** Expand the end date to the end of the month to increase cache hits */
timeMax: getTimeMax(),
// Dont use eventTypeId in key because it can be used by any eventType
// The only reason we are building it per eventType is because there can be different groups of calendars to lookup the availability for
items: selectedCalendars.map((sc) => ({ id: sc.externalId })),
};
const data = await this.fetchAvailability(parsedArgs);
await this.setAvailabilityInCache(parsedArgs, data);
}
// Update SelectedCalendar.updatedAt for all calendars under this credential
await SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {});
}
async createSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration" | "credentialId">
) {
File diff suppressed because it is too large Load Diff
@@ -1,135 +0,0 @@
import { expect, test, beforeEach, vi, describe } from "vitest";
import "vitest-fetch-mock";
import CalendarService from "../CalendarService";
describe("GoogleCalendarService.getFreeBusyResult - shouldServeCache logic", () => {
let calendarService: CalendarService;
let fetchAvailabilitySpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
calendarService = {} as CalendarService;
const mockFetchAvailability = vi.fn().mockResolvedValue({
calendars: {
"test@example.com": {
busy: [
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
],
},
},
});
calendarService.fetchAvailability = mockFetchAvailability;
fetchAvailabilitySpy = mockFetchAvailability;
calendarService.getFreeBusyResult = CalendarService.prototype.getFreeBusyResult.bind(calendarService);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(calendarService as any).credential = { id: 1, userId: 1 };
});
describe("shouldServeCache parameter handling", () => {
test("should call fetchAvailability immediately when shouldServeCache is explicitly false", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
const result = await calendarService.getFreeBusyResult(args, false);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is undefined (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
const result = await calendarService.getFreeBusyResult(args, undefined);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is null (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await calendarService.getFreeBusyResult(args, null as any);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is 0 (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await calendarService.getFreeBusyResult(args, 0 as any);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is empty string (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await calendarService.getFreeBusyResult(args, "" as any);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
});
});
@@ -60,7 +60,6 @@ const testCredential = {
describe("createMeeting", () => {
test("Successful `createMeeting` call", async () => {
prismaMock.calendarCache.findUnique;
const videoApi = VideoApiAdapter(testCredential);
@@ -109,7 +108,6 @@ describe("createMeeting", () => {
});
test(" `createMeeting` when there is no joinWebUrl and only joinUrl", async () => {
prismaMock.calendarCache.findUnique;
const videoApi = VideoApiAdapter(testCredential);
@@ -3,7 +3,6 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
@@ -19,8 +18,6 @@ import { showToast } from "@calcom/ui/components/toast";
interface CredentialActionsDropdownProps {
credentialId: number;
integrationType: string;
cacheUpdatedAt?: Date | null;
onSuccess?: () => void;
delegationCredentialId?: string | null;
disableConnectionModification?: boolean;
@@ -28,27 +25,14 @@ interface CredentialActionsDropdownProps {
export default function CredentialActionsDropdown({
credentialId,
integrationType,
cacheUpdatedAt,
onSuccess,
delegationCredentialId,
disableConnectionModification,
}: CredentialActionsDropdownProps) {
const { t } = useLocale();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);
const deleteCacheMutation = trpc.viewer.calendars.deleteCache.useMutation({
onSuccess: () => {
showToast(t("cache_deleted_successfully"), "success");
onSuccess?.();
},
onError: () => {
showToast(t("error_deleting_cache"), "error");
},
});
const utils = trpc.useUtils();
const disconnectMutation = trpc.viewer.credentials.delete.useMutation({
onSuccess: () => {
@@ -64,11 +48,9 @@ export default function CredentialActionsDropdown({
},
});
const isGoogleCalendar = integrationType === GOOGLE_CALENDAR_TYPE;
const canDisconnect = !delegationCredentialId && !disableConnectionModification;
const hasCache = isGoogleCalendar && cacheUpdatedAt;
if (!canDisconnect && !hasCache) {
if (!canDisconnect) {
return null;
}
@@ -79,37 +61,6 @@ export default function CredentialActionsDropdown({
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{hasCache && (
<>
<DropdownMenuItem className="focus:ring-muted">
<div className="px-2 py-1">
<div className="text-sm font-medium text-gray-900 dark:text-white">{t("cache_status")}</div>
<div className="text-xs text-gray-500 dark:text-white">
{t("cache_last_updated", {
timestamp: new Intl.DateTimeFormat("en-US", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(cacheUpdatedAt)),
interpolation: { escapeValue: false },
})}
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
color="destructive"
StartIcon="trash"
onClick={() => {
setDeleteModalOpen(true);
setDropdownOpen(false);
}}>
{t("delete_cached_data")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{canDisconnect && hasCache && <hr className="my-1" />}
{canDisconnect && (
<DropdownMenuItem className="outline-none">
<DropdownItem
@@ -127,19 +78,6 @@ export default function CredentialActionsDropdown({
</DropdownMenuContent>
</Dropdown>
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_cached_data")}
confirmBtnText={t("yes_delete_cache")}
onConfirm={() => {
deleteCacheMutation.mutate({ credentialId });
setDeleteModalOpen(false);
}}>
{t("confirm_delete_cache")}
</ConfirmationDialogContent>
</Dialog>
<Dialog open={disconnectModalOpen} onOpenChange={setDisconnectModalOpen}>
<ConfirmationDialogContent
variety="danger"
@@ -1,7 +1,6 @@
import { RegularBookingService } from "@calcom/features/bookings/lib/service/RegularBookingService";
import { bindModuleToClassOnToken, createModule } from "@calcom/features/di/di";
import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking";
import { moduleLoader as cacheModuleLoader } from "@calcom/features/di/modules/Cache";
import { moduleLoader as checkBookingAndDurationLimitsModuleLoader } from "@calcom/features/di/modules/CheckBookingAndDurationLimits";
import { moduleLoader as luckyUserServiceModuleLoader } from "@calcom/features/di/modules/LuckyUser";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
@@ -20,7 +19,6 @@ const loadModule = bindModuleToClassOnToken({
depsMap: {
// TODO: In a followup PR, we aim to remove prisma dependency and instead inject the repositories as dependencies.
prismaClient: prismaModuleLoader,
cacheService: cacheModuleLoader,
checkBookingAndDurationLimitsService: checkBookingAndDurationLimitsModuleLoader,
bookingRepository: bookingRepositoryModuleLoader,
luckyUserService: luckyUserServiceModuleLoader,
@@ -34,7 +34,6 @@ import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
@@ -410,7 +409,6 @@ function formatAvailabilitySnapshot(data: {
}
export interface IBookingServiceDependencies {
cacheService: CacheService;
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService;
prismaClient: PrismaClient;
bookingRepository: BookingRepository;
@@ -1,24 +0,0 @@
- googleChannelExpiration is a string which might cause problems with date/number comparisons like `lt` in prisma, which can possibly cause problems with date comparisons. Though not practical right now
## Responsibilities
- File:`/api/calendar-cache/cron` - Runs every minute and takes care of two things:
1. Watching calendars
- Identifies selectedCalendars that are not watched(identified by `googleChannelId` being null) and watches them
- When feature is enabled, it starts watching all related calendars
- SelectedCalendars with same `externalId` are considered same from CalendarCache perspective
- This is how a newly added SelectedCalendar record gets its googleChannel props set
- CalendarService.watchCalendar ensures that the new subscription is not created unnecessarily, reusing existing SelectedCalendar googleChannel props when possible.
- Identifies calendars that are watched but have their subscription about to expire(identified by `googleChannelExpiration` being less than current tomorrow's date) and watches them again
2. Unwatching calendars
- It takes care of cleaning up when the calendar-cache feature flag is disabled.
- File:`calendar-cache-cleanup`
- Deletes all CalendarCache records that have expired
- File:`googlecalendar/api/webhook`
- Populates CalendarCache records by fetching availability.
## Availability Checking Flow
- CalendarService.getAvailability is called
- It checks if CalendarCache exists for the calendar
- If it does, it fetches availability from CalendarCache
- If it doesn't, it fetches availability from the third party calendar service
- It doesn't populate/update CalendarCache. That is solely the responsibility of webhook
@@ -1,165 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import { CalendarCache } from "../calendar-cache";
const log = logger.getSubLogger({ prefix: ["CalendarCacheCron"] });
const validateRequest = (req: NextApiRequest) => {
const apiKey = req.headers.authorization || req.query.apiKey;
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
};
function logRejected(result: PromiseSettledResult<unknown>) {
if (result.status === "rejected") {
console.error(result.reason);
}
}
function getUniqueCalendarsByExternalId<
T extends {
externalId: string;
eventTypeId: number | null;
credentialId: number | null;
id: string;
}
>(calendars: T[]) {
type ExternalId = string;
return calendars.reduce(
(acc, sc) => {
if (!acc[sc.externalId]) {
acc[sc.externalId] = {
eventTypeIds: [sc.eventTypeId],
credentialId: sc.credentialId,
id: sc.id,
};
} else {
acc[sc.externalId].eventTypeIds.push(sc.eventTypeId);
}
return acc;
},
{} as Record<
ExternalId,
{
eventTypeIds: SelectedCalendarEventTypeIds;
credentialId: number | null;
id: string;
}
>
);
}
const handleCalendarsToUnwatch = async () => {
const calendarsToUnwatch = await SelectedCalendarRepository.getNextBatchToUnwatch(500);
const calendarsWithEventTypeIdsGroupedTogether = getUniqueCalendarsByExternalId(calendarsToUnwatch);
const result = await Promise.allSettled(
Object.entries(calendarsWithEventTypeIdsGroupedTogether).map(
async ([externalId, { eventTypeIds, credentialId, id }]) => {
if (!credentialId) {
// So we don't retry on next cron run
// FIXME: There could actually be multiple calendars with the same externalId and thus we need to technically update error for all of them
await SelectedCalendarRepository.setErrorInUnwatching({
id,
error: "Missing credentialId",
});
log.error("no credentialId for SelectedCalendar: ", id);
return;
}
try {
const cc = await CalendarCache.initFromCredentialId(credentialId);
await cc.unwatchCalendar({ calendarId: externalId, eventTypeIds });
await SelectedCalendarRepository.removeUnwatchingError({ id });
} catch (error) {
let errorMessage = "Unknown error";
if (error instanceof Error) {
errorMessage = error.message;
}
log.error(
`Error unwatching calendar ${externalId}`,
safeStringify({
selectedCalendarId: id,
error: errorMessage,
})
);
await SelectedCalendarRepository.setErrorInUnwatching({
id,
error: `${errorMessage}`,
});
}
}
)
);
log.info(`Processed ${result.length} calendars for unwatching`);
result.forEach(logRejected);
return result;
};
const handleCalendarsToWatch = async () => {
const calendarsToWatch = await SelectedCalendarRepository.getNextBatchToWatch(500);
const calendarsWithEventTypeIdsGroupedTogether = getUniqueCalendarsByExternalId(calendarsToWatch);
const result = await Promise.allSettled(
Object.entries(calendarsWithEventTypeIdsGroupedTogether).map(
async ([externalId, { credentialId, eventTypeIds, id }]) => {
if (!credentialId) {
// So we don't retry on next cron run
await SelectedCalendarRepository.setErrorInWatching({ id, error: "Missing credentialId" });
log.error("no credentialId for SelectedCalendar: ", id);
return;
}
try {
const cc = await CalendarCache.initFromCredentialId(credentialId);
await cc.watchCalendar({ calendarId: externalId, eventTypeIds });
await SelectedCalendarRepository.removeWatchingError({ id });
} catch (error) {
let errorMessage = "Unknown error";
if (error instanceof Error) {
errorMessage = error.message;
}
log.error(
`Error watching calendar ${externalId}`,
safeStringify({
selectedCalendarId: id,
error: errorMessage,
})
);
await SelectedCalendarRepository.setErrorInWatching({
id,
error: `${errorMessage}`,
});
}
}
)
);
log.info(`Processed ${result.length} calendars for watching`);
result.forEach(logRejected);
return result;
};
// This cron is used to activate and renew calendar subscriptions
const handler = defaultResponder(async (request: NextApiRequest) => {
validateRequest(request);
await Promise.allSettled([handleCalendarsToWatch(), handleCalendarsToUnwatch()]);
// TODO: Credentials can be installed on a whole team, check for selected calendars on the team
return {
executedAt: new Date().toISOString(),
};
});
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -1,32 +0,0 @@
import type { CalendarCache, Prisma } from "@calcom/prisma/client";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] };
export interface ICalendarCacheRepository {
watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise<any>;
unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise<any>;
upsertCachedAvailability({
credentialId,
userId,
args,
value,
}: {
credentialId: number;
userId: number | null;
args: FreeBusyArgs;
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue;
}): Promise<void>;
getCachedAvailability({
credentialId,
userId,
args,
}: {
credentialId: number;
userId: number | null;
args: FreeBusyArgs;
}): Promise<CalendarCache | null>;
getCacheStatusByCredentialIds(
credentialIds: number[]
): Promise<{ credentialId: number; updatedAt: Date | null }[]>;
}
@@ -1,31 +0,0 @@
import logger from "@calcom/lib/logger";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
const log = logger.getSubLogger({ prefix: ["CalendarCacheRepositoryMock"] });
export class CalendarCacheRepositoryMock implements ICalendarCacheRepository {
async watchCalendar() {
log.info(`Skipping watchCalendar due to calendar-cache being disabled`);
}
async upsertCachedAvailability() {
log.info(`Skipping upsertCachedAvailability due to calendar-cache being disabled`);
}
async getCachedAvailability() {
log.info(`Skipping getCachedAvailability due to calendar-cache being disabled`);
return null;
}
async unwatchCalendar() {
log.info(`Skipping unwatchCalendar due to calendar-cache being disabled`);
}
async deleteManyByCredential() {
log.info(`Skipping deleteManyByCredential due to calendar-cache being disabled`);
}
async getCacheStatusByCredentialIds() {
log.info(`Skipping getCacheStatusByCredentialIds due to calendar-cache being disabled`);
return [];
}
}
@@ -1,9 +0,0 @@
import { z } from "zod";
export const watchCalendarSchema = z.object({
kind: z.literal("api#channel"),
id: z.string(),
resourceId: z.string(),
resourceUri: z.string(),
expiration: z.string(),
});
@@ -1,188 +0,0 @@
import { uniqueBy } from "@calcom/lib/array";
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Calendar, SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
const log = logger.getSubLogger({ prefix: ["CalendarCacheRepository"] });
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ONE_MONTH_IN_MS = 30 * MS_PER_DAY;
const CACHING_TIME = ONE_MONTH_IN_MS;
function parseKeyForCache(args: FreeBusyArgs): string {
// Ensure that calendarIds are unique
const uniqueItems = uniqueBy(args.items, ["id"]);
const key = JSON.stringify({
timeMin: args.timeMin,
timeMax: args.timeMax,
items: uniqueItems,
});
return key;
}
type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] };
/**
* It means that caller can only work with DB Credentials
* In-memory delegation credentials aren't supported here. Delegation User Credentials, that are in DB and have credential.delegationCredential relation can be used though
*/
function assertCalendarHasDbCredential(calendar: Calendar | null) {
if (!calendar?.getCredentialId) {
return;
}
const credentialId = calendar.getCredentialId();
if (credentialId < 0) {
throw new Error(`Received invalid credentialId ${credentialId}`);
}
}
/**
* It means that caller can work with in-memory credential
*/
function declareCanWorkWithInMemoryCredential() {
// No assertion required here, it is for readability who reads the caller's code
}
export class CalendarCacheRepository implements ICalendarCacheRepository {
calendar: Calendar | null;
constructor(calendar: Calendar | null = null) {
this.calendar = calendar;
}
async watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }) {
assertCalendarHasDbCredential(this.calendar);
const { calendarId, eventTypeIds } = args;
if (typeof this.calendar?.watchCalendar !== "function") {
log.info(
'[handleWatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method'
);
return;
}
await this.calendar?.watchCalendar({ calendarId, eventTypeIds });
}
async unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }) {
assertCalendarHasDbCredential(this.calendar);
const { calendarId, eventTypeIds } = args;
if (typeof this.calendar?.unwatchCalendar !== "function") {
log.info(
'[unwatchCalendar] Skipping unwatching calendar due to calendar not having "unwatchCalendar" method'
);
return;
}
const response = await this.calendar?.unwatchCalendar({ calendarId, eventTypeIds });
return response;
}
async getCachedAvailability({
credentialId,
userId,
args,
}: {
credentialId: number;
userId: number | null;
args: FreeBusyArgs;
}) {
declareCanWorkWithInMemoryCredential();
log.debug("Getting cached availability", safeStringify({ credentialId, userId, args }));
const key = parseKeyForCache(args);
let cached;
let usedInMemoryDelegationCredential = false;
if (isInMemoryDelegationCredential({ credentialId })) {
usedInMemoryDelegationCredential = true;
if (!userId) {
log.warn("userId is not available when querying cache for in-memory delegation credential");
return null;
}
// We don't have credentialId available when querying the cache, as we use in-memory delegation credentials for this which don't have valid credentialId
// Also, we would prefer to reuse the existing calendar-cache(connected to regular credentials) when enabling delegation credentials, for which we can't use credentialId in querying as that is not in DB
// Security/Privacy wise, it is fine to query solely based on userId as userId and key(which has external email Ids in there) together can be used to uniquely identify the cache
// A user could have multiple third party calendars connected, but they key would still be different for each case in calendar-cache because of the presence of emails in there.
// Sample key: {"timeMin":"2025-04-01T00:00:00.000Z","timeMax":"2025-08-01T00:00:00.000Z","items":[{"id":"owner@example.com"}]} <- Notice it has emailId in there for which busytimes are fetched, we could assume that these emailIds would be unique across different calendars like Google/Outlook
cached = await prisma.calendarCache.findFirst({
// We have index on userId and key, so this should be fast
// TODO: Should we consider index on all three - userId, key and expiresAt?
where: {
userId,
key,
expiresAt: { gte: new Date(Date.now()) },
},
orderBy: {
// In case of multiple entries for same key and userId, we prefer the one with highest expiry, which will be the most updated one
// TODO: For better tracking we could also want to use updatedAt directly which doesn't exist yet in CalendarCache table
expiresAt: "desc",
},
});
} else {
cached = await prisma.calendarCache.findUnique({
where: {
credentialId_key: {
credentialId,
key,
},
expiresAt: { gte: new Date(Date.now()) },
},
});
}
log.info(
"Got cached availability",
safeStringify({ key, cached, credentialId, usedInMemoryDelegationCredential })
);
return cached;
}
async upsertCachedAvailability({
credentialId,
userId,
args,
value,
}: {
credentialId: number;
userId: number | null;
args: FreeBusyArgs;
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue;
}) {
assertCalendarHasDbCredential(this.calendar);
const key = parseKeyForCache(args);
await prisma.calendarCache.upsert({
where: {
credentialId_key: {
credentialId,
key,
},
},
update: {
// Ensure that on update userId is also set(It handles the case where userId is not set for legacy records)
userId,
value,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
create: {
value,
credentialId,
userId,
key,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
});
}
async getCacheStatusByCredentialIds(credentialIds: number[]) {
const cacheStatuses = await prisma.calendarCache.groupBy({
by: ["credentialId"],
where: {
credentialId: { in: credentialIds },
},
_max: {
updatedAt: true,
},
});
return cacheStatuses.map((cache) => ({
credentialId: cache.credentialId,
updatedAt: cache._max.updatedAt,
}));
}
}
@@ -1,31 +0,0 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { getCredentialForCalendarCache } from "@calcom/app-store/delegationCredential";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
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";
const log = logger.getSubLogger({ prefix: ["CalendarCache"] });
export class CalendarCache {
static async initFromCredentialId(credentialId: number): Promise<ICalendarCacheRepository> {
log.debug("initFromCredentialId", safeStringify({ credentialId }));
const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId });
const calendarForCalendarCache = await getCalendar(credentialForCalendarCache);
return await CalendarCache.init(calendarForCalendarCache);
}
static async init(calendar: Calendar | null): Promise<ICalendarCacheRepository> {
const featureRepo = new FeaturesRepository(prisma);
const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally(
"calendar-cache"
);
if (isCalendarCacheEnabledGlobally) return new CalendarCacheRepository(calendar);
return new CalendarCacheRepositoryMock();
}
}
@@ -1,97 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { getTimeMin, getTimeMax } from "./datesForCache";
describe("getTimeMin", () => {
// Tested on multiple dates
vi.setSystemTime("2025-04-24T00:00:13Z");
it("should return start of current month when no date is passed", () => {
const result = getTimeMin();
const expected = new Date();
expected.setUTCDate(1);
expected.setUTCHours(0, 0, 0, 0);
expect(result).toBe(expected.toISOString());
});
it("should return start of month for a given date", () => {
const result = getTimeMin("2025-03-15T10:30:00Z");
expect(result).toMatchInlineSnapshot(`"2025-03-01T00:00:00.000Z"`);
});
it("should handle dates at the start of month", () => {
const result = getTimeMin("2026-03-01T00:00:00Z");
expect(result).toMatchInlineSnapshot(`"2026-03-01T00:00:00.000Z"`);
});
it("should handle DST changes", () => {
const result = getTimeMin("2024-10-27T01:30:00Z");
expect(result).toMatchInlineSnapshot(`"2024-10-01T00:00:00.000Z"`);
});
});
describe("getTimeMax", () => {
it("should return the start of the overnext month when no date is passed", () => {
const result = getTimeMax();
const expected = new Date();
expected.setUTCMonth(expected.getUTCMonth() + 2);
expected.setUTCDate(1);
expected.setUTCHours(0, 0, 0, 0);
expect(result).toBe(expected.toISOString());
});
it("should return the start of overnext month for dates between start of current month and end of next month", () => {
const testDate = "2024-03-15T10:30:00Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-05-01T00:00:00.000Z"`);
});
it("should return end of month for dates beyond overnext month", () => {
const testDate = "2024-05-15T10:30:00Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-07-01T00:00:00.000Z"`);
});
it("should handle dates at the end of month", () => {
const testDate = "2024-03-31T23:59:59Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-05-01T00:00:00.000Z"`);
});
it("should handle October correctly (31 days)", () => {
const testDate = "2024-10-15T10:30:00Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-12-01T00:00:00.000Z"`);
});
it("should handle DST changes", () => {
const testDate = "2024-10-27T01:30:00Z"; // DST change in Europe
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-12-01T00:00:00.000Z"`);
});
it("should handle dates around DST changes in next month", () => {
const testDate = "2024-09-15T10:30:00Z"; // September, next month includes DST change
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2024-11-01T00:00:00.000Z"`);
});
it("should handle year changes", () => {
const testDate = "2024-12-24T23:59:59Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2025-02-01T00:00:00.000Z"`);
});
it("should handle next month", () => {
const testDate = "2025-05-01T02:59:59.999Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2025-06-01T00:00:00.000Z"`);
});
it("should handle special case where timeMax is more than 2 months from now but less than 3 months", () => {
vi.setSystemTime("2025-04-24T00:00:13Z");
const testDate = "2025-06-02T23:59:59.999Z";
const result = getTimeMax(testDate);
expect(result).toMatchInlineSnapshot(`"2025-06-01T00:00:00.000Z"`);
});
});
@@ -1,59 +0,0 @@
/** Expand the start date to the beginning of the current month */
export const getTimeMin = (timeMin?: string) => {
const date = timeMin ? new Date(timeMin) : new Date();
// Set to UTC to avoid timezone issues
const result = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
result.setUTCHours(0, 0, 0, 0);
return result.toISOString();
};
/**
* Expand the end date to the start of the overnext month if date
* is between start of current month and end of next month,
* otherwise return start of overnext month from the passed date
* @example
* Today: March 15, 2024 ▼-------------▼ getTimeMax returns May 1st 00:00:00
* Mar Apr May Jun
* ├────────┼────────┼────────┼────────┤
* Current Month █████████| | | |
* Next Month | █████████| | |
**/
export function getTimeMax(timeMax?: string) {
const now = new Date();
const currentMonth = now.getUTCMonth();
const currentYear = now.getUTCFullYear();
// If no date is passed, return start of the month two months from *now*.
if (!timeMax) {
const result = new Date(Date.UTC(currentYear, currentMonth + 2, 1));
result.setUTCHours(0, 0, 0, 0);
return result.toISOString();
}
// If a date is passed, determine the base month/year for calculation.
const date = new Date(timeMax);
const dateMonth = date.getUTCMonth();
const dateYear = date.getUTCFullYear();
let baseYear = currentYear;
let baseMonth = currentMonth;
// Check if date is within the current month or the next two months relative to *now*.
const isWithinCurrentOrNextTwoMonths =
(dateYear === currentYear && dateMonth <= currentMonth + 2) ||
(dateYear === currentYear + 1 &&
((currentMonth === 10 && dateMonth === 0) || (currentMonth === 11 && dateMonth <= 1)));
// If the input date is beyond the next two months relative to *now*,
// use the *input date's* month/year as the base.
if (!isWithinCurrentOrNextTwoMonths) {
baseYear = dateYear;
baseMonth = dateMonth;
}
// Otherwise, the base remains the current year/month.
// Calculate the start of the month two months after the determined base date.
const result = new Date(Date.UTC(baseYear, baseMonth + 2, 1));
result.setUTCHours(0, 0, 0, 0);
return result.toISOString();
}
@@ -1,93 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import { CacheService } from "./getShouldServeCache";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
describe("CacheService.getShouldServeCache", () => {
const mockFeaturesRepository: IFeaturesRepository = {
checkIfTeamHasFeature: vi.fn(),
checkIfFeatureIsEnabledGlobally: vi.fn(),
checkIfUserHasFeature: vi.fn(),
};
const cacheService = new CacheService({ featuresRepository: mockFeaturesRepository });
afterEach(() => {
vi.clearAllMocks();
});
describe("when shouldServeCache is explicitly set to boolean", () => {
it("should return true when shouldServeCache is true", async () => {
const result = await cacheService.getShouldServeCache(true, 123);
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
});
it("should return false when shouldServeCache is false", async () => {
const result = await cacheService.getShouldServeCache(false, 123);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
});
});
describe("when shouldServeCache is undefined", () => {
it("should return false when no teamId is provided", async () => {
const result = await cacheService.getShouldServeCache(undefined, undefined);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
});
it("should return false when teamId is null", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await cacheService.getShouldServeCache(undefined, null as any);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
});
it("should return false when teamId is 0", async () => {
const result = await cacheService.getShouldServeCache(undefined, 0);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
});
it("should check feature repository when teamId is provided and return true if feature is enabled", async () => {
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true);
const result = await cacheService.getShouldServeCache(undefined, 123);
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(123, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
});
it("should check feature repository when teamId is provided and return false if feature is disabled", async () => {
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(false);
const result = await cacheService.getShouldServeCache(undefined, 456);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(456, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
});
});
describe("edge cases", () => {
it("should prioritize explicit shouldServeCache over teamId check", async () => {
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true);
const result = await cacheService.getShouldServeCache(false, 123);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
});
it("should handle positive teamId correctly", async () => {
vi.mocked(mockFeaturesRepository.checkIfTeamHasFeature).mockResolvedValue(true);
const result = await cacheService.getShouldServeCache(undefined, 999);
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(999, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
});
});
});
@@ -1,16 +0,0 @@
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
export interface ICacheService {
featuresRepository: IFeaturesRepository;
}
export class CacheService {
constructor(private readonly dependencies: ICacheService) {}
async getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) {
if (typeof shouldServeCache === "boolean") return shouldServeCache;
if (!teamId) return false;
return await this.dependencies.featuresRepository.checkIfTeamHasFeature(teamId, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
}
}
@@ -7,7 +7,6 @@ import { createContainer } from "../di";
import { availableSlotsModule } from "../modules/AvailableSlots";
import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
import { cacheModule } from "../modules/Cache";
import { checkBookingLimitsModule } from "../modules/CheckBookingLimits";
import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features";
@@ -35,7 +34,6 @@ container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
container.load(DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY_MODULE, routingFormResponseRepositoryModule);
container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule);
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
-16
View File
@@ -1,16 +0,0 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { prismaModule } from "@calcom/features/di/modules/Prisma";
import type { CacheService } from "../../../features/calendar-cache/lib/getShouldServeCache";
import { createContainer } from "../di";
import { cacheModule } from "../modules/Cache";
import { featuresRepositoryModule } from "../modules/Features";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule);
export function getCacheService() {
return container.get<CacheService>(DI_TOKENS.CACHE_SERVICE);
}
@@ -15,7 +15,6 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
cacheService: DI_TOKENS.CACHE_SERVICE,
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE,
busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
-23
View File
@@ -1,23 +0,0 @@
import { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di";
import { DI_TOKENS } from "../tokens";
import { moduleLoader as featuresRepositoryModuleLoader } from "./Features";
export const cacheModule = createModule();
const token = DI_TOKENS.CACHE_SERVICE;
const moduleToken = DI_TOKENS.CACHE_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: cacheModule,
moduleToken,
token,
classs: CacheService,
depsMap: {
featuresRepository: featuresRepositoryModuleLoader,
},
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
-2
View File
@@ -32,8 +32,6 @@ export const DI_TOKENS = {
INSIGHTS_BOOKING_SERVICE_MODULE: Symbol("InsightsBookingServiceModule"),
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
CACHE_SERVICE: Symbol("CacheService"),
CACHE_SERVICE_MODULE: Symbol("CacheServiceModule"),
CHECK_BOOKING_LIMITS_SERVICE: Symbol("CheckBookingLimitsService"),
CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"),
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"),
@@ -70,8 +70,6 @@ const ConnectedCalendarList = ({
<div className="flex w-32 justify-end">
<CredentialActionsDropdown
credentialId={connectedCalendar.credentialId}
integrationType={connectedCalendar.integration.type}
cacheUpdatedAt={connectedCalendar.cacheUpdatedAt}
onSuccess={onChanged}
delegationCredentialId={connectedCalendar.delegationCredentialId}
disableConnectionModification={disableConnectionModification}
@@ -123,8 +121,6 @@ const ConnectedCalendarList = ({
<div className="flex w-32 justify-end">
<CredentialActionsDropdown
credentialId={connectedCalendar.credentialId}
integrationType={connectedCalendar.integration.type}
cacheUpdatedAt={connectedCalendar.cacheUpdatedAt}
onSuccess={onChanged}
delegationCredentialId={connectedCalendar.delegationCredentialId}
disableConnectionModification={disableConnectionModification}
@@ -151,7 +147,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
const query = trpc.viewer.calendars.connectedCalendars.useQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
eventTypeId: scope === SelectedCalendarSettingsScope.EventType ? eventTypeId! : null,
},
{
-1
View File
@@ -128,4 +128,3 @@ export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEma
export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publicViewer/checkIfUserEmailVerificationRequired.handler";
export { TeamService } from "@calcom/features/ee/teams/services/teamService";
export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
@@ -1,12 +1,9 @@
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TAdminToggleFeatureFlagSchema } from "./toggleFeatureFlag.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: { id: number };
prisma: PrismaClient;
};
input: TAdminToggleFeatureFlagSchema;
@@ -16,7 +13,6 @@ 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 },
@@ -24,13 +20,3 @@ export const toggleFeatureFlagHandler = async (opts: GetOptions) => {
};
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();
}
}
@@ -1,15 +1,8 @@
import { z } from "zod";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
type CalendarsRouterHandlerCache = {
connectedCalendars?: typeof import("./connectedCalendars.handler").connectedCalendarsHandler;
setDestinationCalendar?: typeof import("./setDestinationCalendar.handler").setDestinationCalendarHandler;
};
export const calendarsRouter = router({
connectedCalendars: authedProcedure.input(ZConnectedCalendarsInputSchema).query(async ({ ctx, input }) => {
const { connectedCalendarsHandler } = await import("./connectedCalendars.handler");
@@ -24,11 +17,4 @@ export const calendarsRouter = router({
return setDestinationCalendarHandler({ ctx, input });
}),
deleteCache: authedProcedure
.input(z.object({ credentialId: z.number() }))
.mutation(async ({ ctx, input }) => {
const { deleteCacheHandler } = await import("./deleteCache.handler");
return deleteCacheHandler({ ctx, input });
}),
});
@@ -1,4 +1,3 @@
import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -24,15 +23,9 @@ export const connectedCalendarsHandler = async ({ ctx, input }: ConnectedCalenda
prisma,
});
const credentialIds = connectedCalendars.map((cal) => cal.credentialId);
const cacheRepository = new CalendarCacheRepository();
const cacheStatuses = await cacheRepository.getCacheStatusByCredentialIds(credentialIds);
const cacheStatusMap = new Map(cacheStatuses.map((cache) => [cache.credentialId, cache.updatedAt]));
const enrichedConnectedCalendars = connectedCalendars.map((calendar) => ({
...calendar,
cacheUpdatedAt: cacheStatusMap.get(calendar.credentialId) || null,
cacheUpdatedAt: null,
}));
return {
@@ -1,33 +0,0 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type DeleteCacheOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: {
credentialId: number;
};
};
export const deleteCacheHandler = async ({ ctx, input }: DeleteCacheOptions) => {
const { user } = ctx;
const { credentialId } = input;
const credential = await prisma.credential.findFirst({
where: {
id: credentialId,
userId: user.id,
},
});
if (!credential) {
throw new Error("Credential not found or access denied");
}
await prisma.calendarCache.deleteMany({
where: { credentialId },
});
return { success: true };
};
@@ -19,7 +19,6 @@ import type { QualifiedHostsService } from "@calcom/features/bookings/lib/host-f
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import type { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
@@ -83,6 +82,7 @@ export interface IGetAvailableSlots {
emoji?: string | undefined;
}[]
>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
troubleshooter?: any;
}
@@ -99,7 +99,6 @@ export interface IAvailableSlotsService {
bookingRepo: BookingRepository;
eventTypeRepo: EventTypeRepository;
routingFormResponseRepo: RoutingFormResponseRepository;
cacheService: CacheService;
checkBookingLimitsService: CheckBookingLimitsService;
userAvailabilityService: UserAvailabilityService;
busyTimesService: BusyTimesService;
@@ -460,7 +459,7 @@ export class AvailableSlotsService {
rescheduleUid,
timeZone,
});
} catch (_) {
} catch {
limitManager.addBusyTime(periodStart, unit, timeZone);
if (
periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone))
@@ -661,7 +660,7 @@ export class AvailableSlotsService {
includeManagedEvents,
timeZone,
});
} catch (_) {
} catch {
limitManager.addBusyTime(periodStart, unit, timeZone);
if (
periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone))