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:
-15
@@ -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);
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"),
|
||||
|
||||
+1
-5
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user