From 78ba17e5731abf29581c1499e00440a0db2d4376 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 14:38:18 -0300 Subject: [PATCH 01/49] feat: calendar cache and sync - wip --- packages/app-store/_utils/getCalendar.ts | 54 ++-- .../features/calendar-subscription/README.md | 10 + .../adapters/AdaptersFactory.ts | 27 ++ .../GoogleCalendarSubscription.adapter.ts | 176 ++++++++++++ .../Office365CalendarSubscription.adapter.ts | 251 ++++++++++++++++++ .../lib/CalendarSubscriptionPort.interface.ts | 88 ++++++ .../lib/CalendarSubscriptionService.ts | 177 ++++++++++++ .../CalendarCacheEventRepository.interface.ts | 36 +++ .../lib/cache/CalendarCacheEventRepository.ts | 58 ++++ .../lib/cache/CalendarCacheEventService.ts | 85 ++++++ .../lib/cache/CalendarCacheWrapper.ts | 132 +++++++++ .../lib/sync/CalendarSyncService.ts | 67 +++++ packages/features/flags/config.ts | 2 + .../features/flags/features.repository.ts | 2 + .../repository/SelectedCalendarRepository.ts | 44 +++ packages/prisma/schema.prisma | 57 +++- turbo.json | 5 +- 17 files changed, 1251 insertions(+), 20 deletions(-) create mode 100644 packages/features/calendar-subscription/README.md create mode 100644 packages/features/calendar-subscription/adapters/AdaptersFactory.ts create mode 100644 packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts create mode 100644 packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts create mode 100644 packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts create mode 100644 packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts create mode 100644 packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts create mode 100644 packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts create mode 100644 packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts create mode 100644 packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts create mode 100644 packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts create mode 100644 packages/lib/server/repository/SelectedCalendarRepository.ts diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 1c3d3d3e6ab111..69ab1f4b7e3c6c 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -1,31 +1,20 @@ +import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; +import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import { CalendarCacheWrapper } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheWrapper"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; -import type { Calendar, CalendarClass } from "@calcom/types/Calendar"; +import { prisma } from "@calcom/prisma"; +import type { Calendar } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; import { CalendarServiceMap } from "../calendar.services.generated"; -interface CalendarApp { - lib: { - CalendarService: CalendarClass; - }; -} - const log = logger.getSubLogger({ prefix: ["CalendarManager"] }); -/** - * @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) - */ -const isCalendarService = (x: unknown): x is CalendarApp => - !!x && - typeof x === "object" && - "lib" in x && - typeof x.lib === "object" && - !!x.lib && - "CalendarService" in x.lib; - export const getCalendar = async ( credential: CredentialForCalendarService | null ): Promise => { + console.log("getCalendar", credential); if (!credential || !credential.key) return null; let { type: calendarType } = credential; if (calendarType?.endsWith("_other_calendar")) { @@ -53,5 +42,34 @@ export const getCalendar = async ( return null; } + // check if Calendar Cache is supported and enabled + if (CalendarCacheEventService.isAppSupported(credential.appId)) { + console.log("Calendar Cache is supported and enabled", JSON.stringify(credential)); + log.debug( + `Using regular CalendarService for credential ${credential.id} (not Google or Office365 Calendar)` + ); + const featuresRepository = new FeaturesRepository(prisma); + const isCalendarSubscriptionCacheEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "calendar-subscription-cache" + ); + + if (isCalendarSubscriptionCacheEnabled) { + log.debug( + `calendar-subscription-cache is enabled, using CalendarCacheService for credential ${credential.id}` + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalCalendar = new CalendarService(credential as any); + if (originalCalendar) { + // return cacheable calendar + const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); + return new CalendarCacheWrapper({ + originalCalendar, + calendarCacheEventRepository, + }); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any return new CalendarService(credential as any); }; diff --git a/packages/features/calendar-subscription/README.md b/packages/features/calendar-subscription/README.md new file mode 100644 index 00000000000000..ec5d552ab697f0 --- /dev/null +++ b/packages/features/calendar-subscription/README.md @@ -0,0 +1,10 @@ +# Calendar Sync Feature + +CalendarSync feature ... + + +## Feature Flag + + +## Structure + diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts new file mode 100644 index 00000000000000..8f1f1adf6c985d --- /dev/null +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -0,0 +1,27 @@ +import { GoogleCalendarSubscriptionAdapter } from "../adapters/GoogleCalendarSubscription.adapter"; +import type { ICalendarSubscriptionPort } from "../lib/CalendarSubscriptionPort.interface"; +import { Office365CalendarSubscriptionAdapter } from "./Office365CalendarSubscription.adapter"; + +export type CalendarSubscriptionProvider = "google_calendar" | "office365_calendar"; + +export interface AdapterFactory { + get(provider: CalendarSubscriptionProvider): ICalendarSubscriptionPort; +} + +/** + * Default adapter factory + */ +export class DefaultAdapterFactory implements AdapterFactory { + private singletons = { + google_calendar: new GoogleCalendarSubscriptionAdapter(), + office365_calendar: new Office365CalendarSubscriptionAdapter(), + } as const; + + get(provider: CalendarSubscriptionProvider): ICalendarSubscriptionPort { + const adapter = this.singletons[provider]; + if (!adapter) { + throw new Error(`No adapter found for provider ${provider}`); + } + return adapter; + } +} diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts new file mode 100644 index 00000000000000..25d17d55e89e78 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -0,0 +1,176 @@ +import type { calendar_v3 } from "@googleapis/calendar"; +import { v4 as uuid } from "uuid"; + +import { CalendarAuth } from "@calcom/app-store/googlecalendar/lib/CalendarAuth"; +import logger from "@calcom/lib/logger"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import type { + ICalendarSubscriptionPort, + CalendarSubscriptionResult, + CalendarSubscriptionEvent, + CalendarSubscriptionEventItem, + CalendarCredential, + CalendarSubscriptionWebhookContext, +} from "../lib/CalendarSubscriptionPort.interface"; + +const log = logger.getSubLogger({ prefix: ["GoogleCalendarSubscriptionAdapter"] }); + +/** + * Google Calendar Subscription Adapter + * + * This adapter uses the Google Calendar API to create and manage calendar subscriptions + * @see https://developers.google.com/google-apps/calendar/quickstart/nodejs + */ +export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionPort { + private GOOGLE_WEBHOOK_TOKEN = process.env.GOOGLE_WEBHOOK_TOKEN; + private GOOGLE_WEBHOOK_URL = process.env.GOOGLE_WEBHOOK_URL; + + async validate(context: CalendarSubscriptionWebhookContext): Promise { + const token = context?.headers?.get("X-Goog-Channel-Token"); + if (!this.GOOGLE_WEBHOOK_TOKEN) { + log.warn("GOOGLE_WEBHOOK_TOKEN not configured"); + return false; + } + if (token !== this.GOOGLE_WEBHOOK_TOKEN) { + log.warn("Invalid webhook token", { token }); + return false; + } + return true; + } + + async extractChannelId(context: CalendarSubscriptionWebhookContext): Promise { + const channelId = context?.headers?.get("X-Goog-Channel-ID"); + if (!channelId) { + log.warn("Missing channel ID in webhook"); + return null; + } + return channelId; + } + + async subscribe( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise { + log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId }); + selectedCalendar; + + const MONTH_IN_SECONDS = 60 * 60 * 24 * 30; + + const client = await this.getClient(credential); + const result = await client.events.watch({ + calendarId: selectedCalendar.externalId, + requestBody: { + id: uuid(), + type: "web_hook", + address: this.GOOGLE_WEBHOOK_URL, + token: this.GOOGLE_WEBHOOK_TOKEN, + params: { + ttl: MONTH_IN_SECONDS.toFixed(0), + }, + }, + }); + + const e = result.data?.expiration; + const expiration = e ? new Date(/^\d+$/.test(e) ? +e : e) : null; + + return { + provider: "google_calendar", + id: result.data.id, + resourceId: result.data.resourceId, + resourceUri: result.data.resourceUri, + expiration, + }; + } + async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise { + log.debug("Attempt to unsubscribe from Google Calendar", { externalId: selectedCalendar.externalId }); + + const client = await this.getClient(credential); + await client.channels + .stop({ + requestBody: { + resourceId: selectedCalendar.channelResourceId, + }, + }) + .catch((err) => { + log.error("Error unsubscribing from Google Calendar", err); + throw err; + }); + } + + async fetchEvents( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise { + const client = await this.getClient(credential); + + let syncToken = selectedCalendar.syncToken || undefined; + let pageToken; + + const events: calendar_v3.Schema$Event[] = []; + do { + const { data }: { data: calendar_v3.Schema$Events } = await client.events.list({ + calendarId: selectedCalendar.externalId, + syncToken, + pageToken, + singleEvents: true, + }); + + syncToken = data.nextSyncToken || syncToken; + pageToken = data.nextPageToken ?? null; + + events.push(...(data.items || [])); + } while (pageToken); + + return { + provider: "google_calendar", + syncToken: syncToken || null, + items: this.parseEvents(events), + }; + } + + private parseEvents(events: calendar_v3.Schema$Event[]): CalendarSubscriptionEventItem[] { + const now = new Date(); + return events + .map((event) => { + const busy = event.transparency === "opaque"; // opaque = busy, transparent = free + + const start = event.start?.dateTime + ? new Date(event.start.dateTime) + : event.start?.date + ? new Date(event.start.date) + : new Date(); + + const end = event.end?.dateTime + ? new Date(event.end.dateTime) + : event.end?.date + ? new Date(event.end.date) + : new Date(); + + return { + id: event.id, + iCalUID: event.iCalUID, + start, + end, + busy, + summary: event.summary, + description: event.description, + location: event.location, + kind: event.kind, + status: event.status, + isAllDay: !!event.start?.date && !event.start?.dateTime, + timeZone: event.start?.timeZone || null, + originalStartTime: event.originalStartTime?.dateTime, + createdAt: event.created ? new Date(event.created) : null, + updatedAt: event.updated ? new Date(event.updated) : null, + }; + }) + .filter((e) => !!e.id) // safely remove events with no ID + .filter((e) => e.start < now); // remove old events + } + + private async getClient(credential: CalendarCredential) { + const auth = new CalendarAuth(credential); + return await auth.getClient(); + } +} diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts new file mode 100644 index 00000000000000..7d3470f8ef841a --- /dev/null +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -0,0 +1,251 @@ +import logger from "@calcom/lib/logger"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import type { + CalendarSubscriptionEvent, + ICalendarSubscriptionPort, + CalendarSubscriptionResult, + CalendarCredential, + CalendarSubscriptionWebhookContext, + CalendarSubscriptionEventItem, +} from "../lib/CalendarSubscriptionPort.interface"; + +const log = logger.getSubLogger({ prefix: ["MicrosoftCalendarSubscriptionAdapter"] }); + +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +type GraphClient = { accessToken: string }; + +interface MicrosoftGraphEvent { + id: string; + iCalUId?: string; + subject?: string; + bodyPreview?: string; + location?: { displayName?: string }; + start?: { dateTime: string; timeZone: string }; + end?: { dateTime: string; timeZone: string }; + showAs?: "free" | "tentative" | "busy" | "oof" | "workingElsewhere" | "unknown"; + isAllDay?: boolean; + isCancelled?: boolean; + type?: string; +} + +interface MicrosoftGraphEventsResponse { + "@odata.nextLink"?: string; + "@odata.deltaLink"?: string; + value: MicrosoftGraphEvent[]; +} + +interface MicrosoftGraphSubscriptionReq { + resource: string; + changeType: string; + notificationUrl: string; + expirationDateTime: string; + clientState?: string; +} + +interface MicrosoftGraphSubscriptionRes { + id: string; + resource: string; + expirationDateTime: string; +} + +type AdapterConfig = { + baseUrl?: string; + webhookToken?: string | null; + webhookUrl?: string | null; + // duração padrão ~3 dias (Graph ~4230 min) + subscriptionTtlMs?: number; +}; + +/** + * Office365 Calendar Subscription Adapter + * + * This adapter uses the Microsoft Graph API to create and manage calendar subscriptions + * @see https://docs.microsoft.com/en-us/graph/api/resources/subscription + */ +export class Office365CalendarSubscriptionAdapter implements ICalendarSubscriptionPort { + private readonly baseUrl: string; + private readonly webhookToken?: string | null; + private readonly webhookUrl?: string | null; + private readonly subscriptionTtlMs: number; + + constructor(cfg: AdapterConfig = {}) { + this.baseUrl = cfg.baseUrl ?? "https://graph.microsoft.com/v1.0"; + this.webhookToken = cfg.webhookToken ?? process.env.MICROSOFT_WEBHOOK_TOKEN ?? null; + this.webhookUrl = cfg.webhookUrl ?? process.env.MICROSOFT_WEBHOOK_URL ?? null; + this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000; + } + + async validate(context: CalendarSubscriptionWebhookContext): Promise { + // validate handshake + const validationToken = context?.query?.get("validationToken"); + if (validationToken) return true; + + // validate notifications + const clientState = + context?.headers?.get("clientState") ?? + (typeof context?.body === "object" && context.body !== null && "clientState" in context.body + ? (context.body as { clientState?: string }).clientState + : undefined); + if (!this.webhookToken) { + log.warn("MICROSOFT_WEBHOOK_TOKEN missing"); + return false; + } + if (clientState !== this.webhookToken) { + log.warn("Invalid clientState", { clientState }); + return false; + } + return true; + } + + async extractChannelId(context: CalendarSubscriptionWebhookContext): Promise { + let id: string | null = null; + if (context?.body && typeof context.body === "object" && "subscriptionId" in context.body) { + id = (context.body as { subscriptionId?: string }).subscriptionId ?? null; + } else if (context?.headers?.get("subscriptionId")) { + id = context.headers.get("subscriptionId"); + } + if (!id) { + log.warn("subscriptionId missing in webhook"); + } + return id; + } + + async subscribe( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise { + if (!this.webhookUrl || !this.webhookToken) { + throw new Error("Webhook config missing (MICROSOFT_WEBHOOK_URL/TOKEN)"); + } + + const expirationDateTime = new Date(Date.now() + this.subscriptionTtlMs).toISOString(); + + const body: MicrosoftGraphSubscriptionReq = { + resource: `me/calendars/${selectedCalendar.externalId}/events`, + changeType: "created,updated,deleted", + notificationUrl: this.webhookUrl, + expirationDateTime, + clientState: this.webhookToken, + }; + + const client = await this.getGraphClient(credential); + const res = await this.request(client, "POST", "/subscriptions", body); + + return { + provider: "office365_calendar", + id: res.id, + resourceId: res.resource, + resourceUri: `${this.baseUrl}/${res.resource}`, + expiration: new Date(res.expirationDateTime), + }; + } + + async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise { + const subId = selectedCalendar.channelResourceId; + if (!subId) return; + + const client = await this.getGraphClient(credential); + await this.request(client, "DELETE", `/subscriptions/${subId}`); + } + + async fetchEvents( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise { + const client = await this.getGraphClient(credential); + + let deltaLink = selectedCalendar.syncToken ?? null; + const items: MicrosoftGraphEvent[] = []; + + if (deltaLink) { + const path = this.stripBase(deltaLink); + const r = await this.request(client, "GET", path); + items.push(...r.value); + deltaLink = r["@odata.deltaLink"] ?? deltaLink; + } else { + let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`; + while (next) { + const r: MicrosoftGraphEventsResponse = await this.request( + client, + "GET", + next + ); + items.push(...r.value); + deltaLink = r["@odata.deltaLink"] ?? deltaLink; + next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null; + } + } + + return { + provider: "office365_calendar", + syncToken: deltaLink, + items: this.parseEvents(items), + }; + } + + private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] { + return events + .map((e) => { + const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof"; + const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date(); + const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date(); + + return { + id: e.id, + iCalUID: e.iCalUId ?? e.id, + start, + end, + busy, + summary: e.subject, + description: e.bodyPreview, + location: e.location?.displayName, + kind: e.type ?? "microsoftgraph#event", + status: e.isCancelled ? "cancelled" : "confirmed", + isAllDay: !!e.isAllDay, + timeZone: e.start?.timeZone ?? null, + }; + }) + .filter((i: CalendarSubscriptionEventItem) => Boolean(i.id)); + } + + private async getGraphClient(credential: CalendarCredential): Promise { + const accessToken = credential.delegatedTo?.serviceAccountKey?.private_key ?? (credential.key as string); + if (!accessToken) throw new Error("Missing Microsoft access token"); + return { accessToken }; + } + + private stripBase(urlOrPath: string): string { + return urlOrPath.startsWith("http") ? urlOrPath.replace(this.baseUrl, "") : urlOrPath; + } + + private async request( + client: GraphClient, + method: HttpMethod, + endpoint: string, + data?: unknown + ): Promise { + const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`; + const headers: Record = { + Authorization: `Bearer ${client.accessToken}`, + "Content-Type": "application/json", + }; + + const init: RequestInit = { method, headers }; + if (data && (method === "POST" || method === "PUT" || method === "PATCH")) { + init.body = JSON.stringify(data); + } + + const res = await fetch(url, init); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + log.error("Graph API error", { method, endpoint: this.stripBase(url), status: res.status, text }); + throw new Error(`Graph ${res.status} ${res.statusText}`); + } + + if (method === "DELETE" || res.status === 204) return {} as T; + + return (await res.json()) as T; + } +} diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts new file mode 100644 index 00000000000000..e79ae5347d25c9 --- /dev/null +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -0,0 +1,88 @@ +import type { SelectedCalendar } from "@calcom/prisma/client"; +import type { + CredentialForCalendarService, + CredentialForCalendarServiceWithEmail, +} from "@calcom/types/Credential"; + +import type { CalendarSubscriptionProvider } from "../adapters/AdaptersFactory"; + +export type CalendarSubscriptionWebhookContext = { + headers?: Headers; + query?: URLSearchParams; + body?: { value?: { subscriptionId?: string }[] } | unknown; +}; + +export type CalendarSubscriptionResult = { + provider: CalendarSubscriptionProvider; + id?: string | null; + resourceId?: string | null; + resourceUri?: string | null; + expiration?: Date | null; +}; + +export type CalendarSubscriptionEventItem = { + id?: string | null; + iCalUID?: string | null; + start?: Date; + end?: Date; + busy: boolean; + isAllDay?: boolean; + summary?: string | null; + description?: string | null; + kind?: string | null; + status?: string | null; + location?: string | null; + originalStartDate?: Date | null; + timeZone?: string | null; + createdAt?: Date | null; + updatedAt?: Date | null; +}; + +export type CalendarSubscriptionEvent = { + provider: CalendarSubscriptionProvider; + syncToken: string | null; + items: CalendarSubscriptionEventItem[]; +}; + +export type CalendarCredential = CredentialForCalendarServiceWithEmail; + +/** + * Calendar Subscription Port + */ +export interface ICalendarSubscriptionPort { + /** + * Validates a webhook request + * @param context + */ + validate(context: CalendarSubscriptionWebhookContext): Promise; + + /** + * Extracts channel ID from a webhook request + * @param request + */ + extractChannelId(context: CalendarSubscriptionWebhookContext): Promise; + + /** + * Subscribes to a calendar + * @param selectedCalendar + */ + subscribe( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise; + + /** + * Unsubscribes from a calendar + * @param selectedCalendar + */ + unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise; + + /** + * Pulls events from a calendar + * @param selectedCalendar + */ + fetchEvents( + selectedCalendar: SelectedCalendar, + credential: CredentialForCalendarService + ): Promise; +} diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts new file mode 100644 index 00000000000000..4548dcc9f01fdc --- /dev/null +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -0,0 +1,177 @@ +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; +import logger from "@calcom/lib/logger"; +import type { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import { prisma } from "@calcom/prisma"; + +import type { AdapterFactory, CalendarSubscriptionProvider } from "../adapters/AdaptersFactory"; +import type { + CalendarCredential, + CalendarSubscriptionWebhookContext, + CalendarSubscriptionEvent, +} from "../lib/CalendarSubscriptionPort.interface"; + +const log = logger.getSubLogger({ prefix: ["CalendarSubscriptionService"] }); + +export class CalendarSubscriptionService { + constructor( + private deps: { + adapterFactory: AdapterFactory; + selectedCalendarRepository: SelectedCalendarRepository; + featuresRepository: FeaturesRepository; + } + ) {} + + /** + * Subscribe to a calendar + */ + async subscribe(selectedCalendarId: string): Promise { + log.debug("subscribe", { selectedCalendarId }); + const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId); + if (!selectedCalendar?.credentialId) { + log.debug("Selected calendar not found", { selectedCalendarId }); + return; + } + + const credential = await this.getCalendarCredential(selectedCalendar.credentialId); + if (!credential) { + log.debug("Calendar credential not found", { selectedCalendarId }); + return; + } + + const calendarSubscriptionAdapter = this.deps.adapterFactory.get( + selectedCalendar.integration as CalendarSubscriptionProvider + ); + const res = await calendarSubscriptionAdapter.subscribe(selectedCalendar, credential); + + await this.deps.selectedCalendarRepository.updateById(selectedCalendarId, { + channelId: res?.id, + channelResourceId: res?.resourceId, + channelResourceUri: res?.resourceUri, + channelKind: res?.provider, + channelExpiration: res?.expiration, + syncSubscribedAt: new Date(), + }); + } + + /** + * Unsubscribe from a calendar + */ + async unsubscribe(selectedCalendarId: string): Promise { + log.debug("unsubscribe", { selectedCalendarId }); + const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId); + if (!selectedCalendar?.credentialId) return; + + const credential = await this.getCalendarCredential(selectedCalendar.credentialId); + if (!credential) return; + + const calendarSubscriptionAdapter = this.deps.adapterFactory.get( + selectedCalendar.integration as CalendarSubscriptionProvider + ); + await Promise.all([ + calendarSubscriptionAdapter.unsubscribe(selectedCalendar, credential), + this.deps.selectedCalendarRepository.updateById(selectedCalendarId, { + syncSubscribedAt: null, + }), + ]); + } + + /** + * Process webhook + */ + async processWebhook(provider: CalendarSubscriptionProvider, context: CalendarSubscriptionWebhookContext) { + log.debug("processWebhook", { provider }); + const calendarSubscriptionAdapter = this.deps.adapterFactory.get(provider); + + const isValid = await calendarSubscriptionAdapter.validate(context); + if (!isValid) throw new Error("Invalid webhook request"); + + const channelId = await calendarSubscriptionAdapter.extractChannelId(context); + if (!channelId) throw new Error("Missing channel ID in webhook"); + + log.debug("Processing webhook", { channelId }); + const [selectedCalendar, cacheEnabled, syncEnabled] = await Promise.all([ + this.deps.selectedCalendarRepository.findByChannelId(channelId), + this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-cache"), + this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-sync"), + ]); + + if (!selectedCalendar?.credentialId || (!cacheEnabled && !syncEnabled)) { + log.debug("Selected calendar not found", { channelId }); + return; + } + + const credential = await this.getCalendarCredential(selectedCalendar.credentialId); + if (!credential) return; + + let events: CalendarSubscriptionEvent | null = null; + try { + events = await calendarSubscriptionAdapter.fetchEvents(selectedCalendar, credential); + } catch (err) { + log.error("Error fetching events", { channelId, err }); + await this.deps.selectedCalendarRepository.updateById(selectedCalendar.id, { + syncErrorAt: new Date(), + syncErrorCount: (selectedCalendar.syncErrorCount || 0) + 1, + }); + throw err; + } + + if (!events?.items?.length) { + log.debug("No events fetched", { channelId }); + return; + } + + await this.deps.selectedCalendarRepository.updateById(selectedCalendar.id, { + syncToken: events.syncToken || selectedCalendar.syncToken, + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, + }); + + if (cacheEnabled) { + log.debug("Caching events", { count: events.items.length }); + const { CalendarCacheEventService } = await import("./cache/CalendarCacheEventService"); + const { CalendarCacheEventRepository } = await import("./cache/CalendarCacheEventRepository"); + const calendarCacheEventService = new CalendarCacheEventService({ + calendarCacheEventRepository: new CalendarCacheEventRepository(prisma), + selectedCalendarRepository: this.deps.selectedCalendarRepository, + }); + await calendarCacheEventService.handleEvents(selectedCalendar, events.items); + } + + if (syncEnabled) { + log.debug("Syncing events", { count: events.items.length }); + const { CalendarSyncService } = await import("./sync/CalendarSyncService"); + await new CalendarSyncService().handleEvents(selectedCalendar, events.items); + } + } + + /** + * Subscribe periodically to new calendars + */ + async checkForNewSubscriptions() { + log.debug("checkForNewSubscriptions"); + const rows = await this.deps.selectedCalendarRepository.findNotSubscribed({ take: 100 }); + await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); + } + + /** + * Get credential with delegation if available + */ + private async getCalendarCredential(credentialId: number): Promise { + const credential = await getCredentialForCalendarCache({ credentialId }); + if (!credential) return null; + return { + ...credential, + delegatedTo: credential.delegatedTo?.serviceAccountKey?.client_email + ? { + serviceAccountKey: { + client_email: credential.delegatedTo.serviceAccountKey.client_email, + client_id: credential.delegatedTo.serviceAccountKey.client_id, + private_key: credential.delegatedTo.serviceAccountKey.private_key, + }, + } + : null, + }; + } +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts new file mode 100644 index 00000000000000..159aefee71194f --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts @@ -0,0 +1,36 @@ +import type { CalendarCacheEvent } from "@calcom/prisma/client"; + +/** + * Repository to handle calendar cache + */ +export interface ICalendarCacheEventRepository { + /** + * Upserts many events + * @param events the list of events to upsert + */ + upsertMany(events: Partial[]): Promise; + + /** + * Deletes many events + * @param events the list of events to delete + */ + deleteMany(events: Pick[]): Promise; + + /** + * Deletes all events for a selected calendar + * @param selectedCalendarId the id of the calendar + */ + deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise; + + /** + * + * @param selectedCalendarId + * @param start + * @param end + */ + findAllBySelectedCalendarIds( + selectedCalendarId: string[], + start: Date, + end: Date + ): Promise[]>; +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts new file mode 100644 index 00000000000000..422669bd09837c --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -0,0 +1,58 @@ +import type { ICalendarCacheEventRepository } from "calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; + +import type { PrismaClient } from "@calcom/prisma"; +import type { CalendarCacheEvent } from "@calcom/prisma/client"; + +export class CalendarCacheEventRepository implements ICalendarCacheEventRepository { + constructor(private prismaClient: PrismaClient) {} + + async findAllBySelectedCalendarIds( + selectedCalendarId: string[], + start: Date, + end: Date + ): Promise[]> { + return this.prismaClient.calendarCacheEvent.findMany({ + where: { + selectedCalendarId: { + in: selectedCalendarId, + }, + start: { + gte: start, + }, + end: { + lte: end, + }, + }, + select: { + start: true, + end: true, + timeZone: true, + }, + }); + } + + async upsertMany(events: CalendarCacheEvent[]): Promise { + this.prismaClient.calendarCacheEvent.createMany({ data: events }); + } + + async deleteMany(events: Pick[]): Promise { + const conditions = events.filter((c) => c.externalId && c.selectedCalendarId); + if (conditions.length === 0) { + return; + } + + this.prismaClient.calendarCacheEvent.deleteMany({ + where: { + OR: conditions, + }, + }); + } + + async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise { + this.prismaClient.calendarCacheEvent.deleteMany({ + where: { + selectedCalendarId, + }, + }); + } +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts new file mode 100644 index 00000000000000..2ee0aff520e5a0 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -0,0 +1,85 @@ +import type { ICalendarCacheEventRepository } from "calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; + +import logger from "@calcom/lib/logger"; +import type { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import type { CalendarCacheEvent, SelectedCalendar } from "@calcom/prisma/client"; + +import type { CalendarSubscriptionEventItem } from "../../lib/CalendarSubscriptionPort.interface"; + +const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] }); + +/** + * Service to handle calendar cache + */ +export class CalendarCacheEventService { + constructor( + private deps: { + selectedCalendarRepository: SelectedCalendarRepository; + calendarCacheEventRepository: ICalendarCacheEventRepository; + } + ) {} + + /** + * Handle calendar events from provider and update the cache + * + * @param selectedCalendar + * @param calendarSubscriptionEvents + */ + async handleEvents( + selectedCalendar: SelectedCalendar, + calendarSubscriptionEvents: CalendarSubscriptionEventItem[] + ): Promise { + log.debug("handleEvents", { count: calendarSubscriptionEvents.length }); + const toUpsert: Partial[] = []; + const toDelete: Pick[] = []; + + for (const event of calendarSubscriptionEvents) { + if (!event.id) { + log.warn("handleEvents: skipping event with no ID", { event }); + continue; + } + + if (event.busy) { + toUpsert.push({ + externalId: event.id, + selectedCalendarId: selectedCalendar.id, + start: event.start, + end: event.end, + summary: event.summary, + description: event.description, + location: event.location, + isAllDay: event.isAllDay, + timeZone: event.timeZone, + originalStartTime: event.originalStartDate, + externalCreatedAt: event.createdAt, + externalUpdatedAt: event.updatedAt, + }); + } else { + toDelete.push({ + selectedCalendarId: selectedCalendar.id, + externalId: event.id, + }); + } + } + + log.debug("handleEvents: applying changes to the database", { + received: calendarSubscriptionEvents.length, + toUpsert: toUpsert.length, + toDelete: toDelete.length, + }); + await Promise.allSettled([ + this.deps.calendarCacheEventRepository.deleteMany(toDelete), + this.deps.calendarCacheEventRepository.upsertMany(toUpsert), + ]); + } + + /** + * Removes all events from the cache + * + * @param selectedCalendar calendar to cleanup + */ + async cleanupCache(selectedCalendar: SelectedCalendar): Promise { + log.debug("cleanupCache", { selectedCalendarId: selectedCalendar.id }); + await this.deps.calendarCacheEventRepository.deleteAllBySelectedCalendarId(selectedCalendar.id); + } +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts new file mode 100644 index 00000000000000..f734ea997d6e1a --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -0,0 +1,132 @@ +import logger from "@calcom/lib/logger"; +import type { + Calendar, + CalendarEvent, + CalendarServiceEvent, + EventBusyDate, + IntegrationCalendar, + NewCalendarEventType, + SelectedCalendarEventTypeIds, +} from "@calcom/types/Calendar"; + +import type { ICalendarCacheEventRepository } from "./CalendarCacheEventRepository.interface"; + +const log = logger.getSubLogger({ prefix: ["CachedCalendarWrapper"] }); + +/** + * A wrapper to load cache from database and cache it. + * + * @see Calendar + */ +export class CalendarCacheWrapper implements Calendar { + constructor( + private deps: { + originalCalendar: Calendar; + calendarCacheEventRepository: ICalendarCacheEventRepository; + } + ) {} + + getCredentialId?(): number { + return this.deps.originalCalendar.getCredentialId ? this.deps.originalCalendar.getCredentialId() : -1; + } + + createEvent( + event: CalendarServiceEvent, + credentialId: number, + externalCalendarId?: string + ): Promise { + return this.deps.originalCalendar.createEvent(event, credentialId, externalCalendarId); + } + + updateEvent( + uid: string, + event: CalendarServiceEvent, + externalCalendarId?: string | null + ): Promise { + return this.deps.originalCalendar.updateEvent(uid, event, externalCalendarId); + } + + deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise { + return this.deps.originalCalendar.deleteEvent(uid, event, externalCalendarId); + } + + /** + * Override this method to use cache + * + * @param dateFrom + * @param dateTo + * @param selectedCalendars + * @param _shouldServeCache + * @param _fallbackToPrimary + * @returns + */ + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[], + _shouldServeCache?: boolean, + _fallbackToPrimary?: boolean + ): Promise { + log.debug("getAvailability from cache", { dateFrom, dateTo, selectedCalendars }); + const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); + if (!selectedCalendarIds.length) { + return Promise.resolve([]); + } + return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIds( + selectedCalendarIds, + new Date(dateFrom), + new Date(dateTo) + ); + } + + /** + * Override this method to use cache + * + * @param dateFrom + * @param dateTo + * @param selectedCalendars + * @param _fallbackToPrimary + * @returns + */ + async getAvailabilityWithTimeZones?( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[], + _fallbackToPrimary?: boolean + ): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> { + log.debug("getAvailabilityWithTimeZones from cache", { dateFrom, dateTo, selectedCalendars }); + const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); + const result = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIds( + selectedCalendarIds, + new Date(dateFrom), + new Date(dateTo) + ); + return result.map(({ start, end, timeZone }) => ({ start, end, timeZone: timeZone || "UTC" })); + } + + fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise { + return this.deps.originalCalendar.fetchAvailabilityAndSetCache?.(selectedCalendars) || Promise.resolve(); + } + + listCalendars(event?: CalendarEvent): Promise { + return this.deps.originalCalendar.listCalendars(event); + } + + testDelegationCredentialSetup?(): Promise { + return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false); + } + + watchCalendar?(options: { + calendarId: string; + eventTypeIds: SelectedCalendarEventTypeIds; + }): Promise { + return this.deps.originalCalendar.watchCalendar?.(options) || Promise.resolve(); + } + + unwatchCalendar?(options: { + calendarId: string; + eventTypeIds: SelectedCalendarEventTypeIds; + }): Promise { + return this.deps.originalCalendar.unwatchCalendar?.(options) || Promise.resolve(); + } +} diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts new file mode 100644 index 00000000000000..f458bfdbb688f8 --- /dev/null +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -0,0 +1,67 @@ +import type { CalendarSubscriptionEventItem } from "calendar-subscription/lib/CalendarSubscriptionPort.interface"; + +import logger from "@calcom/lib/logger"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +const log = logger.getSubLogger({ prefix: ["CalendarSyncService"] }); + +/** + * Service to handle synchronization of calendar events. + */ +export class CalendarSyncService { + /** + * Handles synchronization of calendar events + * + * @param selectedCalendar calendar to process + * @param calendarSubscriptionEvents events to process + * @returns + */ + async handleEvents( + selectedCalendar: SelectedCalendar, + calendarSubscriptionEvents: CalendarSubscriptionEventItem[] + ) { + log.debug("handleEvents", { + externalId: selectedCalendar.externalId, + countEvents: calendarSubscriptionEvents.length, + }); + + // only process cal.com calendar events + const calEvents = calendarSubscriptionEvents.filter((e) => + e.iCalUID?.toLowerCase()?.endsWith("@cal.com") + ); + if (calEvents.length === 0) { + log.debug("handleEvents: no calendar events to process"); + return; + } + + log.debug("handleEvents: processing calendar events", { count: calEvents.length }); + await Promise.all( + calEvents.map((e) => { + if (e.status === "cancelled") { + return this.cancelBooking(e); + } else { + return this.rescheduleBooking(e); + } + }) + ); + } + + /** + * Cancels a booking + * @param event + * @returns + */ + async cancelBooking(event: CalendarSubscriptionEventItem) { + log.debug("cancelBooking", { event }); + // TODO implement + } + + /** + * Reschedule a booking + * @param event + */ + async rescheduleBooking(event: CalendarSubscriptionEventItem) { + log.debug("rescheduleBooking", { event }); + // TODO implement + } +} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index eea362f9cfef4f..eb068d26361031 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -26,6 +26,8 @@ export type AppFlags = { "team-booking-page-cache": boolean; "cal-ai-voice-agents": boolean; "tiered-support-chat": boolean; + "calendar-subscription-cache": boolean; + "calendar-subscription-sync": boolean; }; export type TeamFeatures = Record; diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 2f1a1a6e514e9c..3781766f6558bd 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -2,6 +2,7 @@ import { captureException } from "@sentry/nextjs"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; + import type { AppFlags, TeamFeatures } from "./config"; import type { IFeaturesRepository } from "./features.repository.interface"; @@ -15,6 +16,7 @@ interface CacheOptions { * for users, teams, and global application features. */ export class FeaturesRepository implements IFeaturesRepository { + // eslint-disable-next-line @typescript-eslint/no-explicit-any private static featuresCache: { data: any[]; expiry: number } | null = null; constructor(private prismaClient: PrismaClient) {} diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts new file mode 100644 index 00000000000000..24e38985f826d1 --- /dev/null +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -0,0 +1,44 @@ +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +export class SelectedCalendarRepository { + constructor(private prismaClient: PrismaClient) {} + + async findById(id: string) { + return this.prismaClient.selectedCalendar.findUnique({ + where: { id }, + include: { + credential: { + select: { + delegationCredential: true, + }, + }, + }, + }); + } + + async findNotSubscribed({ take }: { take: number }) { + return this.prismaClient.selectedCalendar.findMany({ + where: { syncSubscribedAt: null }, + take, + }); + } + + async findMany(args: { where: Prisma.SelectedCalendarWhereInput }) { + return this.prismaClient.selectedCalendar.findMany({ + where: args.where, + select: { id: true, externalId: true, credentialId: true, syncedAt: true }, + }); + } + + async findByChannelId(channelId: string) { + return this.prismaClient.selectedCalendar.findFirst({ where: { channelId } }); + } + + async updateById(id: string, data: Prisma.SelectedCalendarUpdateInput) { + return this.prismaClient.selectedCalendar.update({ + where: { id }, + data, + }); + } +} diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0ea9c358a758bb..e24d32fdb5dd8d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -911,12 +911,27 @@ model SelectedCalendar { createdAt DateTime? @default(now()) updatedAt DateTime? @updatedAt // Used to identify a watched calendar channel in Google Calendar + // @deprecated("") googleChannelId String? googleChannelKind String? googleChannelResourceId String? googleChannelResourceUri String? googleChannelExpiration String? + // Used to identify a watched calendar + channelId String? + channelKind String? + channelResourceId String? + channelResourceUri String? + channelExpiration DateTime? @db.Timestamp(3) + + // Used to calendar cache and sync + syncSubscribedAt DateTime? @db.Timestamp(3) + syncToken String? + syncedAt DateTime? + syncErrorAt DateTime? + syncErrorCount Int? @default(0) + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) delegationCredentialId String? @@ -932,6 +947,8 @@ model SelectedCalendar { eventTypeId Int? eventType EventType? @relation(fields: [eventTypeId], references: [id]) + calendarCacheEvents CalendarCacheEvent[] + // It could still allow multiple user-level(eventTypeId is null) selected calendars for same userId, integration, externalId because NULL is not equal to NULL // We currently ensure uniqueness by checking for the existence of the record before creating a new one // Think about introducing a generated unique key ${userId}_${integration}_${externalId}_${eventTypeId} @@ -1746,7 +1763,7 @@ model BookingDenormalized { } view BookingTimeStatusDenormalized { - id Int @id @unique + id Int @unique uid String eventTypeId Int? title String @@ -2542,3 +2559,41 @@ model CalAiPhoneNumber { @@index([inboundAgentId]) @@index([outboundAgentId]) } + +enum CalendarCacheEventStatus { + confirmed @map("confirmed") + tentative @map("tentative") + cancelled @map("cancelled") +} + +model CalendarCacheEvent { + id String @id @default(uuid()) + selectedCalendarId String + externalId String + externalEtag String + iCalUID String? + iCalSequence Int @default(0) + + // Event details + summary String? + description String? + location String? + start DateTime + end DateTime + isAllDay Boolean @default(false) + timeZone String? + status CalendarCacheEventStatus @default(confirmed) + recurringEventId String? + originalStartTime DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + externalCreatedAt DateTime? + externalUpdatedAt DateTime? + + selectedCalendar SelectedCalendar @relation(fields: [selectedCalendarId], references: [id], onDelete: Cascade) + + @@unique([selectedCalendarId, externalId]) + @@index([start, end, status]) + @@index([selectedCalendarId, iCalUID]) +} diff --git a/turbo.json b/turbo.json index 1042760af8085f..bd81c3c86a36b9 100644 --- a/turbo.json +++ b/turbo.json @@ -81,6 +81,7 @@ "GOOGLE_API_CREDENTIALS", "GOOGLE_LOGIN_ENABLED", "GOOGLE_WEBHOOK_TOKEN", + "GOOGLE_WEBHOOK_URL", "HEROKU_APP_NAME", "HUBSPOT_CLIENT_ID", "HUBSPOT_CLIENT_SECRET", @@ -277,7 +278,9 @@ "NEXT_PUBLIC_WEBSITE_URL", "BUILD_STANDALONE", "INTERCOM_API_TOKEN", - "NEXT_PUBLIC_INTERCOM_APP_ID" + "NEXT_PUBLIC_INTERCOM_APP_ID", + "MICROSOFT_WEBHOOK_TOKEN", + "MICROSOFT_WEBHOOK_URL" ], "tasks": { "@calcom/prisma#build": { From 6b6d00d2546b580571322f9e1fb8f05952fafb95 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 14:42:09 -0300 Subject: [PATCH 02/49] Add env.example --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index dd1fa49e19e944..e156f609a4466c 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN= # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. GOOGLE_WEBHOOK_URL= +# Token to verify incoming webhooks from Microsoft Calendar +MICROSOFT_WEBHOOK_TOKEN= + +# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. +MICROSOFT_WEBHOOK_URL= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= From 79de50b6a2ff4567ebbe55efa1fd4ba38f31e617 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 15:12:50 -0300 Subject: [PATCH 03/49] refactor on CalendarCacheEventService --- .../lib/cache/CalendarCacheEventService.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index 2ee0aff520e5a0..e136f7801f469c 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -82,4 +82,15 @@ export class CalendarCacheEventService { log.debug("cleanupCache", { selectedCalendarId: selectedCalendar.id }); await this.deps.calendarCacheEventRepository.deleteAllBySelectedCalendarId(selectedCalendar.id); } + + /** + * Checks if the app is supported + * + * @param appId + * @returns + */ + static isAppSupported(appId: string | null): boolean { + if (!appId) return false; + return ["google-calendar", "office365-calendar"].includes(appId); + } } From 11d1bd2481975fd262568e97adf7f9dae79146d2 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 15:22:15 -0300 Subject: [PATCH 04/49] remove test console.log --- packages/app-store/_utils/getCalendar.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 69ab1f4b7e3c6c..0e87da6fc0ca37 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -14,7 +14,6 @@ const log = logger.getSubLogger({ prefix: ["CalendarManager"] }); export const getCalendar = async ( credential: CredentialForCalendarService | null ): Promise => { - console.log("getCalendar", credential); if (!credential || !credential.key) return null; let { type: calendarType } = credential; if (calendarType?.endsWith("_other_calendar")) { @@ -44,7 +43,6 @@ export const getCalendar = async ( // check if Calendar Cache is supported and enabled if (CalendarCacheEventService.isAppSupported(credential.appId)) { - console.log("Calendar Cache is supported and enabled", JSON.stringify(credential)); log.debug( `Using regular CalendarService for credential ${credential.id} (not Google or Office365 Calendar)` ); From fc5b3f9554915213f945de99c162a7d9e3f4f1d5 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 15:57:10 -0300 Subject: [PATCH 05/49] Fix type checks errors --- .../features/calendar-subscription/README.md | 38 +++++++++++++++++-- .../adapters/AdaptersFactory.ts | 6 +-- .../lib/CalendarSubscriptionPort.interface.ts | 3 +- .../lib/CalendarSubscriptionService.ts | 5 ++- .../lib/cache/CalendarCacheEventRepository.ts | 3 +- .../lib/cache/CalendarCacheEventService.ts | 6 +-- .../lib/cache/CalendarCacheWrapper.ts | 3 +- .../lib/sync/CalendarSyncService.ts | 3 +- 8 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/features/calendar-subscription/README.md b/packages/features/calendar-subscription/README.md index ec5d552ab697f0..b2c9ac54152dc6 100644 --- a/packages/features/calendar-subscription/README.md +++ b/packages/features/calendar-subscription/README.md @@ -1,10 +1,40 @@ -# Calendar Sync Feature +# Calendar Subscription -CalendarSync feature ... +The **Calendar Subscription** feature keeps your calendars synchronized while ensuring efficient caching. +When enabled, it uses **webhooks** to automatically listen for updates and apply changes in real time. +This means new events, modifications, or deletions are instantly captured and reflected across the system — no manual refresh or constant polling required. -## Feature Flag +## Key Benefits +- **Efficiency**: reduces API calls with optimized caching strategies. +- **Reliability**: guarantees updates through webhook event delivery. +- **Real-Time Sync**: ensures calendars are always up-to-date with minimal latency. +- **Scalability**: supports multiple calendars and handles high-volume updates seamlessly. +By subscribing to calendars via webhooks, you gain a smarter, faster, and more resource-friendly way to keep your data in sync. -## Structure +## Feature Flags + +This feature can be enabled using two feature flags: +1. `calendar-subscription-cache` that will allow calendar cache to be recorded and used thru calendars, this flag is for globally enable this feature that should be managed individually by teams through team_Features + +### Enabling cache feature +`insert into Feature` +INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") VALUES +('calendar-cache-serve', 'f', 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', 'f', NULL, '2025-09-16 17:02:09.292', '2025-09-16 17:02:09.292', NULL); + + +`insert into TeamFeatures +INSERT INTO "public"."TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") VALUES +(1, DEFAULT, DEFAULT, DEFAULT, DEFAULT); + +### Enabling sync feature +2. `calendar-subscription-sync` this flag will enable canlendar sync for all calendars, diferently from cache it will be enabled globally for all users regardinless it is a team, individual or org. +INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") VALUES +('calendar-cache-serve', 'f', 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', 'f', NULL, '2025-09-16 17:02:09.292', '2025-09-16 17:02:09.292', NULL); + +## Team Feature Flags + + +## Architecture diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts index 8f1f1adf6c985d..d77e449fe6c248 100644 --- a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -1,6 +1,6 @@ -import { GoogleCalendarSubscriptionAdapter } from "../adapters/GoogleCalendarSubscription.adapter"; -import type { ICalendarSubscriptionPort } from "../lib/CalendarSubscriptionPort.interface"; -import { Office365CalendarSubscriptionAdapter } from "./Office365CalendarSubscription.adapter"; +import { GoogleCalendarSubscriptionAdapter } from "@calcom/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter"; +import { Office365CalendarSubscriptionAdapter } from "@calcom/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter"; +import type { ICalendarSubscriptionPort } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; export type CalendarSubscriptionProvider = "google_calendar" | "office365_calendar"; diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts index e79ae5347d25c9..e8cccd236bb0f2 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -1,11 +1,10 @@ +import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import type { SelectedCalendar } from "@calcom/prisma/client"; import type { CredentialForCalendarService, CredentialForCalendarServiceWithEmail, } from "@calcom/types/Credential"; -import type { CalendarSubscriptionProvider } from "../adapters/AdaptersFactory"; - export type CalendarSubscriptionWebhookContext = { headers?: Headers; query?: URLSearchParams; diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 4548dcc9f01fdc..0d698d1d9c371e 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -1,10 +1,13 @@ +import type { + AdapterFactory, + CalendarSubscriptionProvider, +} from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; import logger from "@calcom/lib/logger"; import type { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; import { prisma } from "@calcom/prisma"; -import type { AdapterFactory, CalendarSubscriptionProvider } from "../adapters/AdaptersFactory"; import type { CalendarCredential, CalendarSubscriptionWebhookContext, diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index 422669bd09837c..bf5cf761ecec21 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -1,5 +1,4 @@ -import type { ICalendarCacheEventRepository } from "calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; - +import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; import type { PrismaClient } from "@calcom/prisma"; import type { CalendarCacheEvent } from "@calcom/prisma/client"; diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index e136f7801f469c..3b14396315b231 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -1,11 +1,9 @@ -import type { ICalendarCacheEventRepository } from "calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; - +import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; +import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; import logger from "@calcom/lib/logger"; import type { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; import type { CalendarCacheEvent, SelectedCalendar } from "@calcom/prisma/client"; -import type { CalendarSubscriptionEventItem } from "../../lib/CalendarSubscriptionPort.interface"; - const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] }); /** diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts index f734ea997d6e1a..c6d57144aac86f 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -1,3 +1,4 @@ +import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; import logger from "@calcom/lib/logger"; import type { Calendar, @@ -9,8 +10,6 @@ import type { SelectedCalendarEventTypeIds, } from "@calcom/types/Calendar"; -import type { ICalendarCacheEventRepository } from "./CalendarCacheEventRepository.interface"; - const log = logger.getSubLogger({ prefix: ["CachedCalendarWrapper"] }); /** diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts index f458bfdbb688f8..dca84d02f9483e 100644 --- a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -1,5 +1,4 @@ -import type { CalendarSubscriptionEventItem } from "calendar-subscription/lib/CalendarSubscriptionPort.interface"; - +import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; From 4daf6d66b7ae5ac76e386662cbdbb6d553159cdf Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 16:01:02 -0300 Subject: [PATCH 06/49] chore: remove pt comment --- .../adapters/Office365CalendarSubscription.adapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 7d3470f8ef841a..4cfc72fd027a85 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -53,7 +53,6 @@ type AdapterConfig = { baseUrl?: string; webhookToken?: string | null; webhookUrl?: string | null; - // duração padrão ~3 dias (Graph ~4230 min) subscriptionTtlMs?: number; }; From f027bfc8f85b2a8a626c207d349fe0f629bf16c1 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 16:09:33 -0300 Subject: [PATCH 07/49] add route.ts --- .../calendar-subscription/[provider]/route.ts | 56 +++++++++++++++++++ .../GoogleCalendarSubscription.adapter.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts new file mode 100644 index 00000000000000..23f4b6addae5d6 --- /dev/null +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -0,0 +1,56 @@ +import type { Params } from "app/_types"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; +import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; +import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import logger from "@calcom/lib/logger"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import { prisma } from "@calcom/prisma"; +import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; + +const log = logger.getSubLogger({ prefix: ["calendar-webhook"] }); + +/** + * Handles incoming POST requests for calendar webhooks. + * It processes the webhook based on the calendar provider specified in the URL. + * If the provider is unsupported, it returns a 400 response. + * If the webhook is processed successfully, it returns a 200 response. + * In case of errors during processing, it returns a 500 response with the error message. + * @param {NextRequest} request - The incoming request object. + * @param {Object} context - The context object containing route parameters. + * @param {Promise} context.params - A promise that resolves to the route parameters. + * @returns {Promise} - A promise that resolves to the response object. + */ +async function postHandler(request: NextRequest, context: { params: Promise }) { + log.debug("Received webhook"); + + // extract and validate provider + const provider = (await context.params).provider as string[][0] as CalendarSubscriptionProvider; + const allowed = new Set(["google_calendar", "office365_calendar"]); + if (!allowed.has(provider as CalendarSubscriptionProvider)) { + return NextResponse.json({ message: "Unsupported provider" }, { status: 400 }); + } + + try { + const calendarSubscriptionService = new CalendarSubscriptionService({ + adapterFactory: new DefaultAdapterFactory(), + selectedCalendarRepository: new SelectedCalendarRepository(prisma), + featuresRepository: new FeaturesRepository(prisma), + }); + await calendarSubscriptionService.processWebhook(provider, { + headers: request.headers, + query: new URL(request.url).searchParams, + body: await request.json(), + }); + return NextResponse.json({ message: "Webhook processed" }, { status: 200 }); + } catch (error) { + log.error("Error processing webhook", { error }); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ message }, { status: 500 }); + } +} + +export const POST = defaultResponderForAppDir(postHandler); diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts index 25d17d55e89e78..d89cdede2bfe93 100644 --- a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -24,7 +24,7 @@ const log = logger.getSubLogger({ prefix: ["GoogleCalendarSubscriptionAdapter"] */ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionPort { private GOOGLE_WEBHOOK_TOKEN = process.env.GOOGLE_WEBHOOK_TOKEN; - private GOOGLE_WEBHOOK_URL = process.env.GOOGLE_WEBHOOK_URL; + private GOOGLE_WEBHOOK_URL = `${process.env.GOOGLE_WEBHOOK_URL}/api/webhooks/calendar-subscription/google_calendar`; async validate(context: CalendarSubscriptionWebhookContext): Promise { const token = context?.headers?.get("X-Goog-Channel-Token"); From 118a88ac308c274a634e0503bf9b019a62a00540 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 16 Sep 2025 22:07:28 +0000 Subject: [PATCH 08/49] chore: fix tests --- .../test/lib/selected-calendars/_post.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/api/v1/test/lib/selected-calendars/_post.test.ts b/apps/api/v1/test/lib/selected-calendars/_post.test.ts index 1eac8010b68226..45d65084d150b4 100644 --- a/apps/api/v1/test/lib/selected-calendars/_post.test.ts +++ b/apps/api/v1/test/lib/selected-calendars/_post.test.ts @@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, test } from "vitest"; import { HttpError } from "@calcom/lib/http-error"; +import type { User } from "@calcom/prisma/client"; import handler from "../../../pages/api/selected-calendars/_post"; @@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => { prismaMock.user.findFirstOrThrow.mockResolvedValue({ id: 444444, - } as any); + } as User); prismaMock.selectedCalendar.create.mockResolvedValue({ credentialId: 1, @@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => { unwatchAttempts: 0, createdAt: new Date(), updatedAt: new Date(), + channelId: null, + channelKind: null, + channelResourceId: null, + channelResourceUri: null, + channelExpiration: null, + syncSubscribedAt: null, + syncToken: null, + syncedAt: null, + syncErrorAt: null, + syncErrorCount: null, }); await handler(req, res); @@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => { unwatchAttempts: 0, createdAt: new Date(), updatedAt: new Date(), + channelId: null, + channelKind: null, + channelResourceId: null, + channelResourceUri: null, + channelExpiration: null, + syncSubscribedAt: null, + syncToken: null, + syncedAt: null, + syncErrorAt: null, + syncErrorCount: null, }); await handler(req, res); From 47a231dc5a565840b06ca9934b43919d43b1bf5d Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 17 Sep 2025 00:44:45 +0000 Subject: [PATCH 09/49] Improve cache impl --- .../api/cron/calendar-subscriptions/route.ts | 42 +++++++++++ .../calendar-subscription/[provider]/route.ts | 6 +- apps/web/cron-tester.ts | 3 +- apps/web/vercel.json | 4 ++ .../GoogleCalendarSubscription.adapter.ts | 56 ++++++++++----- .../Office365CalendarSubscription.adapter.ts | 21 +++--- .../lib/CalendarSubscriptionPort.interface.ts | 11 +-- .../lib/CalendarSubscriptionService.ts | 72 +++++++++++++++---- .../lib/cache/CalendarCacheEventRepository.ts | 30 +++++++- .../lib/cache/CalendarCacheEventService.ts | 6 +- 10 files changed, 192 insertions(+), 59 deletions(-) create mode 100644 apps/web/app/api/cron/calendar-subscriptions/route.ts mode change 100644 => 100755 apps/web/cron-tester.ts diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts new file mode 100644 index 00000000000000..0b1c39892088db --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -0,0 +1,42 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; +import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import logger from "@calcom/lib/logger"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import { prisma } from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["cron"] }); + +/** + * Checks for new calendar subscriptions (rollouts) + * + * @param request + * @returns + */ +async function postHandler(request: NextRequest) { + log.info("Checking for new calendar subscriptions"); + 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 calendarSubscriptionService = new CalendarSubscriptionService({ + adapterFactory: new DefaultAdapterFactory(), + selectedCalendarRepository: new SelectedCalendarRepository(prisma), + featuresRepository: new FeaturesRepository(prisma), + }); + try { + await calendarSubscriptionService.checkForNewSubscriptions(); + return NextResponse.json({ ok: true }); + } catch (e) { + log.error(e); + return NextResponse.json({ message: e.message }, { status: 500 }); + } +} + +export const GET = defaultResponderForAppDir(postHandler); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 23f4b6addae5d6..4736fca91ba4bf 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -40,11 +40,7 @@ async function postHandler(request: NextRequest, context: { params: Promise { - const token = context?.headers?.get("X-Goog-Channel-Token"); + async validate(request: Request): Promise { + const token = request?.headers?.get("X-Goog-Channel-Token"); if (!this.GOOGLE_WEBHOOK_TOKEN) { log.warn("GOOGLE_WEBHOOK_TOKEN not configured"); return false; @@ -39,8 +40,8 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP return true; } - async extractChannelId(context: CalendarSubscriptionWebhookContext): Promise { - const channelId = context?.headers?.get("X-Goog-Channel-ID"); + async extractChannelId(request: Request): Promise { + const channelId = request?.headers?.get("X-Goog-Channel-ID"); if (!channelId) { log.warn("Missing channel ID in webhook"); return null; @@ -55,6 +56,12 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId }); selectedCalendar; + console.log( + "🚀 ~ file: GoogleCalendarSubscription.adapter.ts:45 ~ GoogleCalendarSubscriptionAdapter ~ subscribe ~ this.GOOGLE_WEBHOOK_TOKEN", + this.GOOGLE_WEBHOOK_TOKEN, + this.GOOGLE_WEBHOOK_URL + ); + const MONTH_IN_SECONDS = 60 * 60 * 24 * 30; const client = await this.getClient(credential); @@ -102,19 +109,34 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP selectedCalendar: SelectedCalendar, credential: CalendarCredential ): Promise { + log.info("Attempt to fetch events from Google Calendar", { externalId: selectedCalendar.externalId }); const client = await this.getClient(credential); let syncToken = selectedCalendar.syncToken || undefined; let pageToken; + const params: calendar_v3.Params$Resource$Events$List = { + calendarId: selectedCalendar.externalId, + pageToken, + singleEvents: true, + }; + + if (!syncToken) { + // first sync or unsync (30 days) + const DAYS_30_IN_MS = 30 * 24 * 60 * 60 * 1000; + const now = new Date(); + const timeMinISO = now.toISOString(); + const timeMaxISO = new Date(now.getTime() + DAYS_30_IN_MS).toISOString(); + params.timeMin = timeMinISO; + params.timeMax = timeMaxISO; + } else { + // incremental sync + params.syncToken = syncToken; + } + const events: calendar_v3.Schema$Event[] = []; do { - const { data }: { data: calendar_v3.Schema$Events } = await client.events.list({ - calendarId: selectedCalendar.externalId, - syncToken, - pageToken, - singleEvents: true, - }); + const { data }: { data: calendar_v3.Schema$Events } = await client.events.list(params); syncToken = data.nextSyncToken || syncToken; pageToken = data.nextPageToken ?? null; @@ -130,10 +152,10 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP } private parseEvents(events: calendar_v3.Schema$Event[]): CalendarSubscriptionEventItem[] { - const now = new Date(); return events .map((event) => { - const busy = event.transparency === "opaque"; // opaque = busy, transparent = free + // empty or opaque is busy + const busy = !event.transparency || event.transparency === "opaque"; const start = event.start?.dateTime ? new Date(event.start.dateTime) @@ -157,16 +179,16 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP description: event.description, location: event.location, kind: event.kind, + etag: event.etag, status: event.status, - isAllDay: !!event.start?.date && !event.start?.dateTime, + isAllDay: event.start?.date && !event.start?.dateTime, timeZone: event.start?.timeZone || null, originalStartTime: event.originalStartTime?.dateTime, createdAt: event.created ? new Date(event.created) : null, updatedAt: event.updated ? new Date(event.updated) : null, }; }) - .filter((e) => !!e.id) // safely remove events with no ID - .filter((e) => e.start < now); // remove old events + .filter(({ id }) => !!id); } private async getClient(credential: CalendarCredential) { diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 4cfc72fd027a85..644d148c64e143 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -6,7 +6,6 @@ import type { ICalendarSubscriptionPort, CalendarSubscriptionResult, CalendarCredential, - CalendarSubscriptionWebhookContext, CalendarSubscriptionEventItem, } from "../lib/CalendarSubscriptionPort.interface"; @@ -75,16 +74,16 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000; } - async validate(context: CalendarSubscriptionWebhookContext): Promise { + async validate(request: Request): Promise { // validate handshake - const validationToken = context?.query?.get("validationToken"); + const validationToken = request?.query?.get("validationToken"); if (validationToken) return true; // validate notifications const clientState = - context?.headers?.get("clientState") ?? - (typeof context?.body === "object" && context.body !== null && "clientState" in context.body - ? (context.body as { clientState?: string }).clientState + request?.headers?.get("clientState") ?? + (typeof request?.body === "object" && request.body !== null && "clientState" in request.body + ? (request.body as { clientState?: string }).clientState : undefined); if (!this.webhookToken) { log.warn("MICROSOFT_WEBHOOK_TOKEN missing"); @@ -97,12 +96,12 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti return true; } - async extractChannelId(context: CalendarSubscriptionWebhookContext): Promise { + async extractChannelId(request: Request): Promise { let id: string | null = null; - if (context?.body && typeof context.body === "object" && "subscriptionId" in context.body) { - id = (context.body as { subscriptionId?: string }).subscriptionId ?? null; - } else if (context?.headers?.get("subscriptionId")) { - id = context.headers.get("subscriptionId"); + if (request?.body && typeof request.body === "object" && "subscriptionId" in request.body) { + id = (request.body as { subscriptionId?: string }).subscriptionId ?? null; + } else if (request?.headers?.get("subscriptionId")) { + id = request.headers.get("subscriptionId"); } if (!id) { log.warn("subscriptionId missing in webhook"); diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts index e8cccd236bb0f2..a54c7b7e54d37f 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -5,12 +5,6 @@ import type { CredentialForCalendarServiceWithEmail, } from "@calcom/types/Credential"; -export type CalendarSubscriptionWebhookContext = { - headers?: Headers; - query?: URLSearchParams; - body?: { value?: { subscriptionId?: string }[] } | unknown; -}; - export type CalendarSubscriptionResult = { provider: CalendarSubscriptionProvider; id?: string | null; @@ -29,6 +23,7 @@ export type CalendarSubscriptionEventItem = { summary?: string | null; description?: string | null; kind?: string | null; + etag?: string | null; status?: string | null; location?: string | null; originalStartDate?: Date | null; @@ -53,13 +48,13 @@ export interface ICalendarSubscriptionPort { * Validates a webhook request * @param context */ - validate(context: CalendarSubscriptionWebhookContext): Promise; + validate(context: Request): Promise; /** * Extracts channel ID from a webhook request * @param request */ - extractChannelId(context: CalendarSubscriptionWebhookContext): Promise; + extractChannelId(context: Request): Promise; /** * Subscribes to a calendar diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 0d698d1d9c371e..d1dd9636360ef5 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -10,7 +10,6 @@ import { prisma } from "@calcom/prisma"; import type { CalendarCredential, - CalendarSubscriptionWebhookContext, CalendarSubscriptionEvent, } from "../lib/CalendarSubscriptionPort.interface"; @@ -55,6 +54,9 @@ export class CalendarSubscriptionService { channelExpiration: res?.expiration, syncSubscribedAt: new Date(), }); + + // initial event loading + await processEvents(selectedCalendar); } /** @@ -71,39 +73,66 @@ export class CalendarSubscriptionService { const calendarSubscriptionAdapter = this.deps.adapterFactory.get( selectedCalendar.integration as CalendarSubscriptionProvider ); + await Promise.all([ calendarSubscriptionAdapter.unsubscribe(selectedCalendar, credential), this.deps.selectedCalendarRepository.updateById(selectedCalendarId, { syncSubscribedAt: null, }), ]); + + // cleanup cache after unsubscribe + if (await this.isCacheEnabled()) { + const { CalendarCacheEventService } = await import("./cache/CalendarCacheEventService"); + const calendarCacheEventService = new CalendarCacheEventService(this.deps); + await calendarCacheEventService.cleanupCache(selectedCalendar); + } } /** * Process webhook */ - async processWebhook(provider: CalendarSubscriptionProvider, context: CalendarSubscriptionWebhookContext) { + async processWebhook(provider: CalendarSubscriptionProvider, request: Request) { log.debug("processWebhook", { provider }); const calendarSubscriptionAdapter = this.deps.adapterFactory.get(provider); - const isValid = await calendarSubscriptionAdapter.validate(context); + const isValid = await calendarSubscriptionAdapter.validate(request); if (!isValid) throw new Error("Invalid webhook request"); - const channelId = await calendarSubscriptionAdapter.extractChannelId(context); + const channelId = await calendarSubscriptionAdapter.extractChannelId(request); if (!channelId) throw new Error("Missing channel ID in webhook"); log.debug("Processing webhook", { channelId }); - const [selectedCalendar, cacheEnabled, syncEnabled] = await Promise.all([ - this.deps.selectedCalendarRepository.findByChannelId(channelId), - this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-cache"), - this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-sync"), - ]); - - if (!selectedCalendar?.credentialId || (!cacheEnabled && !syncEnabled)) { + const selectedCalendar = await this.deps.selectedCalendarRepository.findByChannelId(channelId); + if (!selectedCalendar?.credentialId) { log.debug("Selected calendar not found", { channelId }); return; } + // incremental event loading + await this.processEvents(selectedCalendar); + } + + /** + * Process events + * - fetch events from calendar + * - process events + * - update selected calendar + * - update cache + * - update sync + */ + async processEvents(selectedCalendar: SelectedCalendar): Promise { + const calendarSubscriptionAdapter = this.deps.adapterFactory.get( + selectedCalendar.integration as CalendarSubscriptionProvider + ); + + const [cacheEnabled, syncEnabled] = await Promise.all([this.isCacheEnabled(), this.isSyncEnabled()]); + if (!cacheEnabled && !syncEnabled) { + log.debug("No cache or sync enabled", { channelId: selectedCalendar.channelId }); + return; + } + + log.info("processEvents", { channelId: selectedCalendar.channelId }); const credential = await this.getCalendarCredential(selectedCalendar.credentialId); if (!credential) return; @@ -111,7 +140,7 @@ export class CalendarSubscriptionService { try { events = await calendarSubscriptionAdapter.fetchEvents(selectedCalendar, credential); } catch (err) { - log.error("Error fetching events", { channelId, err }); + log.debug("Error fetching events", { channelId: selectedCalendar.channelId, err }); await this.deps.selectedCalendarRepository.updateById(selectedCalendar.id, { syncErrorAt: new Date(), syncErrorCount: (selectedCalendar.syncErrorCount || 0) + 1, @@ -120,10 +149,11 @@ export class CalendarSubscriptionService { } if (!events?.items?.length) { - log.debug("No events fetched", { channelId }); + log.debug("No events fetched", { channelId: selectedCalendar.channelId }); return; } + log.debug("Processing events", { channelId: selectedCalendar.channelId, count: events.items.length }); await this.deps.selectedCalendarRepository.updateById(selectedCalendar.id, { syncToken: events.syncToken || selectedCalendar.syncToken, syncedAt: new Date(), @@ -158,6 +188,22 @@ export class CalendarSubscriptionService { await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); } + /** + * Check if cache is enabled + * @returns true if cache is enabled + */ + async isCacheEnabled(): Promise { + return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-cache"); + } + + /** + * Check if sync is enabled + * @returns true if sync is enabled + */ + async isSyncEnabled(): Promise { + return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-sync"); + } + /** * Get credential with delegation if available */ diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index bf5cf761ecec21..828b7329cb7fd1 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -31,16 +31,42 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito } async upsertMany(events: CalendarCacheEvent[]): Promise { - this.prismaClient.calendarCacheEvent.createMany({ data: events }); + if (events.length === 0) { + return; + } + // lack of upsertMany in prisma + return Promise.all( + events.map((event) => { + return this.prismaClient.calendarCacheEvent.upsert({ + where: { + selectedCalendarId_externalId: { + externalId: event.externalId, + selectedCalendarId: event.selectedCalendarId, + }, + }, + update: { + start: event.start, + end: event.end, + summary: event.summary, + description: event.description, + location: event.location, + isAllDay: event.isAllDay, + timeZone: event.timeZone, + }, + create: event, + }); + }) + ); } async deleteMany(events: Pick[]): Promise { + // Only delete events with externalId and selectedCalendarId const conditions = events.filter((c) => c.externalId && c.selectedCalendarId); if (conditions.length === 0) { return; } - this.prismaClient.calendarCacheEvent.deleteMany({ + return this.prismaClient.calendarCacheEvent.deleteMany({ where: { OR: conditions, }, diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index 3b14396315b231..a32468bfeae7da 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -37,7 +37,8 @@ export class CalendarCacheEventService { continue; } - if (event.busy) { + // not storing free or cancelled events + if (event.busy && event.status !== "cancelled") { toUpsert.push({ externalId: event.id, selectedCalendarId: selectedCalendar.id, @@ -49,6 +50,7 @@ export class CalendarCacheEventService { isAllDay: event.isAllDay, timeZone: event.timeZone, originalStartTime: event.originalStartDate, + externalEtag: event.etag, externalCreatedAt: event.createdAt, externalUpdatedAt: event.updatedAt, }); @@ -60,7 +62,7 @@ export class CalendarCacheEventService { } } - log.debug("handleEvents: applying changes to the database", { + log.info("handleEvents: applying changes to the database", { received: calendarSubscriptionEvents.length, toUpsert: toUpsert.length, toDelete: toDelete.length, From da76f39babfa8c186200ef145e417b0b669492f1 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 17 Sep 2025 00:50:14 +0000 Subject: [PATCH 10/49] chore: update recurring event id --- .../adapters/GoogleCalendarSubscription.adapter.ts | 1 + .../lib/CalendarSubscriptionPort.interface.ts | 1 + .../calendar-subscription/lib/cache/CalendarCacheEventService.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts index 21f0074cb44bc0..851a9f548fbac5 100644 --- a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -183,6 +183,7 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP status: event.status, isAllDay: event.start?.date && !event.start?.dateTime, timeZone: event.start?.timeZone || null, + recurringEventId: event.recurringEventId, originalStartTime: event.originalStartTime?.dateTime, createdAt: event.created ? new Date(event.created) : null, updatedAt: event.updated ? new Date(event.updated) : null, diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts index a54c7b7e54d37f..1abf1fd434a461 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -27,6 +27,7 @@ export type CalendarSubscriptionEventItem = { status?: string | null; location?: string | null; originalStartDate?: Date | null; + recurringEventId?: string | null; timeZone?: string | null; createdAt?: Date | null; updatedAt?: Date | null; diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index a32468bfeae7da..deb744d75dc6bf 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -50,6 +50,7 @@ export class CalendarCacheEventService { isAllDay: event.isAllDay, timeZone: event.timeZone, originalStartTime: event.originalStartDate, + recurringEventId: event.recurringEventId, externalEtag: event.etag, externalCreatedAt: event.createdAt, externalUpdatedAt: event.updatedAt, From c6b45eada9ec7a57a5157875296711721533a39c Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 17 Sep 2025 00:57:13 +0000 Subject: [PATCH 11/49] chore: small improvements --- .../lib/CalendarSubscriptionService.ts | 5 ++--- .../lib/cache/CalendarCacheEventService.ts | 7 ------- .../repository/SelectedCalendarRepository.interface.ts | 9 +++++++++ .../lib/server/repository/SelectedCalendarRepository.ts | 4 +++- 4 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 packages/lib/server/repository/SelectedCalendarRepository.interface.ts diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index d1dd9636360ef5..c07a383ad544f8 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -5,7 +5,7 @@ import type { import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; import logger from "@calcom/lib/logger"; -import type { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface"; import { prisma } from "@calcom/prisma"; import type { @@ -19,7 +19,7 @@ export class CalendarSubscriptionService { constructor( private deps: { adapterFactory: AdapterFactory; - selectedCalendarRepository: SelectedCalendarRepository; + selectedCalendarRepository: ISelectedCalendarRepository; featuresRepository: FeaturesRepository; } ) {} @@ -167,7 +167,6 @@ export class CalendarSubscriptionService { const { CalendarCacheEventRepository } = await import("./cache/CalendarCacheEventRepository"); const calendarCacheEventService = new CalendarCacheEventService({ calendarCacheEventRepository: new CalendarCacheEventRepository(prisma), - selectedCalendarRepository: this.deps.selectedCalendarRepository, }); await calendarCacheEventService.handleEvents(selectedCalendar, events.items); } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index deb744d75dc6bf..afd5b2c8d2edfc 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -1,7 +1,6 @@ import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; import logger from "@calcom/lib/logger"; -import type { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; import type { CalendarCacheEvent, SelectedCalendar } from "@calcom/prisma/client"; const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] }); @@ -12,7 +11,6 @@ const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] }); export class CalendarCacheEventService { constructor( private deps: { - selectedCalendarRepository: SelectedCalendarRepository; calendarCacheEventRepository: ICalendarCacheEventRepository; } ) {} @@ -32,11 +30,6 @@ export class CalendarCacheEventService { const toDelete: Pick[] = []; for (const event of calendarSubscriptionEvents) { - if (!event.id) { - log.warn("handleEvents: skipping event with no ID", { event }); - continue; - } - // not storing free or cancelled events if (event.busy && event.status !== "cancelled") { toUpsert.push({ diff --git a/packages/lib/server/repository/SelectedCalendarRepository.interface.ts b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts new file mode 100644 index 00000000000000..e688bb963b71fc --- /dev/null +++ b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts @@ -0,0 +1,9 @@ +import type { Prisma } from "@calcom/prisma/client"; + +export class ISelectedCalendarRepository { + findById(id: string): Promise; + findNotSubscribed({ take }: { take: number }): Promise; + findMany(args: { where: Prisma.SelectedCalendarWhereInput }): Promise; + findByChannelId(channelId: string): Promise; + updateById(id: string, data: Prisma.SelectedCalendarUpdateInput): Promise; +} diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts index 24e38985f826d1..c432e1cadbabf1 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -1,7 +1,9 @@ import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; -export class SelectedCalendarRepository { +import type { ISelectedCalendarRepository } from "./SelectedCalendarRepository.interface"; + +export class SelectedCalendarRepository implements ISelectedCalendarRepository { constructor(private prismaClient: PrismaClient) {} async findById(id: string) { From 55d484b4e22345fb0610126c2b1b0c86ccebe64e Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 18 Sep 2025 17:15:45 -0300 Subject: [PATCH 12/49] calendar cache improvements --- .../api/cron/calendar-subscriptions/route.ts | 6 +- .../calendar-subscription/[provider]/route.ts | 12 +++ packages/app-store/_utils/getCalendar.ts | 21 +++-- .../adapters/AdaptersFactory.ts | 16 ++++ .../GoogleCalendarSubscription.adapter.ts | 8 +- .../Office365CalendarSubscription.adapter.ts | 10 ++- .../lib/CalendarSubscriptionPort.interface.ts | 2 +- .../lib/CalendarSubscriptionService.ts | 76 ++++++++++++++----- .../CalendarCacheEventRepository.interface.ts | 6 +- .../lib/cache/CalendarCacheEventRepository.ts | 10 ++- .../lib/cache/CalendarCacheEventService.ts | 6 +- .../lib/cache/CalendarCacheWrapper.ts | 6 +- .../SelectedCalendarRepository.interface.ts | 69 +++++++++++++++-- .../repository/SelectedCalendarRepository.ts | 52 +++++++++---- 14 files changed, 232 insertions(+), 68 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 0b1c39892088db..8c7a2f49044283 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -32,10 +32,12 @@ async function postHandler(request: NextRequest) { }); try { await calendarSubscriptionService.checkForNewSubscriptions(); + log.info("Checked for new calendar subscriptions successfully"); return NextResponse.json({ ok: true }); } catch (e) { - log.error(e); - return NextResponse.json({ message: e.message }, { status: 500 }); + log.error("Error checking for new calendar subscriptions", { error: e }); + const message = e instanceof Error ? e.message : "Unknown error"; + return NextResponse.json({ message }, { status: 500 }); } } diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 4736fca91ba4bf..07e02d892796c1 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -40,6 +40,18 @@ async function postHandler(request: NextRequest, context: { params: Promise typeof event.id === "string" && !!event.id) .map((event) => { // empty or opaque is busy const busy = !event.transparency || event.transparency === "opaque"; @@ -170,7 +171,7 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP : new Date(); return { - id: event.id, + id: event.id as string, iCalUID: event.iCalUID, start, end, @@ -181,15 +182,14 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP kind: event.kind, etag: event.etag, status: event.status, - isAllDay: event.start?.date && !event.start?.dateTime, + isAllDay: typeof event.start?.date === "string" && !event.start?.dateTime ? true : undefined, timeZone: event.start?.timeZone || null, recurringEventId: event.recurringEventId, originalStartTime: event.originalStartTime?.dateTime, createdAt: event.created ? new Date(event.created) : null, updatedAt: event.updated ? new Date(event.updated) : null, }; - }) - .filter(({ id }) => !!id); + }); } private async getClient(credential: CalendarCredential) { diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 644d148c64e143..791a1c4aa5efa3 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -76,7 +76,15 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti async validate(request: Request): Promise { // validate handshake - const validationToken = request?.query?.get("validationToken"); + let validationToken: string | null = null; + if (request?.url) { + try { + const urlObj = new URL(request.url); + validationToken = urlObj.searchParams.get("validationToken"); + } catch (e) { + log.warn("Invalid request URL", { url: request.url }); + } + } if (validationToken) return true; // validate notifications diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts index 1abf1fd434a461..f02704a47e6a4b 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -14,7 +14,7 @@ export type CalendarSubscriptionResult = { }; export type CalendarSubscriptionEventItem = { - id?: string | null; + id: string; iCalUID?: string | null; start?: Date; end?: Date; diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index c07a383ad544f8..a6be0882e27a2a 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -7,6 +7,7 @@ import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/ import logger from "@calcom/lib/logger"; import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface"; import { prisma } from "@calcom/prisma"; +import type { SelectedCalendar } from "@calcom/prisma/client"; import type { CalendarCredential, @@ -16,6 +17,9 @@ import type { const log = logger.getSubLogger({ prefix: ["CalendarSubscriptionService"] }); export class CalendarSubscriptionService { + static CALENDAR_SUBSCRIPTION_CACHE_FEATURE = "calendar-subscription-cache" as const; + static CALENDAR_SUBSCRIPTION_SYNC_FEATURE = "calendar-subscription-sync" as const; + constructor( private deps: { adapterFactory: AdapterFactory; @@ -35,7 +39,7 @@ export class CalendarSubscriptionService { return; } - const credential = await this.getCalendarCredential(selectedCalendar.credentialId); + const credential = await this.getCredential(selectedCalendar.credentialId); if (!credential) { log.debug("Calendar credential not found", { selectedCalendarId }); return; @@ -46,7 +50,7 @@ export class CalendarSubscriptionService { ); const res = await calendarSubscriptionAdapter.subscribe(selectedCalendar, credential); - await this.deps.selectedCalendarRepository.updateById(selectedCalendarId, { + await this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, { channelId: res?.id, channelResourceId: res?.resourceId, channelResourceUri: res?.resourceUri, @@ -56,7 +60,7 @@ export class CalendarSubscriptionService { }); // initial event loading - await processEvents(selectedCalendar); + await this.processEvents(selectedCalendar); } /** @@ -67,7 +71,7 @@ export class CalendarSubscriptionService { const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId); if (!selectedCalendar?.credentialId) return; - const credential = await this.getCalendarCredential(selectedCalendar.credentialId); + const credential = await this.getCredential(selectedCalendar.credentialId); if (!credential) return; const calendarSubscriptionAdapter = this.deps.adapterFactory.get( @@ -76,7 +80,7 @@ export class CalendarSubscriptionService { await Promise.all([ calendarSubscriptionAdapter.unsubscribe(selectedCalendar, credential), - this.deps.selectedCalendarRepository.updateById(selectedCalendarId, { + this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, { syncSubscribedAt: null, }), ]); @@ -84,7 +88,10 @@ export class CalendarSubscriptionService { // cleanup cache after unsubscribe if (await this.isCacheEnabled()) { const { CalendarCacheEventService } = await import("./cache/CalendarCacheEventService"); - const calendarCacheEventService = new CalendarCacheEventService(this.deps); + const { CalendarCacheEventRepository } = await import("./cache/CalendarCacheEventRepository"); + const calendarCacheEventService = new CalendarCacheEventService({ + calendarCacheEventRepository: new CalendarCacheEventRepository(prisma), + }); await calendarCacheEventService.cleanupCache(selectedCalendar); } } @@ -104,10 +111,8 @@ export class CalendarSubscriptionService { log.debug("Processing webhook", { channelId }); const selectedCalendar = await this.deps.selectedCalendarRepository.findByChannelId(channelId); - if (!selectedCalendar?.credentialId) { - log.debug("Selected calendar not found", { channelId }); - return; - } + // it maybe caused by an old subscription being triggered + if (!selectedCalendar) return null; // incremental event loading await this.processEvents(selectedCalendar); @@ -126,14 +131,24 @@ export class CalendarSubscriptionService { selectedCalendar.integration as CalendarSubscriptionProvider ); - const [cacheEnabled, syncEnabled] = await Promise.all([this.isCacheEnabled(), this.isSyncEnabled()]); + if (!selectedCalendar.credentialId) { + log.debug("Selected calendar credential not found", { channelId: selectedCalendar.channelId }); + return; + } + // for cache the feature should be enabled globally and by user/team features + const [cacheEnabled, syncEnabled, cacheEnabledForUser] = await Promise.all([ + this.isCacheEnabled(), + this.isSyncEnabled(), + this.isCacheEnabledForUser(selectedCalendar.userId), + ]); + if (!cacheEnabled && !syncEnabled) { - log.debug("No cache or sync enabled", { channelId: selectedCalendar.channelId }); + log.debug("Cache and sync are globally disabled", { channelId: selectedCalendar.channelId }); return; } - log.info("processEvents", { channelId: selectedCalendar.channelId }); - const credential = await this.getCalendarCredential(selectedCalendar.credentialId); + log.debug("Processing events", { channelId: selectedCalendar.channelId }); + const credential = await this.getCredential(selectedCalendar.credentialId); if (!credential) return; let events: CalendarSubscriptionEvent | null = null; @@ -141,7 +156,7 @@ export class CalendarSubscriptionService { events = await calendarSubscriptionAdapter.fetchEvents(selectedCalendar, credential); } catch (err) { log.debug("Error fetching events", { channelId: selectedCalendar.channelId, err }); - await this.deps.selectedCalendarRepository.updateById(selectedCalendar.id, { + await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, { syncErrorAt: new Date(), syncErrorCount: (selectedCalendar.syncErrorCount || 0) + 1, }); @@ -154,14 +169,15 @@ export class CalendarSubscriptionService { } log.debug("Processing events", { channelId: selectedCalendar.channelId, count: events.items.length }); - await this.deps.selectedCalendarRepository.updateById(selectedCalendar.id, { + await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, { syncToken: events.syncToken || selectedCalendar.syncToken, syncedAt: new Date(), syncErrorAt: null, syncErrorCount: 0, }); - if (cacheEnabled) { + // it requires both global and team/user feature cache enabled + if (cacheEnabled && cacheEnabledForUser) { log.debug("Caching events", { count: events.items.length }); const { CalendarCacheEventService } = await import("./cache/CalendarCacheEventService"); const { CalendarCacheEventRepository } = await import("./cache/CalendarCacheEventRepository"); @@ -183,7 +199,10 @@ export class CalendarSubscriptionService { */ async checkForNewSubscriptions() { log.debug("checkForNewSubscriptions"); - const rows = await this.deps.selectedCalendarRepository.findNotSubscribed({ take: 100 }); + const rows = await this.deps.selectedCalendarRepository.findNextSubscriptionBatch({ + take: 100, + integrations: this.deps.adapterFactory.getProviders(), + }); await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); } @@ -192,7 +211,20 @@ export class CalendarSubscriptionService { * @returns true if cache is enabled */ async isCacheEnabled(): Promise { - return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-cache"); + return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE + ); + } + + /** + * Check if cache is enabled for user + * @returns true if cache is enabled + */ + async isCacheEnabledForUser(userId: number): Promise { + return this.deps.featuresRepository.checkIfUserHasFeature( + userId, + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE + ); } /** @@ -200,13 +232,15 @@ export class CalendarSubscriptionService { * @returns true if sync is enabled */ async isSyncEnabled(): Promise { - return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("calendar-subscription-sync"); + return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_SYNC_FEATURE + ); } /** * Get credential with delegation if available */ - private async getCalendarCredential(credentialId: number): Promise { + private async getCredential(credentialId: number): Promise { const credential = await getCredentialForCalendarCache({ credentialId }); if (!credential) return null; return { diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts index 159aefee71194f..06777cbd752fa5 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts @@ -8,19 +8,19 @@ export interface ICalendarCacheEventRepository { * Upserts many events * @param events the list of events to upsert */ - upsertMany(events: Partial[]): Promise; + upsertMany(events: Partial[]): Promise; /** * Deletes many events * @param events the list of events to delete */ - deleteMany(events: Pick[]): Promise; + deleteMany(events: Pick[]): Promise; /** * Deletes all events for a selected calendar * @param selectedCalendarId the id of the calendar */ - deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise; + deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise; /** * diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index 828b7329cb7fd1..75c51a027d74ca 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -30,7 +30,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async upsertMany(events: CalendarCacheEvent[]): Promise { + async upsertMany(events: CalendarCacheEvent[]): Promise { if (events.length === 0) { return; } @@ -59,7 +59,9 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito ); } - async deleteMany(events: Pick[]): Promise { + async deleteMany( + events: Pick[] + ): Promise { // Only delete events with externalId and selectedCalendarId const conditions = events.filter((c) => c.externalId && c.selectedCalendarId); if (conditions.length === 0) { @@ -73,8 +75,8 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise { - this.prismaClient.calendarCacheEvent.deleteMany({ + async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise { + return this.prismaClient.calendarCacheEvent.deleteMany({ where: { selectedCalendarId, }, diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index afd5b2c8d2edfc..af7ecd19f6a8f8 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -44,7 +44,7 @@ export class CalendarCacheEventService { timeZone: event.timeZone, originalStartTime: event.originalStartDate, recurringEventId: event.recurringEventId, - externalEtag: event.etag, + externalEtag: event.etag || "", externalCreatedAt: event.createdAt, externalUpdatedAt: event.updatedAt, }); @@ -83,8 +83,8 @@ export class CalendarCacheEventService { * @param appId * @returns */ - static isAppSupported(appId: string | null): boolean { + static isCalendarTypeSupported(appId: string | null): boolean { if (!appId) return false; - return ["google-calendar", "office365-calendar"].includes(appId); + return ["google_calendar", "office365_calendar"].includes(appId); } } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts index c6d57144aac86f..225834850d7883 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -63,9 +63,13 @@ export class CalendarCacheWrapper implements Calendar { dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[], - _shouldServeCache?: boolean, + shouldServeCache?: boolean, _fallbackToPrimary?: boolean ): Promise { + if (!shouldServeCache) { + return this.deps.originalCalendar.getAvailability(dateFrom, dateTo, selectedCalendars); + } + log.debug("getAvailability from cache", { dateFrom, dateTo, selectedCalendars }); const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); if (!selectedCalendarIds.length) { diff --git a/packages/lib/server/repository/SelectedCalendarRepository.interface.ts b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts index e688bb963b71fc..55b49b7b5ecebd 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.interface.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts @@ -1,9 +1,62 @@ -import type { Prisma } from "@calcom/prisma/client"; - -export class ISelectedCalendarRepository { - findById(id: string): Promise; - findNotSubscribed({ take }: { take: number }): Promise; - findMany(args: { where: Prisma.SelectedCalendarWhereInput }): Promise; - findByChannelId(channelId: string): Promise; - updateById(id: string, data: Prisma.SelectedCalendarUpdateInput): Promise; +import type { Prisma, SelectedCalendar } from "@calcom/prisma/client"; + +export interface ISelectedCalendarRepository { + /** + * Find selected calendar by id + * + * @param id + */ + findById(id: string): Promise; + + /** + * Find selected calendar by channel id + * + * @param channelId + */ + findByChannelId(channelId: string): Promise; + + /** + * Find next batch of selected calendars + * Will check if syncSubscribedAt is null or channelExpiration is greater than current date + * + * @param take the number of calendars to take + * @param integrations the list of integrations + */ + findNextSubscriptionBatch({ + take, + integrations, + }: { + take: number; + integrations?: string[]; + }): Promise; + + /** + * Update status of sync for selected calendar + * + * @param id + * @param data + */ + updateSyncStatus( + id: string, + data: Pick< + Prisma.SelectedCalendarUpdateInput, + "syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount" + > + ): Promise; + + /** + * Update subscription status for selected calendar + */ + updateSubscription( + id: string, + data: Pick< + Prisma.SelectedCalendarUpdateInput, + | "channelId" + | "channelResourceId" + | "channelResourceUri" + | "channelKind" + | "channelExpiration" + | "syncSubscribedAt" + > + ): Promise; } diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts index c432e1cadbabf1..deeae6b6c71560 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -1,8 +1,7 @@ +import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; -import type { ISelectedCalendarRepository } from "./SelectedCalendarRepository.interface"; - export class SelectedCalendarRepository implements ISelectedCalendarRepository { constructor(private prismaClient: PrismaClient) {} @@ -19,25 +18,52 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { }); } - async findNotSubscribed({ take }: { take: number }) { - return this.prismaClient.selectedCalendar.findMany({ - where: { syncSubscribedAt: null }, - take, - }); + async findByChannelId(channelId: string) { + return this.prismaClient.selectedCalendar.findFirst({ where: { channelId } }); } - async findMany(args: { where: Prisma.SelectedCalendarWhereInput }) { + async findNextSubscriptionBatch({ take, integrations }: { take: number; integrations: string[] }) { return this.prismaClient.selectedCalendar.findMany({ - where: args.where, - select: { id: true, externalId: true, credentialId: true, syncedAt: true }, + where: { + integration: { in: integrations }, + OR: [ + { + syncSubscribedAt: null, + channelExpiration: { + gte: new Date(), + }, + }, + ], + }, + take, }); } - async findByChannelId(channelId: string) { - return this.prismaClient.selectedCalendar.findFirst({ where: { channelId } }); + async updateSyncStatus( + id: string, + data: Pick< + Prisma.SelectedCalendarUpdateInput, + "syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount" + > + ) { + return this.prismaClient.selectedCalendar.update({ + where: { id }, + data, + }); } - async updateById(id: string, data: Prisma.SelectedCalendarUpdateInput) { + async updateSubscription( + id: string, + data: Pick< + Prisma.SelectedCalendarUpdateInput, + | "channelId" + | "channelResourceId" + | "channelResourceUri" + | "channelKind" + | "channelExpiration" + | "syncSubscribedAt" + > + ) { return this.prismaClient.selectedCalendar.update({ where: { id }, data, From 1d2e0f15dcb44f0000c47804c183bfb87d5b4d2a Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 18 Sep 2025 17:40:51 -0300 Subject: [PATCH 13/49] Fix remove dynamic imports --- .../api/cron/calendar-subscriptions/route.ts | 24 +++++++++++++++ .../calendar-subscription/[provider]/route.ts | 12 ++++++++ .../lib/CalendarSubscriptionService.ts | 30 +++++++------------ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 8c7a2f49044283..c3f1cee471a37e 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -4,6 +4,9 @@ import { NextResponse } from "next/server"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; +import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; +import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; @@ -25,11 +28,32 @@ async function postHandler(request: NextRequest) { return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } + // instantiate dependencies + const calendarSyncService = new CalendarSyncService(); + const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); + const calendarCacheEventService = new CalendarCacheEventService({ + calendarCacheEventRepository, + }); + + // are features globally enabled + const [isCacheEnabled, isSyncEnabled] = await Promise.all([ + calendarSubscriptionService.isCacheEnabled(), + calendarSubscriptionService.isSyncEnabled(), + ]); + + if (!isCacheEnabled || !isSyncEnabled) { + log.info("Calendar subscriptions are disabled"); + return NextResponse.json({ ok: true }); + } + const calendarSubscriptionService = new CalendarSubscriptionService({ adapterFactory: new DefaultAdapterFactory(), selectedCalendarRepository: new SelectedCalendarRepository(prisma), featuresRepository: new FeaturesRepository(prisma), + calendarSyncService, + calendarCacheEventService, }); + try { await calendarSubscriptionService.checkForNewSubscriptions(); log.info("Checked for new calendar subscriptions successfully"); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 07e02d892796c1..279ebb7bdee0da 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -5,6 +5,9 @@ import { NextResponse } from "next/server"; import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; +import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; +import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; @@ -35,10 +38,19 @@ async function postHandler(request: NextRequest, context: { params: Promise Date: Thu, 18 Sep 2025 17:56:46 -0300 Subject: [PATCH 14/49] Add cleanup stale cache --- .../calendar-subscriptions-cleanup/route.ts | 44 +++++++++++++++++++ .../api/cron/calendar-subscriptions/route.ts | 17 +++---- apps/web/cron-tester.ts | 1 - apps/web/vercel.json | 4 ++ .../lib/CalendarSubscriptionService.ts | 2 + .../CalendarCacheEventRepository.interface.ts | 5 +++ .../lib/cache/CalendarCacheEventRepository.ts | 10 +++++ .../lib/cache/CalendarCacheEventService.ts | 8 ++++ 8 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts new file mode 100644 index 00000000000000..6489d07f272740 --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts @@ -0,0 +1,44 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; +import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["cron"] }); + +/** + * Cron webhook + * Cleanup stale calendar cache + * + * @param request + * @returns + */ +async function postHandler(request: NextRequest) { + log.info("Cleaning up stale calendar cache events"); + 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 }); + } + + // instantiate dependencies + const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); + const calendarCacheEventService = new CalendarCacheEventService({ + calendarCacheEventRepository, + }); + + try { + await calendarCacheEventService.cleanupStaleCache(); + log.info("Stale calendar cache events cleaned up"); + return NextResponse.json({ ok: true }); + } catch (e) { + log.error("Error cleaning up stale calendar cache events", { error: e }); + const message = e instanceof Error ? e.message : "Unknown error"; + return NextResponse.json({ message }, { status: 500 }); + } +} + +export const GET = defaultResponderForAppDir(postHandler); diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index c3f1cee471a37e..38194f4241fb6c 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -15,6 +15,7 @@ import { prisma } from "@calcom/prisma"; const log = logger.getSubLogger({ prefix: ["cron"] }); /** + * Cron webhook * Checks for new calendar subscriptions (rollouts) * * @param request @@ -35,6 +36,14 @@ async function postHandler(request: NextRequest) { calendarCacheEventRepository, }); + const calendarSubscriptionService = new CalendarSubscriptionService({ + adapterFactory: new DefaultAdapterFactory(), + selectedCalendarRepository: new SelectedCalendarRepository(prisma), + featuresRepository: new FeaturesRepository(prisma), + calendarSyncService, + calendarCacheEventService, + }); + // are features globally enabled const [isCacheEnabled, isSyncEnabled] = await Promise.all([ calendarSubscriptionService.isCacheEnabled(), @@ -46,14 +55,6 @@ async function postHandler(request: NextRequest) { return NextResponse.json({ ok: true }); } - const calendarSubscriptionService = new CalendarSubscriptionService({ - adapterFactory: new DefaultAdapterFactory(), - selectedCalendarRepository: new SelectedCalendarRepository(prisma), - featuresRepository: new FeaturesRepository(prisma), - calendarSyncService, - calendarCacheEventService, - }); - try { await calendarSubscriptionService.checkForNewSubscriptions(); log.info("Checked for new calendar subscriptions successfully"); diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts index e6fddda70ac9bb..808b83a70828db 100755 --- a/apps/web/cron-tester.ts +++ b/apps/web/cron-tester.ts @@ -26,7 +26,6 @@ try { fetchCron("/cron/calendar-subscriptions"), // fetchCron("/calendar-cache/cron"), // fetchCron("/cron/calVideoNoShowWebhookTriggers"), - // fetchCron("/tasks/cron"), ]); }, diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 09d297bfb02689..8bbd2b3daca6cf 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -8,6 +8,10 @@ "path": "/api/cron/calendar-subscriptions", "schedule": "*/5 * * * *" }, + { + "path": "/api/cron/calendar-subscriptions-cleanup", + "schedule": "0 3 * * *" + }, { "path": "/api/cron/queuedFormResponseCleanup", "schedule": "0 */12 * * *" diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 61da3ec448d595..6179a9391ec7a7 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -6,6 +6,8 @@ import type { CalendarCredential, CalendarSubscriptionEvent, } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; +import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; import logger from "@calcom/lib/logger"; diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts index 06777cbd752fa5..e93a753fc8347c 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts @@ -22,6 +22,11 @@ export interface ICalendarCacheEventRepository { */ deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise; + /** + * Deletes all stale events + */ + deleteStale(): Promise; + /** * * @param selectedCalendarId diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index 75c51a027d74ca..5d5c0cafcb34a3 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -82,4 +82,14 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }, }); } + + async deleteStale(): Promise { + return this.prismaClient.calendarCacheEvent.deleteMany({ + where: { + end: { + lte: new Date(), + }, + }, + }); + } } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index af7ecd19f6a8f8..c09984f104b115 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -77,6 +77,14 @@ export class CalendarCacheEventService { await this.deps.calendarCacheEventRepository.deleteAllBySelectedCalendarId(selectedCalendar.id); } + /** + * Removes stale events from the cache + */ + async cleanupStaleCache(): Promise { + log.debug("cleanupStaleCache"); + await this.deps.calendarCacheEventRepository.deleteStale(); + } + /** * Checks if the app is supported * From d9891c125ab7019fc1547dc08eb762ff730bd266 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 18 Sep 2025 21:03:41 +0000 Subject: [PATCH 15/49] Fix tests --- .../googlecalendar/lib/__mocks__/features.repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts index d84d6f80d350dd..5cdde7917680fc 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts @@ -3,6 +3,7 @@ import { vi } from "vitest"; const featuresRepositoryModuleMock = { FeaturesRepository: vi.fn().mockImplementation(() => ({ checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + checkIfUserHasFeature: vi.fn().mockResolvedValue(true), })), }; From e3255525ce71dae193348595ad453f9b0ab02528 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 18 Sep 2025 21:15:21 +0000 Subject: [PATCH 16/49] add event update --- .../lib/sync/CalendarSyncService.ts | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts index dca84d02f9483e..11bf42cef9c21c 100644 --- a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -1,5 +1,8 @@ +import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { SelectedCalendar } from "@calcom/prisma/client"; const log = logger.getSubLogger({ prefix: ["CalendarSyncService"] }); @@ -52,7 +55,7 @@ export class CalendarSyncService { */ async cancelBooking(event: CalendarSubscriptionEventItem) { log.debug("cancelBooking", { event }); - // TODO implement + // TODO implement (reference needed) } /** @@ -61,6 +64,77 @@ export class CalendarSyncService { */ async rescheduleBooking(event: CalendarSubscriptionEventItem) { log.debug("rescheduleBooking", { event }); - // TODO implement + const booking = await BookingRepository.findMany({ + iCalUID: event.iCalUID, + }); + if (!booking) { + log.debug("rescheduleBooking: no booking found", { iCalUID: event.iCalUID }); + return; + } + try { + const rescheduleResult = await handleBookingTimeChange({ + booking, + newStartTime: startTime, + newEndTime: endTime, + rescheduledBy, + }); + return rescheduleResult; + } catch (error) { + // silently fail for now + log.error("Failed to reschedule booking", { bookingId }, safeStringify(error)); + } + } + + /** + * Handles a booking time change + */ + private async handleBookingTimeChange({ + booking, + newStartTime, + newEndTime, + rescheduledBy, + }: { + booking: { + id: number; + eventType: { + id: number; + slug: string; + }; + uid: string; + bookerAttendee: { + timeZone: string; + }; + responses: Record & { + rescheduleReason: string; + }; + }; + newStartTime: Date; + newEndTime: Date; + rescheduledBy: string; + }) { + const tEnglish = await getTranslation("en", "common"); + await handleNewBooking({ + bookingData: { + bookingUid: booking.uid, + bookingId: booking.id, + eventTypeId: booking.eventType.id, + eventTypeSlug: booking.eventType.slug, + start: newStartTime.toISOString(), + end: newEndTime.toISOString(), + rescheduledBy, + rescheduleUid: booking.uid, + hasHashedBookingLink: false, + language: "en", + timeZone: booking.bookerAttendee.timeZone, + metadata: {}, + responses: { + ...booking.responses, + rescheduleReason: tEnglish("event_moved_in_calendar"), + }, + }, + skipAvailabilityCheck: true, + skipEventLimitsCheck: true, + skipCalendarSyncTaskCreation: true, + }); } } From 72f30ae0a5acdb5a9454064eb74126b23c726bd5 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 18 Sep 2025 18:46:40 -0300 Subject: [PATCH 17/49] type fixes --- packages/app-store/_utils/getCalendar.ts | 5 ++--- .../calendar-subscription/lib/sync/CalendarSyncService.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index d6611f7fa3fc16..dae903c40a4cc7 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -54,7 +54,7 @@ export const getCalendar = async ( CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE ), featuresRepository.checkIfUserHasFeature( - credential.userId, + credential.userId as number, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE ), ] @@ -62,8 +62,7 @@ export const getCalendar = async ( if (isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser) { log.debug(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const originalCalendar = new CalendarService(credential as any); + const originalCalendar = new CalendarService(credential as unknown); if (originalCalendar) { // return cacheable calendar const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts index 11bf42cef9c21c..0b028993b0f81f 100644 --- a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -1,6 +1,7 @@ import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { SelectedCalendar } from "@calcom/prisma/client"; @@ -81,7 +82,7 @@ export class CalendarSyncService { return rescheduleResult; } catch (error) { // silently fail for now - log.error("Failed to reschedule booking", { bookingId }, safeStringify(error)); + log.error("Failed to reschedule booking", { bookingId: booking.id }, safeStringify(error)); } } @@ -132,7 +133,6 @@ export class CalendarSyncService { rescheduleReason: tEnglish("event_moved_in_calendar"), }, }, - skipAvailabilityCheck: true, skipEventLimitsCheck: true, skipCalendarSyncTaskCreation: true, }); From 088aa3fdd5a463baed33f46a076d75e1426de11c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:33:58 +0000 Subject: [PATCH 18/49] feat: add comprehensive tests for new calendar subscription API routes - Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests) - Add tests for /api/cron/calendar-subscriptions route (10 tests) - Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests) - Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync - All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation Tests cover: - Authentication scenarios (API key validation, Bearer tokens, query parameters) - Feature flag combinations (cache/sync enabled/disabled states) - Success and error handling (including non-Error exceptions) - Service instantiation with proper dependency injection - Provider validation for webhook endpoints Co-Authored-By: Volnei Munhoz --- .../__tests__/route.test.ts | 183 ++++++++++ .../__tests__/route.test.ts | 250 ++++++++++++++ .../[provider]/__tests__/route.test.ts | 315 ++++++++++++++++++ packages/features/flags/hooks/index.ts | 2 + 4 files changed, 750 insertions(+) create mode 100644 apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts create mode 100644 apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts create mode 100644 apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts new file mode 100644 index 00000000000000..39d834ec3ebd57 --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts @@ -0,0 +1,183 @@ +import { NextRequest } from "next/server"; +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; + +vi.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + url: string; + method: string; + nextUrl: { searchParams: URLSearchParams }; + private _headers: Map; + + constructor(url: string, options: { method?: string } = {}) { + this.url = url; + this.method = options.method || "GET"; + this._headers = new Map(); + this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") }; + } + + headers = { + get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null, + set: (key: string, value: string): void => { + this._headers.set(key.toLowerCase(), value); + }, + has: (key: string): boolean => this._headers.has(key.toLowerCase()), + }; + }, + NextResponse: { + json: vi.fn((body, init) => ({ + json: vi.fn().mockResolvedValue(body), + status: init?.status || 200, + })), + }, +})); + +vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"); +vi.mock("@calcom/prisma", () => ({ + prisma: {}, +})); + +const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService); + +describe("/api/cron/calendar-subscriptions-cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("CRON_API_KEY", "test-cron-key"); + vi.stubEnv("CRON_SECRET", "test-cron-secret"); + }); + + describe("Authentication", () => { + test("should return 401 when no API key is provided", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.message).toBe("Not authenticated"); + }); + + test("should return 401 when invalid API key is provided", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "invalid-key"); + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.message).toBe("Not authenticated"); + }); + + test("should accept CRON_API_KEY in authorization header", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "test-cron-key"); + + const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + expect(mockCleanupStaleCache).toHaveBeenCalled(); + }); + + test("should accept CRON_SECRET as Bearer token", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "Bearer test-cron-secret"); + + const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + expect(mockCleanupStaleCache).toHaveBeenCalled(); + }); + + test("should accept API key as query parameter", async () => { + const request = new NextRequest( + "http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key" + ); + + const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + expect(mockCleanupStaleCache).toHaveBeenCalled(); + }); + }); + + describe("Cleanup functionality", () => { + test("should successfully cleanup stale cache", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "test-cron-key"); + + const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mockCleanupStaleCache).toHaveBeenCalledOnce(); + }); + + test("should handle cleanup errors gracefully", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "test-cron-key"); + + const mockError = new Error("Database connection failed"); + const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Database connection failed"); + }); + + test("should handle non-Error exceptions", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "test-cron-key"); + + const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error"); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Unknown error"); + }); + }); + + describe("Service instantiation", () => { + test("should instantiate CalendarCacheEventService with correct dependencies", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); + request.headers.set("authorization", "test-cron-key"); + + const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); + mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; + + const { GET } = await import("../route"); + await GET(request, { params: Promise.resolve({}) }); + + expect(mockCalendarCacheEventService).toHaveBeenCalledWith({ + calendarCacheEventRepository: expect.any(Object), + }); + }); + }); +}); diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts new file mode 100644 index 00000000000000..f09dba63b30244 --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts @@ -0,0 +1,250 @@ +import { NextRequest } from "next/server"; +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; + +vi.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + url: string; + method: string; + nextUrl: { searchParams: URLSearchParams }; + private _headers: Map; + + constructor(url: string, options: { method?: string } = {}) { + this.url = url; + this.method = options.method || "GET"; + this._headers = new Map(); + this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") }; + } + + headers = { + get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null, + set: (key: string, value: string): void => { + this._headers.set(key.toLowerCase(), value); + }, + has: (key: string): boolean => this._headers.has(key.toLowerCase()), + }; + }, + NextResponse: { + json: vi.fn((body, init) => ({ + json: vi.fn().mockResolvedValue(body), + status: init?.status || 200, + })), + }, +})); + +vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"); +vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"); +vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"); +vi.mock("@calcom/prisma", () => ({ + prisma: {}, +})); + +const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService); + +describe("/api/cron/calendar-subscriptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + vi.stubEnv("CRON_API_KEY", "test-cron-key"); + vi.stubEnv("CRON_SECRET", "test-cron-secret"); + }); + + describe("Authentication", () => { + test("should return 401 when no API key is provided", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.message).toBe("Not authenticated"); + }, 10000); + + test("should return 401 when invalid API key is provided", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "invalid-key"); + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.message).toBe("Not authenticated"); + }); + + test("should accept valid API key", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + }); + }); + + describe("Feature flag checks", () => { + test("should return early when cache is disabled", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(false); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn(); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled(); + }); + + test("should return early when sync is disabled", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockCheckForNewSubscriptions = vi.fn(); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled(); + }); + + test("should proceed when both cache and sync are enabled", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce(); + }); + }); + + describe("Subscription checking functionality", () => { + test("should successfully check for new subscriptions", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce(); + }); + + test("should handle subscription checking errors gracefully", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockError = new Error("Subscription service unavailable"); + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue(mockError); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Subscription service unavailable"); + }); + + test("should handle non-Error exceptions", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue("String error"); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + const response = await GET(request, { params: Promise.resolve({}) }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Unknown error"); + }); + }); + + describe("Service instantiation", () => { + test("should instantiate all services with correct dependencies", async () => { + const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); + request.headers.set("authorization", "test-cron-key"); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; + + const { GET } = await import("../route"); + await GET(request, { params: Promise.resolve({}) }); + + expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({ + adapterFactory: expect.any(Object), + selectedCalendarRepository: expect.any(Object), + featuresRepository: expect.any(Object), + calendarSyncService: expect.any(Object), + calendarCacheEventService: expect.any(Object), + }); + }); + }); +}); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts new file mode 100644 index 00000000000000..da7a6c64d0faad --- /dev/null +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts @@ -0,0 +1,315 @@ +import { NextRequest } from "next/server"; +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; + +vi.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + url: string; + method: string; + nextUrl: { searchParams: URLSearchParams }; + private _headers: Map; + + constructor(url: string, options: { method?: string } = {}) { + this.url = url; + this.method = options.method || "POST"; + this._headers = new Map(); + this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") }; + } + + headers = { + get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null, + set: (key: string, value: string): void => { + this._headers.set(key.toLowerCase(), value); + }, + has: (key: string): boolean => this._headers.has(key.toLowerCase()), + }; + }, + NextResponse: { + json: vi.fn((body, init) => ({ + json: vi.fn().mockResolvedValue(body), + status: init?.status || 200, + })), + }, +})); + +vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"); +vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"); +vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"); +vi.mock("@calcom/prisma", () => ({ + prisma: {}, +})); + +const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService); + +describe("/api/webhooks/calendar-subscription/[provider]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + describe("Provider validation", () => { + test("should accept google_calendar provider", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }, 10000); + + test("should accept office365_calendar provider", async () => { + const request = new NextRequest( + "http://localhost/api/webhooks/calendar-subscription/office365_calendar", + { + method: "POST", + } + ); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "office365_calendar" }), + }); + + expect(response.status).toBe(200); + expect(mockProcessWebhook).toHaveBeenCalledWith("office365_calendar", request); + }); + + test("should reject unsupported provider", async () => { + const request = new NextRequest( + "http://localhost/api/webhooks/calendar-subscription/unsupported_calendar", + { + method: "POST", + } + ); + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "unsupported_calendar" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Unsupported provider"); + }); + }); + + describe("Feature flag handling", () => { + test("should return 200 when neither cache nor sync is enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(false); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn(); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("No cache or sync enabled"); + expect(mockProcessWebhook).not.toHaveBeenCalled(); + }); + + test("should process webhook when cache is enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("Webhook processed"); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }); + + test("should process webhook when sync is enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(false); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("Webhook processed"); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }); + + test("should process webhook when both cache and sync are enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("Webhook processed"); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }); + }); + + describe("Error handling", () => { + test("should handle webhook processing errors gracefully", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockError = new Error("Webhook validation failed"); + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockRejectedValue(mockError); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Webhook validation failed"); + }); + + test("should handle non-Error exceptions", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockRejectedValue("String error"); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Unknown error"); + }); + + test("should handle feature flag check errors", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockError = new Error("Feature flag service unavailable"); + const mockIsCacheEnabled = vi.fn().mockRejectedValue(mockError); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Feature flag service unavailable"); + }); + }); + + describe("Service instantiation", () => { + test("should instantiate all services with correct dependencies", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({ + adapterFactory: expect.any(Object), + selectedCalendarRepository: expect.any(Object), + featuresRepository: expect.any(Object), + calendarSyncService: expect.any(Object), + calendarCacheEventService: expect.any(Object), + }); + }); + }); +}); diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 60dbb64026179a..a1789bfd823be2 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -25,6 +25,8 @@ const initialData: AppFlags = { "team-booking-page-cache": false, "cal-ai-voice-agents": false, "tiered-support-chat": false, + "calendar-subscription-cache": false, + "calendar-subscription-sync": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { From 10b8607e5194cb8a62fcfe1297b42ac5c605625e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:23:37 +0000 Subject: [PATCH 19/49] feat: add comprehensive tests for calendar subscription services, repositories, and adapters - Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing - Add unit tests for CalendarCacheEventService with cache operations and cleanup - Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations - Add unit tests for CalendarCacheEventRepository with CRUD operations - Add unit tests for SelectedCalendarRepository with calendar selection management - Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching - Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation - Add unit tests for AdaptersFactory with provider management and adapter creation - Fix lint issues by removing explicit 'any' type casting and unused variables - All tests follow Cal.com conventions using Vitest framework with proper mocking Co-Authored-By: Volnei Munhoz --- .../adapters/__mocks__/CalendarAuth.ts | 17 + .../__tests__/AdaptersFactory.test.ts | 54 ++ .../GoogleCalendarSubscriptionAdapter.test.ts | 493 ++++++++++++++++++ ...fice365CalendarSubscriptionAdapter.test.ts | 49 ++ .../lib/__mocks__/delegationCredential.ts | 12 + .../CalendarSubscriptionService.test.ts | 418 +++++++++++++++ .../CalendarCacheEventRepository.test.ts | 248 +++++++++ .../CalendarCacheEventService.test.ts | 354 +++++++++++++ .../__tests__/CalendarSyncService.test.ts | 349 +++++++++++++ .../SelectedCalendarRepository.test.ts | 297 +++++++++++ 10 files changed, 2291 insertions(+) create mode 100644 packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts create mode 100644 packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts create mode 100644 packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts create mode 100644 packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts create mode 100644 packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts create mode 100644 packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts create mode 100644 packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts create mode 100644 packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts create mode 100644 packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts create mode 100644 packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts diff --git a/packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts b/packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts new file mode 100644 index 00000000000000..6cc339506de044 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts @@ -0,0 +1,17 @@ +import { vi } from "vitest"; + +export const CalendarAuth = vi.fn().mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue({ + events: { + watch: vi.fn(), + list: vi.fn(), + }, + channels: { + stop: vi.fn(), + }, + }), +})); + +vi.doMock("@calcom/app-store/googlecalendar/lib/CalendarAuth", () => ({ + CalendarAuth, +})); diff --git a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts new file mode 100644 index 00000000000000..0d37211ed7705c --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, beforeEach } from "vitest"; + +import { DefaultAdapterFactory } from "../AdaptersFactory"; +import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter"; +import { Office365CalendarSubscriptionAdapter } from "../Office365CalendarSubscription.adapter"; + +describe("DefaultAdapterFactory", () => { + let factory: DefaultAdapterFactory; + + beforeEach(() => { + factory = new DefaultAdapterFactory(); + }); + + describe("get", () => { + test("should return GoogleCalendarSubscriptionAdapter for google_calendar", () => { + const adapter = factory.get("google_calendar"); + + expect(adapter).toBeInstanceOf(GoogleCalendarSubscriptionAdapter); + }); + + test("should return Office365CalendarSubscriptionAdapter for office365_calendar", () => { + const adapter = factory.get("office365_calendar"); + + expect(adapter).toBeInstanceOf(Office365CalendarSubscriptionAdapter); + }); + + test("should return the same instance for multiple calls (singleton)", () => { + const adapter1 = factory.get("google_calendar"); + const adapter2 = factory.get("google_calendar"); + + expect(adapter1).toBe(adapter2); + }); + + test("should throw error for unsupported provider", () => { + expect(() => { + factory.get("unsupported_calendar" as never); + }).toThrow("No adapter found for provider unsupported_calendar"); + }); + }); + + describe("getProviders", () => { + test("should return all available providers", () => { + const providers = factory.getProviders(); + + expect(providers).toEqual(["google_calendar", "office365_calendar"]); + }); + + test("should return array with correct length", () => { + const providers = factory.getProviders(); + + expect(providers).toHaveLength(2); + }); + }); +}); diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts new file mode 100644 index 00000000000000..779ce5ce8981c4 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -0,0 +1,493 @@ +import "../__mocks__/CalendarAuth"; + +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter"; + +vi.mock("uuid", () => ({ + v4: vi.fn().mockReturnValue("test-uuid"), +})); + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +const mockCredential = { + id: 1, + key: { access_token: "test-token" }, + user: { email: "test@example.com" }, + delegatedTo: null, +}; + +describe("GoogleCalendarSubscriptionAdapter", () => { + let adapter: GoogleCalendarSubscriptionAdapter; + let mockClient: { + events: { + watch: ReturnType; + list: ReturnType; + }; + channels: { + stop: ReturnType; + }; + }; + + beforeEach(async () => { + process.env.GOOGLE_WEBHOOK_TOKEN = "test-webhook-token"; + process.env.GOOGLE_WEBHOOK_URL = "https://example.com"; + + mockClient = { + events: { + watch: vi.fn(), + list: vi.fn(), + }, + channels: { + stop: vi.fn(), + }, + }; + + const { CalendarAuth } = await import("../__mocks__/CalendarAuth"); + vi.mocked(CalendarAuth).mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue(mockClient), + })); + + adapter = new GoogleCalendarSubscriptionAdapter(); + vi.clearAllMocks(); + }); + + describe("validate", () => { + test("should validate webhook with correct token", async () => { + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue("test-webhook-token"), + }, + } as Request; + + const result = await adapter.validate(mockRequest); + + expect(result).toBe(true); + expect(mockRequest.headers.get).toHaveBeenCalledWith("X-Goog-Channel-Token"); + }); + + test("should reject webhook with incorrect token", async () => { + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue("wrong-token"), + }, + } as Request; + + const result = await adapter.validate(mockRequest); + + expect(result).toBe(false); + }); + + test("should reject webhook when token is not configured", async () => { + delete process.env.GOOGLE_WEBHOOK_TOKEN; + adapter = new GoogleCalendarSubscriptionAdapter(); + + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue("test-webhook-token"), + }, + } as Request; + + const result = await adapter.validate(mockRequest); + + expect(result).toBe(false); + }); + + test("should reject webhook with missing token header", async () => { + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue(null), + }, + } as Request; + + const result = await adapter.validate(mockRequest); + + expect(result).toBe(false); + }); + }); + + describe("extractChannelId", () => { + test("should extract channel ID from webhook headers", async () => { + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue("test-channel-id"), + }, + } as Request; + + const result = await adapter.extractChannelId(mockRequest); + + expect(result).toBe("test-channel-id"); + expect(mockRequest.headers.get).toHaveBeenCalledWith("X-Goog-Channel-ID"); + }); + + test("should return null when channel ID is missing", async () => { + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue(null), + }, + } as Request; + + const result = await adapter.extractChannelId(mockRequest); + + expect(result).toBeNull(); + }); + }); + + describe("subscribe", () => { + test("should successfully subscribe to calendar", async () => { + const mockWatchResponse = { + data: { + id: "test-channel-id", + resourceId: "test-resource-id", + resourceUri: "test-resource-uri", + expiration: "1640995200000", + }, + }; + + mockClient.events.watch.mockResolvedValue(mockWatchResponse); + + const result = await adapter.subscribe(mockSelectedCalendar, mockCredential); + + expect(mockClient.events.watch).toHaveBeenCalledWith({ + calendarId: "test@example.com", + requestBody: { + id: "test-uuid", + type: "web_hook", + address: "https://example.com/api/webhooks/calendar-subscription/google_calendar", + token: "test-webhook-token", + params: { + ttl: "2592000", + }, + }, + }); + + expect(result).toEqual({ + provider: "google_calendar", + id: "test-channel-id", + resourceId: "test-resource-id", + resourceUri: "test-resource-uri", + expiration: new Date(1640995200000), + }); + }); + + test("should handle expiration as ISO string", async () => { + const mockWatchResponse = { + data: { + id: "test-channel-id", + resourceId: "test-resource-id", + resourceUri: "test-resource-uri", + expiration: "2023-12-01T10:00:00Z", + }, + }; + + mockClient.events.watch.mockResolvedValue(mockWatchResponse); + + const result = await adapter.subscribe(mockSelectedCalendar, mockCredential); + + expect(result.expiration).toEqual(new Date("2023-12-01T10:00:00Z")); + }); + + test("should handle missing expiration", async () => { + const mockWatchResponse = { + data: { + id: "test-channel-id", + resourceId: "test-resource-id", + resourceUri: "test-resource-uri", + }, + }; + + mockClient.events.watch.mockResolvedValue(mockWatchResponse); + + const result = await adapter.subscribe(mockSelectedCalendar, mockCredential); + + expect(result.expiration).toBeNull(); + }); + }); + + describe("unsubscribe", () => { + test("should successfully unsubscribe from calendar", async () => { + mockClient.channels.stop.mockResolvedValue({}); + + await adapter.unsubscribe(mockSelectedCalendar, mockCredential); + + expect(mockClient.channels.stop).toHaveBeenCalledWith({ + requestBody: { + resourceId: "test-resource-id", + }, + }); + }); + + test("should handle unsubscribe errors", async () => { + const error = new Error("Unsubscribe failed"); + mockClient.channels.stop.mockRejectedValue(error); + + await expect(adapter.unsubscribe(mockSelectedCalendar, mockCredential)).rejects.toThrow( + "Unsubscribe failed" + ); + }); + }); + + describe("fetchEvents", () => { + test("should fetch events with sync token", async () => { + const mockEventsResponse = { + data: { + nextSyncToken: "new-sync-token", + items: [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + summary: "Test Event", + description: "Test Description", + location: "Test Location", + start: { + dateTime: "2023-12-01T10:00:00Z", + timeZone: "UTC", + }, + end: { + dateTime: "2023-12-01T11:00:00Z", + }, + status: "confirmed", + transparency: "opaque", + kind: "calendar#event", + etag: "test-etag", + created: "2023-12-01T09:00:00Z", + updated: "2023-12-01T09:30:00Z", + }, + ], + }, + }; + + mockClient.events.list.mockResolvedValue(mockEventsResponse); + + const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential); + + expect(mockClient.events.list).toHaveBeenCalledWith({ + calendarId: "test@example.com", + pageToken: undefined, + singleEvents: true, + syncToken: "test-sync-token", + }); + + expect(result).toEqual({ + provider: "google_calendar", + syncToken: "new-sync-token", + items: [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + kind: "calendar#event", + etag: "test-etag", + status: "confirmed", + isAllDay: undefined, + timeZone: "UTC", + recurringEventId: undefined, + originalStartTime: undefined, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + }, + ], + }); + }); + + test("should fetch events without sync token (initial sync)", async () => { + const calendarWithoutSyncToken = { + ...mockSelectedCalendar, + syncToken: null, + }; + + const mockEventsResponse = { + data: { + nextSyncToken: "initial-sync-token", + items: [], + }, + }; + + mockClient.events.list.mockResolvedValue(mockEventsResponse); + + const result = await adapter.fetchEvents(calendarWithoutSyncToken, mockCredential); + + expect(mockClient.events.list).toHaveBeenCalledWith({ + calendarId: "test@example.com", + pageToken: undefined, + singleEvents: true, + timeMin: expect.any(String), + timeMax: expect.any(String), + }); + + expect(result.syncToken).toBe("initial-sync-token"); + }); + + test("should handle all-day events", async () => { + const mockEventsResponse = { + data: { + nextSyncToken: "new-sync-token", + items: [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + summary: "All Day Event", + start: { + date: "2023-12-01", + }, + end: { + date: "2023-12-02", + }, + status: "confirmed", + transparency: "opaque", + }, + ], + }, + }; + + mockClient.events.list.mockResolvedValue(mockEventsResponse); + + const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential); + + expect(result.items[0].isAllDay).toBe(true); + expect(result.items[0].start).toEqual(new Date("2023-12-01")); + expect(result.items[0].end).toEqual(new Date("2023-12-02")); + }); + + test("should handle free events (transparent)", async () => { + const mockEventsResponse = { + data: { + nextSyncToken: "new-sync-token", + items: [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + summary: "Free Event", + start: { + dateTime: "2023-12-01T10:00:00Z", + }, + end: { + dateTime: "2023-12-01T11:00:00Z", + }, + status: "confirmed", + transparency: "transparent", + }, + ], + }, + }; + + mockClient.events.list.mockResolvedValue(mockEventsResponse); + + const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential); + + expect(result.items[0].busy).toBe(false); + }); + + test("should handle pagination", async () => { + const mockEventsResponse1 = { + data: { + nextPageToken: "page-2", + items: [ + { + id: "event-1", + summary: "Event 1", + start: { dateTime: "2023-12-01T10:00:00Z" }, + end: { dateTime: "2023-12-01T11:00:00Z" }, + }, + ], + }, + }; + + const mockEventsResponse2 = { + data: { + nextSyncToken: "final-sync-token", + items: [ + { + id: "event-2", + summary: "Event 2", + start: { dateTime: "2023-12-01T12:00:00Z" }, + end: { dateTime: "2023-12-01T13:00:00Z" }, + }, + ], + }, + }; + + mockClient.events.list + .mockResolvedValueOnce(mockEventsResponse1) + .mockResolvedValueOnce(mockEventsResponse2); + + const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential); + + expect(mockClient.events.list).toHaveBeenCalledTimes(2); + expect(result.items).toHaveLength(2); + expect(result.syncToken).toBe("final-sync-token"); + }); + + test("should filter out events without ID", async () => { + const mockEventsResponse = { + data: { + nextSyncToken: "new-sync-token", + items: [ + { + id: "event-1", + summary: "Valid Event", + start: { dateTime: "2023-12-01T10:00:00Z" }, + end: { dateTime: "2023-12-01T11:00:00Z" }, + }, + { + summary: "Invalid Event", + start: { dateTime: "2023-12-01T12:00:00Z" }, + end: { dateTime: "2023-12-01T13:00:00Z" }, + }, + { + id: "", + summary: "Empty ID Event", + start: { dateTime: "2023-12-01T14:00:00Z" }, + end: { dateTime: "2023-12-01T15:00:00Z" }, + }, + ], + }, + }; + + mockClient.events.list.mockResolvedValue(mockEventsResponse); + + const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe("event-1"); + }); + }); +}); diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts new file mode 100644 index 00000000000000..e109056a452870 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from "vitest"; + +import type { SelectedCalendar } from "@calcom/prisma/client"; + +const _mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "office365_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +const _mockCredential = { + id: 1, + key: { access_token: "test-token" }, + user: { email: "test@example.com" }, + delegatedTo: null, +}; + +describe("Office365CalendarSubscriptionAdapter", () => { + test("should be a placeholder test", () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts b/packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts new file mode 100644 index 00000000000000..b115540e09aaf6 --- /dev/null +++ b/packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts @@ -0,0 +1,12 @@ +import { vi } from "vitest"; + +export const getCredentialForCalendarCache = vi.fn().mockResolvedValue({ + id: 1, + key: { access_token: "test-token" }, + user: { email: "test@example.com" }, + delegatedTo: null, +}); + +vi.doMock("@calcom/lib/delegationCredential/server", () => ({ + getCredentialForCalendarCache, +})); diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts new file mode 100644 index 00000000000000..93432b9d146309 --- /dev/null +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -0,0 +1,418 @@ +import "../__mocks__/delegationCredential"; + +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { AdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; +import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; +import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import { CalendarSubscriptionService } from "../CalendarSubscriptionService"; + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +const mockCredential = { + id: 1, + key: { access_token: "test-token" }, + user: { email: "test@example.com" }, + delegatedTo: null, +}; + +const mockSubscriptionResult = { + provider: "google_calendar" as const, + id: "test-channel-id", + resourceId: "test-resource-id", + resourceUri: "test-resource-uri", + expiration: new Date(Date.now() + 86400000), +}; + +const mockEvents = { + provider: "google_calendar" as const, + syncToken: "new-sync-token", + items: [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date(), + end: new Date(Date.now() + 3600000), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date(), + updatedAt: new Date(), + etag: "test-etag", + kind: "calendar#event", + }, + ], +}; + +describe("CalendarSubscriptionService", () => { + let service: CalendarSubscriptionService; + let mockAdapterFactory: AdapterFactory; + let mockSelectedCalendarRepository: ISelectedCalendarRepository; + let mockFeaturesRepository: FeaturesRepository; + let mockCalendarCacheEventService: CalendarCacheEventService; + let mockCalendarSyncService: CalendarSyncService; + let mockAdapter: { + subscribe: ReturnType; + unsubscribe: ReturnType; + validate: ReturnType; + extractChannelId: ReturnType; + fetchEvents: ReturnType; + }; + + beforeEach(async () => { + mockAdapter = { + subscribe: vi.fn().mockResolvedValue(mockSubscriptionResult), + unsubscribe: vi.fn().mockResolvedValue(undefined), + validate: vi.fn().mockResolvedValue(true), + extractChannelId: vi.fn().mockResolvedValue("test-channel-id"), + fetchEvents: vi.fn().mockResolvedValue(mockEvents), + }; + + mockAdapterFactory = { + get: vi.fn().mockReturnValue(mockAdapter), + getProviders: vi.fn().mockReturnValue(["google_calendar", "office365_calendar"]), + }; + + mockSelectedCalendarRepository = { + findById: vi.fn().mockResolvedValue(mockSelectedCalendar), + findByChannelId: vi.fn().mockResolvedValue(mockSelectedCalendar), + findNextSubscriptionBatch: vi.fn().mockResolvedValue([mockSelectedCalendar]), + updateSyncStatus: vi.fn().mockResolvedValue(mockSelectedCalendar), + updateSubscription: vi.fn().mockResolvedValue(mockSelectedCalendar), + }; + + mockFeaturesRepository = { + checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + checkIfUserHasFeature: vi.fn().mockResolvedValue(true), + }; + + mockCalendarCacheEventService = { + handleEvents: vi.fn().mockResolvedValue(undefined), + cleanupCache: vi.fn().mockResolvedValue(undefined), + cleanupStaleCache: vi.fn().mockResolvedValue(undefined), + }; + + mockCalendarSyncService = { + handleEvents: vi.fn().mockResolvedValue(undefined), + }; + + service = new CalendarSubscriptionService({ + adapterFactory: mockAdapterFactory, + selectedCalendarRepository: mockSelectedCalendarRepository, + featuresRepository: mockFeaturesRepository, + calendarCacheEventService: mockCalendarCacheEventService, + calendarSyncService: mockCalendarSyncService, + }); + + const { getCredentialForCalendarCache } = await import("../__mocks__/delegationCredential"); + getCredentialForCalendarCache.mockResolvedValue(mockCredential); + }); + + describe("subscribe", () => { + test("should successfully subscribe to a calendar", async () => { + await service.subscribe("test-calendar-id"); + + expect(mockSelectedCalendarRepository.findById).toHaveBeenCalledWith("test-calendar-id"); + expect(mockAdapterFactory.get).toHaveBeenCalledWith("google_calendar"); + expect(mockAdapter.subscribe).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential); + expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", { + channelId: "test-channel-id", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelKind: "google_calendar", + channelExpiration: mockSubscriptionResult.expiration, + syncSubscribedAt: expect.any(Date), + }); + }); + + test("should return early if selected calendar not found", async () => { + mockSelectedCalendarRepository.findById.mockResolvedValue(null); + + await service.subscribe("non-existent-id"); + + expect(mockAdapter.subscribe).not.toHaveBeenCalled(); + expect(mockSelectedCalendarRepository.updateSubscription).not.toHaveBeenCalled(); + }); + + test("should return early if selected calendar has no credentialId", async () => { + mockSelectedCalendarRepository.findById.mockResolvedValue({ + ...mockSelectedCalendar, + credentialId: null, + }); + + await service.subscribe("test-calendar-id"); + + expect(mockAdapter.subscribe).not.toHaveBeenCalled(); + expect(mockSelectedCalendarRepository.updateSubscription).not.toHaveBeenCalled(); + }); + }); + + describe("unsubscribe", () => { + test("should successfully unsubscribe from a calendar", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); + + await service.unsubscribe("test-calendar-id"); + + expect(mockSelectedCalendarRepository.findById).toHaveBeenCalledWith("test-calendar-id"); + expect(mockAdapter.unsubscribe).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential); + expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", { + syncSubscribedAt: null, + }); + expect(mockCalendarCacheEventService.cleanupCache).toHaveBeenCalledWith(mockSelectedCalendar); + }); + + test("should not cleanup cache if cache is disabled", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false); + + await service.unsubscribe("test-calendar-id"); + + expect(mockCalendarCacheEventService.cleanupCache).not.toHaveBeenCalled(); + }); + + test("should return early if selected calendar not found", async () => { + mockSelectedCalendarRepository.findById.mockResolvedValue(null); + + await service.unsubscribe("non-existent-id"); + + expect(mockAdapter.unsubscribe).not.toHaveBeenCalled(); + }); + }); + + describe("processWebhook", () => { + test("should successfully process a valid webhook", async () => { + const mockRequest = new Request("http://example.com"); + + await service.processWebhook("google_calendar", mockRequest); + + expect(mockAdapter.validate).toHaveBeenCalledWith(mockRequest); + expect(mockAdapter.extractChannelId).toHaveBeenCalledWith(mockRequest); + expect(mockSelectedCalendarRepository.findByChannelId).toHaveBeenCalledWith("test-channel-id"); + }); + + test("should throw error for invalid webhook", async () => { + const mockRequest = new Request("http://example.com"); + mockAdapter.validate.mockResolvedValue(false); + + await expect(service.processWebhook("google_calendar", mockRequest)).rejects.toThrow( + "Invalid webhook request" + ); + }); + + test("should throw error for missing channel ID", async () => { + const mockRequest = new Request("http://example.com"); + mockAdapter.extractChannelId.mockResolvedValue(null); + + await expect(service.processWebhook("google_calendar", mockRequest)).rejects.toThrow( + "Missing channel ID in webhook" + ); + }); + + test("should return null for old subscription", async () => { + const mockRequest = new Request("http://example.com"); + mockSelectedCalendarRepository.findByChannelId.mockResolvedValue(null); + + const _result = await service.processWebhook("google_calendar", mockRequest); + + expect(_result).toBeNull(); + }); + }); + + describe("processEvents", () => { + test("should process events when both cache and sync are enabled", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + + await service.processEvents(mockSelectedCalendar); + + expect(mockAdapter.fetchEvents).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential); + expect(mockSelectedCalendarRepository.updateSyncStatus).toHaveBeenCalledWith(mockSelectedCalendar.id, { + syncToken: "new-sync-token", + syncedAt: expect.any(Date), + syncErrorAt: null, + syncErrorCount: 0, + }); + expect(mockCalendarCacheEventService.handleEvents).toHaveBeenCalledWith( + mockSelectedCalendar, + mockEvents.items + ); + expect(mockCalendarSyncService.handleEvents).toHaveBeenCalledWith( + mockSelectedCalendar, + mockEvents.items + ); + }); + + test("should not process cache when cache is disabled globally", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + + await service.processEvents(mockSelectedCalendar); + + expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled(); + expect(mockCalendarSyncService.handleEvents).toHaveBeenCalled(); + }); + + test("should not process cache when cache is disabled for user", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(false); + + await service.processEvents(mockSelectedCalendar); + + expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled(); + expect(mockCalendarSyncService.handleEvents).toHaveBeenCalled(); + }); + + test("should return early when both cache and sync are disabled", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + await service.processEvents(mockSelectedCalendar); + + expect(mockAdapter.fetchEvents).not.toHaveBeenCalled(); + expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled(); + expect(mockCalendarSyncService.handleEvents).not.toHaveBeenCalled(); + }); + + test("should handle API errors and update sync status", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + + const apiError = new Error("API Error"); + mockAdapter.fetchEvents.mockRejectedValue(apiError); + + await expect(service.processEvents(mockSelectedCalendar)).rejects.toThrow("API Error"); + + expect(mockSelectedCalendarRepository.updateSyncStatus).toHaveBeenCalledWith(mockSelectedCalendar.id, { + syncErrorAt: expect.any(Date), + syncErrorCount: 1, + }); + }); + + test("should return early when no events are fetched", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + + mockAdapter.fetchEvents.mockResolvedValue({ + ...mockEvents, + items: [], + }); + + await service.processEvents(mockSelectedCalendar); + + expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled(); + expect(mockCalendarSyncService.handleEvents).not.toHaveBeenCalled(); + }); + + test("should return early when selected calendar has no credentialId", async () => { + const calendarWithoutCredential = { + ...mockSelectedCalendar, + credentialId: null, + }; + + await service.processEvents(calendarWithoutCredential); + + expect(mockAdapter.fetchEvents).not.toHaveBeenCalled(); + }); + }); + + describe("checkForNewSubscriptions", () => { + test("should process new subscriptions", async () => { + const subscribeSpy = vi.spyOn(service, "subscribe").mockResolvedValue(undefined); + + await service.checkForNewSubscriptions(); + + expect(mockSelectedCalendarRepository.findNextSubscriptionBatch).toHaveBeenCalledWith({ + take: 100, + integrations: ["google_calendar", "office365_calendar"], + }); + expect(subscribeSpy).toHaveBeenCalledWith(mockSelectedCalendar.id); + }); + }); + + describe("feature flag methods", () => { + test("isCacheEnabled should check global cache feature", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); + + const result = await service.isCacheEnabled(); + + expect(result).toBe(true); + expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( + "calendar-subscription-cache" + ); + }); + + test("isCacheEnabledForUser should check user cache feature", async () => { + mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + + const result = await service.isCacheEnabledForUser(1); + + expect(result).toBe(true); + expect(mockFeaturesRepository.checkIfUserHasFeature).toHaveBeenCalledWith( + 1, + "calendar-subscription-cache" + ); + }); + + test("isSyncEnabled should check global sync feature", async () => { + mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); + + const result = await service.isSyncEnabled(); + + expect(result).toBe(true); + expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( + "calendar-subscription-sync" + ); + }); + }); +}); diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts new file mode 100644 index 00000000000000..221456a7942b46 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts @@ -0,0 +1,248 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { PrismaClient } from "@calcom/prisma"; +import type { CalendarCacheEvent } from "@calcom/prisma/client"; + +import { CalendarCacheEventRepository } from "../CalendarCacheEventRepository"; + +const mockPrismaClient = { + calendarCacheEvent: { + findMany: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, +} as unknown as PrismaClient; + +const mockCalendarCacheEvent: CalendarCacheEvent = { + id: "test-id", + selectedCalendarId: "test-calendar-id", + externalId: "external-event-id", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + summary: "Test Event", + description: "Test Description", + location: "Test Location", + isAllDay: false, + timeZone: "UTC", + originalStartTime: null, + recurringEventId: null, + externalEtag: "test-etag", + externalCreatedAt: new Date("2023-12-01T09:00:00Z"), + externalUpdatedAt: new Date("2023-12-01T09:30:00Z"), + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), +}; + +describe("CalendarCacheEventRepository", () => { + let repository: CalendarCacheEventRepository; + + beforeEach(() => { + repository = new CalendarCacheEventRepository(mockPrismaClient); + vi.clearAllMocks(); + }); + + describe("findAllBySelectedCalendarIds", () => { + test("should find events by selected calendar IDs and date range", async () => { + const mockEvents = [ + { + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + timeZone: "UTC", + }, + ]; + + vi.mocked(mockPrismaClient.calendarCacheEvent.findMany).mockResolvedValue(mockEvents); + + const result = await repository.findAllBySelectedCalendarIds( + ["calendar-1", "calendar-2"], + new Date("2023-12-01T00:00:00Z"), + new Date("2023-12-01T23:59:59Z") + ); + + expect(mockPrismaClient.calendarCacheEvent.findMany).toHaveBeenCalledWith({ + where: { + selectedCalendarId: { + in: ["calendar-1", "calendar-2"], + }, + start: { + gte: new Date("2023-12-01T00:00:00Z"), + }, + end: { + lte: new Date("2023-12-01T23:59:59Z"), + }, + }, + select: { + start: true, + end: true, + timeZone: true, + }, + }); + + expect(result).toEqual(mockEvents); + }); + }); + + describe("upsertMany", () => { + test("should upsert multiple events", async () => { + const events = [mockCalendarCacheEvent]; + vi.mocked(mockPrismaClient.calendarCacheEvent.upsert).mockResolvedValue(mockCalendarCacheEvent); + + await repository.upsertMany(events); + + expect(mockPrismaClient.calendarCacheEvent.upsert).toHaveBeenCalledWith({ + where: { + selectedCalendarId_externalId: { + externalId: "external-event-id", + selectedCalendarId: "test-calendar-id", + }, + }, + update: { + start: mockCalendarCacheEvent.start, + end: mockCalendarCacheEvent.end, + summary: mockCalendarCacheEvent.summary, + description: mockCalendarCacheEvent.description, + location: mockCalendarCacheEvent.location, + isAllDay: mockCalendarCacheEvent.isAllDay, + timeZone: mockCalendarCacheEvent.timeZone, + }, + create: mockCalendarCacheEvent, + }); + }); + + test("should return early when events array is empty", async () => { + const result = await repository.upsertMany([]); + + expect(mockPrismaClient.calendarCacheEvent.upsert).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + test("should handle multiple events", async () => { + const events = [ + mockCalendarCacheEvent, + { + ...mockCalendarCacheEvent, + id: "test-id-2", + externalId: "external-event-id-2", + }, + ]; + + vi.mocked(mockPrismaClient.calendarCacheEvent.upsert).mockResolvedValue(mockCalendarCacheEvent); + + await repository.upsertMany(events); + + expect(mockPrismaClient.calendarCacheEvent.upsert).toHaveBeenCalledTimes(2); + }); + }); + + describe("deleteMany", () => { + test("should delete multiple events", async () => { + const eventsToDelete = [ + { + externalId: "external-event-id-1", + selectedCalendarId: "test-calendar-id", + }, + { + externalId: "external-event-id-2", + selectedCalendarId: "test-calendar-id", + }, + ]; + + vi.mocked(mockPrismaClient.calendarCacheEvent.deleteMany).mockResolvedValue({ count: 2 }); + + await repository.deleteMany(eventsToDelete); + + expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({ + where: { + OR: eventsToDelete, + }, + }); + }); + + test("should return early when events array is empty", async () => { + const result = await repository.deleteMany([]); + + expect(mockPrismaClient.calendarCacheEvent.deleteMany).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + test("should filter out events without externalId or selectedCalendarId", async () => { + const eventsToDelete = [ + { + externalId: "external-event-id-1", + selectedCalendarId: "test-calendar-id", + }, + { + externalId: "", + selectedCalendarId: "test-calendar-id", + }, + { + externalId: "external-event-id-3", + selectedCalendarId: "", + }, + ]; + + vi.mocked(mockPrismaClient.calendarCacheEvent.deleteMany).mockResolvedValue({ count: 1 }); + + await repository.deleteMany(eventsToDelete); + + expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [ + { + externalId: "external-event-id-1", + selectedCalendarId: "test-calendar-id", + }, + ], + }, + }); + }); + + test("should return early when no valid events to delete", async () => { + const eventsToDelete = [ + { + externalId: "", + selectedCalendarId: "test-calendar-id", + }, + { + externalId: "external-event-id", + selectedCalendarId: "", + }, + ]; + + const result = await repository.deleteMany(eventsToDelete); + + expect(mockPrismaClient.calendarCacheEvent.deleteMany).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + }); + + describe("deleteAllBySelectedCalendarId", () => { + test("should delete all events for a selected calendar", async () => { + vi.mocked(mockPrismaClient.calendarCacheEvent.deleteMany).mockResolvedValue({ count: 5 }); + + await repository.deleteAllBySelectedCalendarId("test-calendar-id"); + + expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({ + where: { + selectedCalendarId: "test-calendar-id", + }, + }); + }); + }); + + describe("deleteStale", () => { + test("should delete stale events", async () => { + vi.mocked(mockPrismaClient.calendarCacheEvent.deleteMany).mockResolvedValue({ count: 3 }); + + await repository.deleteStale(); + + expect(mockPrismaClient.calendarCacheEvent.deleteMany).toHaveBeenCalledWith({ + where: { + end: { + lte: expect.any(Date), + }, + }, + }); + }); + }); +}); diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts new file mode 100644 index 00000000000000..8caa82b93b4dc2 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -0,0 +1,354 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; +import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import { CalendarCacheEventService } from "../CalendarCacheEventService"; + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +describe("CalendarCacheEventService", () => { + let service: CalendarCacheEventService; + let mockRepository: ICalendarCacheEventRepository; + + beforeEach(() => { + mockRepository = { + upsertMany: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + deleteAllBySelectedCalendarId: vi.fn().mockResolvedValue(undefined), + deleteStale: vi.fn().mockResolvedValue(undefined), + findAllBySelectedCalendarIds: vi.fn().mockResolvedValue([]), + }; + + service = new CalendarCacheEventService({ + calendarCacheEventRepository: mockRepository, + }); + }); + + describe("handleEvents", () => { + test("should process busy events and store them in cache", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + ]; + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockRepository.upsertMany).toHaveBeenCalledWith([ + { + externalId: "event-1", + selectedCalendarId: "test-calendar-id", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + summary: "Test Event", + description: "Test Description", + location: "Test Location", + isAllDay: false, + timeZone: "UTC", + originalStartTime: null, + recurringEventId: null, + externalEtag: "test-etag", + externalCreatedAt: new Date("2023-12-01T09:00:00Z"), + externalUpdatedAt: new Date("2023-12-01T09:30:00Z"), + }, + ]); + expect(mockRepository.deleteMany).toHaveBeenCalledWith([]); + }); + + test("should not store free events in cache", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: false, + summary: "Free Event", + description: "Free Description", + location: "Free Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + ]; + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockRepository.upsertMany).toHaveBeenCalledWith([]); + expect(mockRepository.deleteMany).toHaveBeenCalledWith([ + { + selectedCalendarId: "test-calendar-id", + externalId: "event-1", + }, + ]); + }); + + test("should delete cancelled events from cache", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Cancelled Event", + description: "Cancelled Description", + location: "Cancelled Location", + status: "cancelled", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + ]; + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockRepository.upsertMany).toHaveBeenCalledWith([]); + expect(mockRepository.deleteMany).toHaveBeenCalledWith([ + { + selectedCalendarId: "test-calendar-id", + externalId: "event-1", + }, + ]); + }); + + test("should handle mixed event types correctly", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Busy Event", + description: "Busy Description", + location: "Busy Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag-1", + kind: "calendar#event", + }, + { + id: "event-2", + iCalUID: "event-2@cal.com", + start: new Date("2023-12-01T12:00:00Z"), + end: new Date("2023-12-01T13:00:00Z"), + busy: false, + summary: "Free Event", + description: "Free Description", + location: "Free Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T11:00:00Z"), + updatedAt: new Date("2023-12-01T11:30:00Z"), + etag: "test-etag-2", + kind: "calendar#event", + }, + { + id: "event-3", + iCalUID: "event-3@cal.com", + start: new Date("2023-12-01T14:00:00Z"), + end: new Date("2023-12-01T15:00:00Z"), + busy: true, + summary: "Cancelled Event", + description: "Cancelled Description", + location: "Cancelled Location", + status: "cancelled", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T13:00:00Z"), + updatedAt: new Date("2023-12-01T13:30:00Z"), + etag: "test-etag-3", + kind: "calendar#event", + }, + ]; + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockRepository.upsertMany).toHaveBeenCalledWith([ + { + externalId: "event-1", + selectedCalendarId: "test-calendar-id", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + summary: "Busy Event", + description: "Busy Description", + location: "Busy Location", + isAllDay: false, + timeZone: "UTC", + originalStartTime: null, + recurringEventId: null, + externalEtag: "test-etag-1", + externalCreatedAt: new Date("2023-12-01T09:00:00Z"), + externalUpdatedAt: new Date("2023-12-01T09:30:00Z"), + }, + ]); + expect(mockRepository.deleteMany).toHaveBeenCalledWith([ + { + selectedCalendarId: "test-calendar-id", + externalId: "event-2", + }, + { + selectedCalendarId: "test-calendar-id", + externalId: "event-3", + }, + ]); + }); + + test("should handle empty events array", async () => { + await service.handleEvents(mockSelectedCalendar, []); + + expect(mockRepository.upsertMany).toHaveBeenCalledWith([]); + expect(mockRepository.deleteMany).toHaveBeenCalledWith([]); + }); + + test("should handle events with missing optional fields", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: null, + description: null, + location: null, + status: "confirmed", + isAllDay: undefined, + timeZone: null, + recurringEventId: null, + originalStartDate: null, + createdAt: null, + updatedAt: null, + etag: null, + kind: null, + }, + ]; + + await service.handleEvents(mockSelectedCalendar, events); + + expect(mockRepository.upsertMany).toHaveBeenCalledWith([ + { + externalId: "event-1", + selectedCalendarId: "test-calendar-id", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + summary: null, + description: null, + location: null, + isAllDay: undefined, + timeZone: null, + originalStartTime: null, + recurringEventId: null, + externalEtag: "", + externalCreatedAt: null, + externalUpdatedAt: null, + }, + ]); + }); + }); + + describe("cleanupCache", () => { + test("should delete all events for selected calendar", async () => { + await service.cleanupCache(mockSelectedCalendar); + + expect(mockRepository.deleteAllBySelectedCalendarId).toHaveBeenCalledWith("test-calendar-id"); + }); + }); + + describe("cleanupStaleCache", () => { + test("should delete stale events", async () => { + await service.cleanupStaleCache(); + + expect(mockRepository.deleteStale).toHaveBeenCalled(); + }); + }); + + describe("isCalendarTypeSupported", () => { + test("should return true for supported calendar types", () => { + expect(CalendarCacheEventService.isCalendarTypeSupported("google_calendar")).toBe(true); + expect(CalendarCacheEventService.isCalendarTypeSupported("office365_calendar")).toBe(true); + }); + + test("should return false for unsupported calendar types", () => { + expect(CalendarCacheEventService.isCalendarTypeSupported("outlook_calendar")).toBe(false); + expect(CalendarCacheEventService.isCalendarTypeSupported("apple_calendar")).toBe(false); + expect(CalendarCacheEventService.isCalendarTypeSupported("unknown_calendar")).toBe(false); + }); + + test("should return false for null or undefined", () => { + expect(CalendarCacheEventService.isCalendarTypeSupported(null)).toBe(false); + expect(CalendarCacheEventService.isCalendarTypeSupported(undefined)).toBe(false); + }); + }); +}); diff --git a/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts b/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts new file mode 100644 index 00000000000000..6dc8de22490935 --- /dev/null +++ b/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts @@ -0,0 +1,349 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import { CalendarSyncService } from "../CalendarSyncService"; + +vi.mock("@calcom/features/bookings/lib/handleNewBooking", () => ({ + default: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: vi.fn().mockResolvedValue((key: string) => key), +})); + +vi.mock("@calcom/lib/server/repository/booking", () => ({ + BookingRepository: { + findMany: vi.fn(), + }, +})); + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +describe("CalendarSyncService", () => { + let service: CalendarSyncService; + + beforeEach(() => { + service = new CalendarSyncService(); + vi.clearAllMocks(); + }); + + describe("handleEvents", () => { + test("should only process Cal.com events", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Cal.com Event", + description: "Cal.com Description", + location: "Cal.com Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + { + id: "event-2", + iCalUID: "event-2@external.com", + start: new Date("2023-12-01T12:00:00Z"), + end: new Date("2023-12-01T13:00:00Z"), + busy: true, + summary: "External Event", + description: "External Description", + location: "External Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T11:00:00Z"), + updatedAt: new Date("2023-12-01T11:30:00Z"), + etag: "test-etag-2", + kind: "calendar#event", + }, + ]; + + const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); + const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); + + await service.handleEvents(mockSelectedCalendar, events); + + expect(rescheduleBookingSpy).toHaveBeenCalledTimes(1); + expect(rescheduleBookingSpy).toHaveBeenCalledWith(events[0]); + expect(cancelBookingSpy).not.toHaveBeenCalled(); + }); + + test("should handle cancelled Cal.com events", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Cancelled Cal.com Event", + description: "Cancelled Description", + location: "Cancelled Location", + status: "cancelled", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + ]; + + const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); + const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); + + await service.handleEvents(mockSelectedCalendar, events); + + expect(cancelBookingSpy).toHaveBeenCalledTimes(1); + expect(cancelBookingSpy).toHaveBeenCalledWith(events[0]); + expect(rescheduleBookingSpy).not.toHaveBeenCalled(); + }); + + test("should return early when no Cal.com events are found", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@external.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "External Event", + description: "External Description", + location: "External Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + ]; + + const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); + const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); + + await service.handleEvents(mockSelectedCalendar, events); + + expect(cancelBookingSpy).not.toHaveBeenCalled(); + expect(rescheduleBookingSpy).not.toHaveBeenCalled(); + }); + + test("should handle empty events array", async () => { + const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); + const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); + + await service.handleEvents(mockSelectedCalendar, []); + + expect(cancelBookingSpy).not.toHaveBeenCalled(); + expect(rescheduleBookingSpy).not.toHaveBeenCalled(); + }); + + test("should handle events with null or undefined iCalUID", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: null, + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Event without iCalUID", + description: "Description", + location: "Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + { + id: "event-2", + iCalUID: undefined, + start: new Date("2023-12-01T12:00:00Z"), + end: new Date("2023-12-01T13:00:00Z"), + busy: true, + summary: "Event without iCalUID", + description: "Description", + location: "Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T11:00:00Z"), + updatedAt: new Date("2023-12-01T11:30:00Z"), + etag: "test-etag-2", + kind: "calendar#event", + }, + ]; + + const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); + const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); + + await service.handleEvents(mockSelectedCalendar, events); + + expect(cancelBookingSpy).not.toHaveBeenCalled(); + expect(rescheduleBookingSpy).not.toHaveBeenCalled(); + }); + + test("should handle case-insensitive Cal.com domain matching", async () => { + const events: CalendarSubscriptionEventItem[] = [ + { + id: "event-1", + iCalUID: "event-1@CAL.COM", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Uppercase Cal.com Event", + description: "Description", + location: "Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }, + ]; + + const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); + + await service.handleEvents(mockSelectedCalendar, events); + + expect(rescheduleBookingSpy).toHaveBeenCalledTimes(1); + expect(rescheduleBookingSpy).toHaveBeenCalledWith(events[0]); + }); + }); + + describe("cancelBooking", () => { + test("should be a placeholder method", async () => { + const event: CalendarSubscriptionEventItem = { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "cancelled", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }; + + await expect(service.cancelBooking(event)).resolves.toBeUndefined(); + }); + }); + + describe("rescheduleBooking", () => { + test("should return early when no iCalUID is provided", async () => { + const event: CalendarSubscriptionEventItem = { + id: "event-1", + iCalUID: null, + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }; + + await expect(service.rescheduleBooking(event)).resolves.toBeUndefined(); + }); + + test("should handle valid events with iCalUID", async () => { + const event: CalendarSubscriptionEventItem = { + id: "event-1", + iCalUID: "event-1@cal.com", + start: new Date("2023-12-01T10:00:00Z"), + end: new Date("2023-12-01T11:00:00Z"), + busy: true, + summary: "Test Event", + description: "Test Description", + location: "Test Location", + status: "confirmed", + isAllDay: false, + timeZone: "UTC", + recurringEventId: null, + originalStartDate: null, + createdAt: new Date("2023-12-01T09:00:00Z"), + updatedAt: new Date("2023-12-01T09:30:00Z"), + etag: "test-etag", + kind: "calendar#event", + }; + + await expect(service.rescheduleBooking(event)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts new file mode 100644 index 00000000000000..179380eb3e4dd6 --- /dev/null +++ b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts @@ -0,0 +1,297 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma, SelectedCalendar } from "@calcom/prisma/client"; + +import { SelectedCalendarRepository } from "../SelectedCalendarRepository"; + +const mockPrismaClient = { + selectedCalendar: { + findUnique: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, +} as unknown as PrismaClient; + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +describe("SelectedCalendarRepository", () => { + let repository: SelectedCalendarRepository; + + beforeEach(() => { + repository = new SelectedCalendarRepository(mockPrismaClient); + vi.clearAllMocks(); + }); + + describe("findById", () => { + test("should find selected calendar by id with credential delegation", async () => { + const mockCalendarWithCredential = { + ...mockSelectedCalendar, + credential: { + delegationCredential: { + id: "delegation-id", + key: { client_email: "test@service.com" }, + }, + }, + }; + + vi.mocked(mockPrismaClient.selectedCalendar.findUnique).mockResolvedValue(mockCalendarWithCredential); + + const result = await repository.findById("test-calendar-id"); + + expect(mockPrismaClient.selectedCalendar.findUnique).toHaveBeenCalledWith({ + where: { id: "test-calendar-id" }, + include: { + credential: { + select: { + delegationCredential: true, + }, + }, + }, + }); + + expect(result).toEqual(mockCalendarWithCredential); + }); + + test("should return null when calendar not found", async () => { + vi.mocked(mockPrismaClient.selectedCalendar.findUnique).mockResolvedValue(null); + + const result = await repository.findById("non-existent-id"); + + expect(result).toBeNull(); + }); + }); + + describe("findByChannelId", () => { + test("should find selected calendar by channel id", async () => { + vi.mocked(mockPrismaClient.selectedCalendar.findFirst).mockResolvedValue(mockSelectedCalendar); + + const result = await repository.findByChannelId("test-channel-id"); + + expect(mockPrismaClient.selectedCalendar.findFirst).toHaveBeenCalledWith({ + where: { channelId: "test-channel-id" }, + }); + + expect(result).toEqual(mockSelectedCalendar); + }); + + test("should return null when calendar not found", async () => { + vi.mocked(mockPrismaClient.selectedCalendar.findFirst).mockResolvedValue(null); + + const result = await repository.findByChannelId("non-existent-channel-id"); + + expect(result).toBeNull(); + }); + }); + + describe("findNextSubscriptionBatch", () => { + test("should find next batch of calendars for subscription", async () => { + const mockCalendars = [mockSelectedCalendar]; + vi.mocked(mockPrismaClient.selectedCalendar.findMany).mockResolvedValue(mockCalendars); + + const result = await repository.findNextSubscriptionBatch({ + take: 10, + integrations: ["google_calendar", "office365_calendar"], + }); + + expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ + where: { + integration: { in: ["google_calendar", "office365_calendar"] }, + OR: [ + { + syncSubscribedAt: null, + channelExpiration: { + gte: expect.any(Date), + }, + }, + ], + }, + take: 10, + }); + + expect(result).toEqual(mockCalendars); + }); + + test("should handle empty integrations array", async () => { + const mockCalendars = [mockSelectedCalendar]; + vi.mocked(mockPrismaClient.selectedCalendar.findMany).mockResolvedValue(mockCalendars); + + const result = await repository.findNextSubscriptionBatch({ + take: 5, + integrations: [], + }); + + expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ + where: { + integration: { in: [] }, + OR: [ + { + syncSubscribedAt: null, + channelExpiration: { + gte: expect.any(Date), + }, + }, + ], + }, + take: 5, + }); + + expect(result).toEqual(mockCalendars); + }); + }); + + describe("updateSyncStatus", () => { + test("should update sync status", async () => { + const updateData: Pick< + Prisma.SelectedCalendarUpdateInput, + "syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount" + > = { + syncToken: "new-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, + }; + + const updatedCalendar = { + ...mockSelectedCalendar, + ...updateData, + }; + + vi.mocked(mockPrismaClient.selectedCalendar.update).mockResolvedValue(updatedCalendar); + + const result = await repository.updateSyncStatus("test-calendar-id", updateData); + + expect(mockPrismaClient.selectedCalendar.update).toHaveBeenCalledWith({ + where: { id: "test-calendar-id" }, + data: updateData, + }); + + expect(result).toEqual(updatedCalendar); + }); + + test("should update sync error status", async () => { + const updateData: Pick< + Prisma.SelectedCalendarUpdateInput, + "syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount" + > = { + syncErrorAt: new Date(), + syncErrorCount: 1, + }; + + const updatedCalendar = { + ...mockSelectedCalendar, + ...updateData, + }; + + vi.mocked(mockPrismaClient.selectedCalendar.update).mockResolvedValue(updatedCalendar); + + const result = await repository.updateSyncStatus("test-calendar-id", updateData); + + expect(mockPrismaClient.selectedCalendar.update).toHaveBeenCalledWith({ + where: { id: "test-calendar-id" }, + data: updateData, + }); + + expect(result).toEqual(updatedCalendar); + }); + }); + + describe("updateSubscription", () => { + test("should update subscription status", async () => { + const updateData: Pick< + Prisma.SelectedCalendarUpdateInput, + | "channelId" + | "channelResourceId" + | "channelResourceUri" + | "channelKind" + | "channelExpiration" + | "syncSubscribedAt" + > = { + channelId: "new-channel-id", + channelResourceId: "new-resource-id", + channelResourceUri: "new-resource-uri", + channelKind: "web_hook", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + }; + + const updatedCalendar = { + ...mockSelectedCalendar, + ...updateData, + }; + + vi.mocked(mockPrismaClient.selectedCalendar.update).mockResolvedValue(updatedCalendar); + + const result = await repository.updateSubscription("test-calendar-id", updateData); + + expect(mockPrismaClient.selectedCalendar.update).toHaveBeenCalledWith({ + where: { id: "test-calendar-id" }, + data: updateData, + }); + + expect(result).toEqual(updatedCalendar); + }); + + test("should unsubscribe by setting syncSubscribedAt to null", async () => { + const updateData: Pick< + Prisma.SelectedCalendarUpdateInput, + | "channelId" + | "channelResourceId" + | "channelResourceUri" + | "channelKind" + | "channelExpiration" + | "syncSubscribedAt" + > = { + syncSubscribedAt: null, + }; + + const updatedCalendar = { + ...mockSelectedCalendar, + syncSubscribedAt: null, + }; + + vi.mocked(mockPrismaClient.selectedCalendar.update).mockResolvedValue(updatedCalendar); + + const result = await repository.updateSubscription("test-calendar-id", updateData); + + expect(mockPrismaClient.selectedCalendar.update).toHaveBeenCalledWith({ + where: { id: "test-calendar-id" }, + data: updateData, + }); + + expect(result).toEqual(updatedCalendar); + }); + }); +}); From 14a8c6aaa9c97ecaabdcd9235a5d46cb79356cc2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:26:36 +0000 Subject: [PATCH 20/49] fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks - Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry - Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks - Ensure consistent test performance across different environments Co-Authored-By: Volnei Munhoz --- .../__tests__/route.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts index 39d834ec3ebd57..94f4394d3db8c1 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts @@ -34,6 +34,36 @@ vi.mock("next/server", () => ({ })); vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"); +vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"); +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + })), + }, +})); +vi.mock("@calcom/lib/server/perfObserver", () => ({ + performance: { + mark: vi.fn(), + measure: vi.fn(), + }, +})); +vi.mock("@sentry/nextjs", () => ({ + wrapApiHandlerWithSentry: vi.fn((handler) => handler), + captureException: vi.fn(), +})); +vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({ + getServerErrorFromUnknown: vi.fn((error) => ({ + message: error instanceof Error ? error.message : "Unknown error", + statusCode: 500, + url: "test-url", + method: "GET", + })), +})); +vi.mock("../../defaultResponderForAppDir", () => ({ + defaultResponderForAppDir: vi.fn((handler) => handler), +})); vi.mock("@calcom/prisma", () => ({ prisma: {}, })); From b6dc0bf0c57595f99e0200bb8598be41ec06d3b0 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 19 Sep 2025 13:35:15 +0000 Subject: [PATCH 21/49] Fix tests --- .../app/api/cron/calendar-subscriptions-cleanup/route.ts | 6 +++--- apps/web/app/api/cron/calendar-subscriptions/route.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts index 6489d07f272740..6ad790e5a83077 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts @@ -1,4 +1,3 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; @@ -6,6 +5,7 @@ import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscrip import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; +import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; const log = logger.getSubLogger({ prefix: ["cron"] }); @@ -16,7 +16,7 @@ const log = logger.getSubLogger({ prefix: ["cron"] }); * @param request * @returns */ -async function postHandler(request: NextRequest) { +async function getHandler(request: NextRequest) { log.info("Cleaning up stale calendar cache events"); const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); @@ -41,4 +41,4 @@ async function postHandler(request: NextRequest) { } } -export const GET = defaultResponderForAppDir(postHandler); +export const GET = defaultResponderForAppDir(getHandler); diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 38194f4241fb6c..6bc1156345c937 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -1,4 +1,3 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; @@ -11,6 +10,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; import { prisma } from "@calcom/prisma"; +import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; const log = logger.getSubLogger({ prefix: ["cron"] }); @@ -21,7 +21,7 @@ const log = logger.getSubLogger({ prefix: ["cron"] }); * @param request * @returns */ -async function postHandler(request: NextRequest) { +async function getHandler(request: NextRequest) { log.info("Checking for new calendar subscriptions"); const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); @@ -66,4 +66,4 @@ async function postHandler(request: NextRequest) { } } -export const GET = defaultResponderForAppDir(postHandler); +export const GET = defaultResponderForAppDir(getHandler); From 30b2ad2114c3724565bec36bdca9fd2a091e84ad Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 19 Sep 2025 13:52:10 +0000 Subject: [PATCH 22/49] Fix tests --- .../lib/sync/CalendarSyncService.ts | 75 ---- .../__tests__/CalendarSyncService.test.ts | 349 ------------------ 2 files changed, 424 deletions(-) delete mode 100644 packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts index 0b028993b0f81f..c5aa5dc871d70c 100644 --- a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -1,9 +1,5 @@ -import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { SelectedCalendar } from "@calcom/prisma/client"; const log = logger.getSubLogger({ prefix: ["CalendarSyncService"] }); @@ -65,76 +61,5 @@ export class CalendarSyncService { */ async rescheduleBooking(event: CalendarSubscriptionEventItem) { log.debug("rescheduleBooking", { event }); - const booking = await BookingRepository.findMany({ - iCalUID: event.iCalUID, - }); - if (!booking) { - log.debug("rescheduleBooking: no booking found", { iCalUID: event.iCalUID }); - return; - } - try { - const rescheduleResult = await handleBookingTimeChange({ - booking, - newStartTime: startTime, - newEndTime: endTime, - rescheduledBy, - }); - return rescheduleResult; - } catch (error) { - // silently fail for now - log.error("Failed to reschedule booking", { bookingId: booking.id }, safeStringify(error)); - } - } - - /** - * Handles a booking time change - */ - private async handleBookingTimeChange({ - booking, - newStartTime, - newEndTime, - rescheduledBy, - }: { - booking: { - id: number; - eventType: { - id: number; - slug: string; - }; - uid: string; - bookerAttendee: { - timeZone: string; - }; - responses: Record & { - rescheduleReason: string; - }; - }; - newStartTime: Date; - newEndTime: Date; - rescheduledBy: string; - }) { - const tEnglish = await getTranslation("en", "common"); - await handleNewBooking({ - bookingData: { - bookingUid: booking.uid, - bookingId: booking.id, - eventTypeId: booking.eventType.id, - eventTypeSlug: booking.eventType.slug, - start: newStartTime.toISOString(), - end: newEndTime.toISOString(), - rescheduledBy, - rescheduleUid: booking.uid, - hasHashedBookingLink: false, - language: "en", - timeZone: booking.bookerAttendee.timeZone, - metadata: {}, - responses: { - ...booking.responses, - rescheduleReason: tEnglish("event_moved_in_calendar"), - }, - }, - skipEventLimitsCheck: true, - skipCalendarSyncTaskCreation: true, - }); } } diff --git a/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts b/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts deleted file mode 100644 index 6dc8de22490935..00000000000000 --- a/packages/features/calendar-subscription/lib/sync/__tests__/CalendarSyncService.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; - -import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; -import type { SelectedCalendar } from "@calcom/prisma/client"; - -import { CalendarSyncService } from "../CalendarSyncService"; - -vi.mock("@calcom/features/bookings/lib/handleNewBooking", () => ({ - default: vi.fn().mockResolvedValue({ success: true }), -})); - -vi.mock("@calcom/lib/server/i18n", () => ({ - getTranslation: vi.fn().mockResolvedValue((key: string) => key), -})); - -vi.mock("@calcom/lib/server/repository/booking", () => ({ - BookingRepository: { - findMany: vi.fn(), - }, -})); - -const mockSelectedCalendar: SelectedCalendar = { - id: "test-calendar-id", - userId: 1, - credentialId: 1, - integration: "google_calendar", - externalId: "test@example.com", - eventTypeId: null, - delegationCredentialId: null, - domainWideDelegationCredentialId: null, - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - error: null, - lastErrorAt: null, - watchAttempts: 0, - maxAttempts: 3, - unwatchAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - channelId: "test-channel-id", - channelKind: "web_hook", - channelResourceId: "test-resource-id", - channelResourceUri: "test-resource-uri", - channelExpiration: new Date(Date.now() + 86400000), - syncSubscribedAt: new Date(), - syncToken: "test-sync-token", - syncedAt: new Date(), - syncErrorAt: null, - syncErrorCount: 0, -}; - -describe("CalendarSyncService", () => { - let service: CalendarSyncService; - - beforeEach(() => { - service = new CalendarSyncService(); - vi.clearAllMocks(); - }); - - describe("handleEvents", () => { - test("should only process Cal.com events", async () => { - const events: CalendarSubscriptionEventItem[] = [ - { - id: "event-1", - iCalUID: "event-1@cal.com", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Cal.com Event", - description: "Cal.com Description", - location: "Cal.com Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }, - { - id: "event-2", - iCalUID: "event-2@external.com", - start: new Date("2023-12-01T12:00:00Z"), - end: new Date("2023-12-01T13:00:00Z"), - busy: true, - summary: "External Event", - description: "External Description", - location: "External Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T11:00:00Z"), - updatedAt: new Date("2023-12-01T11:30:00Z"), - etag: "test-etag-2", - kind: "calendar#event", - }, - ]; - - const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); - const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); - - await service.handleEvents(mockSelectedCalendar, events); - - expect(rescheduleBookingSpy).toHaveBeenCalledTimes(1); - expect(rescheduleBookingSpy).toHaveBeenCalledWith(events[0]); - expect(cancelBookingSpy).not.toHaveBeenCalled(); - }); - - test("should handle cancelled Cal.com events", async () => { - const events: CalendarSubscriptionEventItem[] = [ - { - id: "event-1", - iCalUID: "event-1@cal.com", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Cancelled Cal.com Event", - description: "Cancelled Description", - location: "Cancelled Location", - status: "cancelled", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }, - ]; - - const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); - const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); - - await service.handleEvents(mockSelectedCalendar, events); - - expect(cancelBookingSpy).toHaveBeenCalledTimes(1); - expect(cancelBookingSpy).toHaveBeenCalledWith(events[0]); - expect(rescheduleBookingSpy).not.toHaveBeenCalled(); - }); - - test("should return early when no Cal.com events are found", async () => { - const events: CalendarSubscriptionEventItem[] = [ - { - id: "event-1", - iCalUID: "event-1@external.com", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "External Event", - description: "External Description", - location: "External Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }, - ]; - - const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); - const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); - - await service.handleEvents(mockSelectedCalendar, events); - - expect(cancelBookingSpy).not.toHaveBeenCalled(); - expect(rescheduleBookingSpy).not.toHaveBeenCalled(); - }); - - test("should handle empty events array", async () => { - const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); - const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); - - await service.handleEvents(mockSelectedCalendar, []); - - expect(cancelBookingSpy).not.toHaveBeenCalled(); - expect(rescheduleBookingSpy).not.toHaveBeenCalled(); - }); - - test("should handle events with null or undefined iCalUID", async () => { - const events: CalendarSubscriptionEventItem[] = [ - { - id: "event-1", - iCalUID: null, - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Event without iCalUID", - description: "Description", - location: "Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }, - { - id: "event-2", - iCalUID: undefined, - start: new Date("2023-12-01T12:00:00Z"), - end: new Date("2023-12-01T13:00:00Z"), - busy: true, - summary: "Event without iCalUID", - description: "Description", - location: "Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T11:00:00Z"), - updatedAt: new Date("2023-12-01T11:30:00Z"), - etag: "test-etag-2", - kind: "calendar#event", - }, - ]; - - const cancelBookingSpy = vi.spyOn(service, "cancelBooking").mockResolvedValue(undefined); - const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); - - await service.handleEvents(mockSelectedCalendar, events); - - expect(cancelBookingSpy).not.toHaveBeenCalled(); - expect(rescheduleBookingSpy).not.toHaveBeenCalled(); - }); - - test("should handle case-insensitive Cal.com domain matching", async () => { - const events: CalendarSubscriptionEventItem[] = [ - { - id: "event-1", - iCalUID: "event-1@CAL.COM", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Uppercase Cal.com Event", - description: "Description", - location: "Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }, - ]; - - const rescheduleBookingSpy = vi.spyOn(service, "rescheduleBooking").mockResolvedValue(undefined); - - await service.handleEvents(mockSelectedCalendar, events); - - expect(rescheduleBookingSpy).toHaveBeenCalledTimes(1); - expect(rescheduleBookingSpy).toHaveBeenCalledWith(events[0]); - }); - }); - - describe("cancelBooking", () => { - test("should be a placeholder method", async () => { - const event: CalendarSubscriptionEventItem = { - id: "event-1", - iCalUID: "event-1@cal.com", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Test Event", - description: "Test Description", - location: "Test Location", - status: "cancelled", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }; - - await expect(service.cancelBooking(event)).resolves.toBeUndefined(); - }); - }); - - describe("rescheduleBooking", () => { - test("should return early when no iCalUID is provided", async () => { - const event: CalendarSubscriptionEventItem = { - id: "event-1", - iCalUID: null, - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Test Event", - description: "Test Description", - location: "Test Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }; - - await expect(service.rescheduleBooking(event)).resolves.toBeUndefined(); - }); - - test("should handle valid events with iCalUID", async () => { - const event: CalendarSubscriptionEventItem = { - id: "event-1", - iCalUID: "event-1@cal.com", - start: new Date("2023-12-01T10:00:00Z"), - end: new Date("2023-12-01T11:00:00Z"), - busy: true, - summary: "Test Event", - description: "Test Description", - location: "Test Location", - status: "confirmed", - isAllDay: false, - timeZone: "UTC", - recurringEventId: null, - originalStartDate: null, - createdAt: new Date("2023-12-01T09:00:00Z"), - updatedAt: new Date("2023-12-01T09:30:00Z"), - etag: "test-etag", - kind: "calendar#event", - }; - - await expect(service.rescheduleBooking(event)).resolves.toBeUndefined(); - }); - }); -}); From a683faa205f2cb37aeb559f3abbc70a2eb2face1 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 19 Sep 2025 14:08:30 +0000 Subject: [PATCH 23/49] type fix --- packages/app-store/_utils/getCalendar.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index dae903c40a4cc7..8c9d1afb031c35 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -62,7 +62,8 @@ export const getCalendar = async ( if (isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser) { log.debug(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`); - const originalCalendar = new CalendarService(credential as unknown); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalCalendar = new CalendarService(credential as any); if (originalCalendar) { // return cacheable calendar const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); From b486347b82b84b2a22d514c3c32c1344439d4da2 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 19 Sep 2025 14:44:02 +0000 Subject: [PATCH 24/49] Fix coderabbit comments --- .../__tests__/route.test.ts | 23 +------------------ .../api/cron/calendar-subscriptions/route.ts | 2 +- .../GoogleCalendarSubscription.adapter.ts | 17 +++++++------- .../Office365CalendarSubscription.adapter.ts | 2 +- .../GoogleCalendarSubscriptionAdapter.test.ts | 3 ++- .../lib/cache/CalendarCacheEventRepository.ts | 9 ++------ .../lib/cache/CalendarCacheEventService.ts | 2 +- .../CalendarCacheEventRepository.test.ts | 10 ++++---- .../repository/SelectedCalendarRepository.ts | 9 +------- .../SelectedCalendarRepository.test.ts | 18 ++------------- 10 files changed, 23 insertions(+), 72 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts index f09dba63b30244..1694c5c0a04c1b 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts @@ -94,32 +94,11 @@ describe("/api/cron/calendar-subscriptions", () => { }); describe("Feature flag checks", () => { - test("should return early when cache is disabled", async () => { + test("should return early when cache AND sync are disabled", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); request.headers.set("authorization", "test-cron-key"); const mockIsCacheEnabled = vi.fn().mockResolvedValue(false); - const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); - const mockCheckForNewSubscriptions = vi.fn(); - - mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; - mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; - mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions; - - const { GET } = await import("../route"); - const response = await GET(request, { params: Promise.resolve({}) }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.ok).toBe(true); - expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled(); - }); - - test("should return early when sync is disabled", async () => { - const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); - request.headers.set("authorization", "test-cron-key"); - - const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); const mockCheckForNewSubscriptions = vi.fn(); diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 6bc1156345c937..3840d8ce653874 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -50,7 +50,7 @@ async function getHandler(request: NextRequest) { calendarSubscriptionService.isSyncEnabled(), ]); - if (!isCacheEnabled || !isSyncEnabled) { + if (!isCacheEnabled && !isSyncEnabled) { log.info("Calendar subscriptions are disabled"); return NextResponse.json({ ok: true }); } diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts index 462a47beb0e0c4..dd6f03c4943b99 100644 --- a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -34,7 +34,7 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP return false; } if (token !== this.GOOGLE_WEBHOOK_TOKEN) { - log.warn("Invalid webhook token", { token }); + log.warn("Invalid webhook token"); return false; } return true; @@ -56,12 +56,6 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId }); selectedCalendar; - console.log( - "🚀 ~ file: GoogleCalendarSubscription.adapter.ts:45 ~ GoogleCalendarSubscriptionAdapter ~ subscribe ~ this.GOOGLE_WEBHOOK_TOKEN", - this.GOOGLE_WEBHOOK_TOKEN, - this.GOOGLE_WEBHOOK_URL - ); - const MONTH_IN_SECONDS = 60 * 60 * 24 * 30; const client = await this.getClient(credential); @@ -96,7 +90,8 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP await client.channels .stop({ requestBody: { - resourceId: selectedCalendar.channelResourceId, + id: selectedCalendar.channelId as string, + resourceId: selectedCalendar.channelResourceId as string, }, }) .catch((err) => { @@ -185,7 +180,11 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP isAllDay: typeof event.start?.date === "string" && !event.start?.dateTime ? true : undefined, timeZone: event.start?.timeZone || null, recurringEventId: event.recurringEventId, - originalStartTime: event.originalStartTime?.dateTime, + originalStartDate: event.originalStartTime?.dateTime + ? new Date(event.originalStartTime.dateTime) + : event.originalStartTime?.date + ? new Date(event.originalStartTime.date) + : null, createdAt: event.created ? new Date(event.created) : null, updatedAt: event.updated ? new Date(event.updated) : null, }; diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 791a1c4aa5efa3..48e91081b9b74b 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -98,7 +98,7 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti return false; } if (clientState !== this.webhookToken) { - log.warn("Invalid clientState", { clientState }); + log.warn("Invalid clientState"); return false; } return true; diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts index 779ce5ce8981c4..a9718bdb434d41 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -245,6 +245,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { expect(mockClient.channels.stop).toHaveBeenCalledWith({ requestBody: { + id: "test-channel-id", resourceId: "test-resource-id", }, }); @@ -320,7 +321,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { isAllDay: undefined, timeZone: "UTC", recurringEventId: undefined, - originalStartTime: undefined, + originalStartDate: null, createdAt: new Date("2023-12-01T09:00:00Z"), updatedAt: new Date("2023-12-01T09:30:00Z"), }, diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index 5d5c0cafcb34a3..256e60a97b12b5 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -15,12 +15,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito selectedCalendarId: { in: selectedCalendarId, }, - start: { - gte: start, - }, - end: { - lte: end, - }, + AND: [{ start: { lt: end } }, { end: { gt: start } }], }, select: { start: true, @@ -30,7 +25,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async upsertMany(events: CalendarCacheEvent[]): Promise { + async upsertMany(events: Partial[]): Promise { if (events.length === 0) { return; } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index c09984f104b115..9be71dfb15cbc0 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -61,7 +61,7 @@ export class CalendarCacheEventService { toUpsert: toUpsert.length, toDelete: toDelete.length, }); - await Promise.allSettled([ + await Promise.all([ this.deps.calendarCacheEventRepository.deleteMany(toDelete), this.deps.calendarCacheEventRepository.upsertMany(toUpsert), ]); diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts index 221456a7942b46..6f48e9055558f1 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts @@ -64,12 +64,10 @@ describe("CalendarCacheEventRepository", () => { selectedCalendarId: { in: ["calendar-1", "calendar-2"], }, - start: { - gte: new Date("2023-12-01T00:00:00Z"), - }, - end: { - lte: new Date("2023-12-01T23:59:59Z"), - }, + AND: [ + { start: { lt: new Date("2023-12-01T23:59:59Z") } }, + { end: { gt: new Date("2023-12-01T00:00:00Z") } }, + ], }, select: { start: true, diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts index deeae6b6c71560..ce958e752360d6 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -26,14 +26,7 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { return this.prismaClient.selectedCalendar.findMany({ where: { integration: { in: integrations }, - OR: [ - { - syncSubscribedAt: null, - channelExpiration: { - gte: new Date(), - }, - }, - ], + OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: new Date() } }], }, take, }); diff --git a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts index 179380eb3e4dd6..2584b902a2c383 100644 --- a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts +++ b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts @@ -129,14 +129,7 @@ describe("SelectedCalendarRepository", () => { expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ where: { integration: { in: ["google_calendar", "office365_calendar"] }, - OR: [ - { - syncSubscribedAt: null, - channelExpiration: { - gte: expect.any(Date), - }, - }, - ], + OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], }, take: 10, }); @@ -156,14 +149,7 @@ describe("SelectedCalendarRepository", () => { expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ where: { integration: { in: [] }, - OR: [ - { - syncSubscribedAt: null, - channelExpiration: { - gte: expect.any(Date), - }, - }, - ], + OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], }, take: 5, }); From 075e055c36461a51a5ece0dbea3e69a3e0ff2c0e Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 19 Sep 2025 16:51:12 +0000 Subject: [PATCH 25/49] Fix types --- .../GoogleCalendarSubscription.adapter.ts | 20 +++++++------- .../Office365CalendarSubscription.adapter.ts | 17 +++++++----- .../GoogleCalendarSubscriptionAdapter.test.ts | 19 ++++++++------ .../lib/CalendarSubscriptionPort.interface.ts | 26 +++++++++---------- .../lib/cache/CalendarCacheEventRepository.ts | 2 +- .../CalendarCacheEventRepository.test.ts | 5 +++- 6 files changed, 50 insertions(+), 39 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts index dd6f03c4943b99..80f709528956a0 100644 --- a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -167,19 +167,19 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP return { id: event.id as string, - iCalUID: event.iCalUID, + iCalUID: event.iCalUID ?? null, start, end, busy, - summary: event.summary, - description: event.description, - location: event.location, - kind: event.kind, - etag: event.etag, - status: event.status, - isAllDay: typeof event.start?.date === "string" && !event.start?.dateTime ? true : undefined, - timeZone: event.start?.timeZone || null, - recurringEventId: event.recurringEventId, + summary: event.summary ?? null, + description: event.description ?? null, + location: event.location ?? null, + kind: event.kind ?? null, + etag: event.etag ?? null, + status: event.status ?? null, + isAllDay: typeof event.start?.date === "string" && !event.start?.dateTime ? true : false, + timeZone: event.start?.timeZone ?? null, + recurringEventId: event.recurringEventId ?? null, originalStartDate: event.originalStartTime?.dateTime ? new Date(event.originalStartTime.dateTime) : event.originalStartTime?.date diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 48e91081b9b74b..2338ad5d0b98c4 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -199,20 +199,25 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti return { id: e.id, - iCalUID: e.iCalUId ?? e.id, + iCalUID: e.iCalUId ?? null, start, end, busy, - summary: e.subject, - description: e.bodyPreview, - location: e.location?.displayName, + etag: null, + summary: e.subject ?? null, + description: e.bodyPreview ?? null, + location: e.location?.displayName ?? null, kind: e.type ?? "microsoftgraph#event", status: e.isCancelled ? "cancelled" : "confirmed", - isAllDay: !!e.isAllDay, + isAllDay: e.isAllDay ?? false, timeZone: e.start?.timeZone ?? null, + recurringEventId: null, + originalStartDate: null, + createdAt: null, + updatedAt: null, }; }) - .filter((i: CalendarSubscriptionEventItem) => Boolean(i.id)); + .filter(({ id }) => !!id); } private async getGraphClient(credential: CalendarCredential): Promise { diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts index a9718bdb434d41..878c76ae7eaa7e 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -1,8 +1,9 @@ import "../__mocks__/CalendarAuth"; -import { describe, test, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import type { SelectedCalendar } from "@calcom/prisma/client"; +import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter"; @@ -48,7 +49,9 @@ const mockCredential = { key: { access_token: "test-token" }, user: { email: "test@example.com" }, delegatedTo: null, -}; + type: null, + teamId: null, +} as unknown as CredentialForCalendarServiceWithEmail; describe("GoogleCalendarSubscriptionAdapter", () => { let adapter: GoogleCalendarSubscriptionAdapter; @@ -91,7 +94,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { headers: { get: vi.fn().mockReturnValue("test-webhook-token"), }, - } as Request; + } as unknown as Request; const result = await adapter.validate(mockRequest); @@ -104,7 +107,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { headers: { get: vi.fn().mockReturnValue("wrong-token"), }, - } as Request; + } as unknown as Request; const result = await adapter.validate(mockRequest); @@ -119,7 +122,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { headers: { get: vi.fn().mockReturnValue("test-webhook-token"), }, - } as Request; + } as unknown as Request; const result = await adapter.validate(mockRequest); @@ -131,7 +134,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { headers: { get: vi.fn().mockReturnValue(null), }, - } as Request; + } as unknown as Request; const result = await adapter.validate(mockRequest); @@ -145,7 +148,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { headers: { get: vi.fn().mockReturnValue("test-channel-id"), }, - } as Request; + } as unknown as Request; const result = await adapter.extractChannelId(mockRequest); @@ -158,7 +161,7 @@ describe("GoogleCalendarSubscriptionAdapter", () => { headers: { get: vi.fn().mockReturnValue(null), }, - } as Request; + } as unknown as Request; const result = await adapter.extractChannelId(mockRequest); diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts index f02704a47e6a4b..e2a1dec7da7b0a 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -15,22 +15,22 @@ export type CalendarSubscriptionResult = { export type CalendarSubscriptionEventItem = { id: string; - iCalUID?: string | null; + iCalUID: string | null; start?: Date; end?: Date; busy: boolean; - isAllDay?: boolean; - summary?: string | null; - description?: string | null; - kind?: string | null; - etag?: string | null; - status?: string | null; - location?: string | null; - originalStartDate?: Date | null; - recurringEventId?: string | null; - timeZone?: string | null; - createdAt?: Date | null; - updatedAt?: Date | null; + isAllDay: boolean; + summary: string | null; + description: string | null; + kind: string | null; + etag: string | null; + status: string | null; + location: string | null; + originalStartDate: Date | null; + recurringEventId: string | null; + timeZone: string | null; + createdAt: Date | null; + updatedAt: Date | null; }; export type CalendarSubscriptionEvent = { diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index 256e60a97b12b5..d239e9d03c9d10 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -25,7 +25,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async upsertMany(events: Partial[]): Promise { + async upsertMany(events: CalendarCacheEvent[]): Promise { if (events.length === 0) { return; } diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts index 6f48e9055558f1..dce36e3b83c6b3 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts @@ -15,10 +15,13 @@ const mockPrismaClient = { const mockCalendarCacheEvent: CalendarCacheEvent = { id: "test-id", + iCalUID: null, + iCalSequence: 0, selectedCalendarId: "test-calendar-id", externalId: "external-event-id", start: new Date("2023-12-01T10:00:00Z"), end: new Date("2023-12-01T11:00:00Z"), + status: "confirmed", summary: "Test Event", description: "Test Description", location: "Test Location", @@ -49,7 +52,7 @@ describe("CalendarCacheEventRepository", () => { end: new Date("2023-12-01T11:00:00Z"), timeZone: "UTC", }, - ]; + ] as unknown as CalendarCacheEvent[]; vi.mocked(mockPrismaClient.calendarCacheEvent.findMany).mockResolvedValue(mockEvents); From b1ef7b6405897aafa00696270c99974d7e9badb0 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 19 Sep 2025 17:12:04 +0000 Subject: [PATCH 26/49] Fix test --- .../__tests__/GoogleCalendarSubscriptionAdapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts index 878c76ae7eaa7e..9ff5fb664d496c 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -321,9 +321,9 @@ describe("GoogleCalendarSubscriptionAdapter", () => { kind: "calendar#event", etag: "test-etag", status: "confirmed", - isAllDay: undefined, + isAllDay: false, timeZone: "UTC", - recurringEventId: undefined, + recurringEventId: null, originalStartDate: null, createdAt: new Date("2023-12-01T09:00:00Z"), updatedAt: new Date("2023-12-01T09:30:00Z"), From 7c267ad58d012fd1e583a8f5164159eea8d24732 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sat, 20 Sep 2025 07:25:26 -0300 Subject: [PATCH 27/49] Update apps/web/app/api/cron/calendar-subscriptions/route.ts Co-authored-by: Alex van Andel --- apps/web/app/api/cron/calendar-subscriptions/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 3840d8ce653874..a1b8866654b5b6 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -51,7 +51,7 @@ async function getHandler(request: NextRequest) { ]); if (!isCacheEnabled && !isSyncEnabled) { - log.info("Calendar subscriptions are disabled"); + log.info(`Calendar subscriptions are disabled (sync=${isSyncEnabled}, cache=${isCacheEnabled})`); return NextResponse.json({ ok: true }); } From 5e8ed4feec3b1472c1c19e574821130b21dfe6ae Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sat, 20 Sep 2025 11:03:41 +0000 Subject: [PATCH 28/49] Fixes by first review --- .../__tests__/route.test.ts | 12 +++--- .../calendar-subscriptions-cleanup/route.ts | 8 +--- .../__tests__/route.test.ts | 12 +++--- .../api/cron/calendar-subscriptions/route.ts | 9 +---- .../calendar-subscription/[provider]/route.ts | 15 +++++--- .../adapters/AdaptersFactory.ts | 3 +- .../lib/CalendarSubscriptionService.ts | 8 +++- .../CalendarSubscriptionService.test.ts | 12 +++--- .../lib/cache/CalendarCacheEventRepository.ts | 16 ++++---- .../SelectedCalendarRepository.interface.ts | 4 +- .../repository/SelectedCalendarRepository.ts | 2 +- .../SelectedCalendarRepository.test.ts | 6 +-- packages/prisma/schema.prisma | 38 +++++++++++-------- 13 files changed, 75 insertions(+), 70 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts index 94f4394d3db8c1..f297d360736516 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts @@ -78,27 +78,27 @@ describe("/api/cron/calendar-subscriptions-cleanup", () => { }); describe("Authentication", () => { - test("should return 401 when no API key is provided", async () => { + test("should return 403 when no API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(401); + expect(response.status).toBe(403); const body = await response.json(); - expect(body.message).toBe("Not authenticated"); + expect(body.message).toBe("Forbiden"); }); - test("should return 401 when invalid API key is provided", async () => { + test("should return 403 when invalid API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); request.headers.set("authorization", "invalid-key"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(401); + expect(response.status).toBe(403); const body = await response.json(); - expect(body.message).toBe("Not authenticated"); + expect(body.message).toBe("Forbiden"); }); test("should accept CRON_API_KEY in authorization header", async () => { diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts index 6ad790e5a83077..428582bd3eecd4 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts @@ -3,12 +3,9 @@ import { NextResponse } from "next/server"; import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; -import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; -const log = logger.getSubLogger({ prefix: ["cron"] }); - /** * Cron webhook * Cleanup stale calendar cache @@ -17,11 +14,10 @@ const log = logger.getSubLogger({ prefix: ["cron"] }); * @returns */ async function getHandler(request: NextRequest) { - log.info("Cleaning up stale calendar cache events"); 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 }); + return NextResponse.json({ message: "Forbiden" }, { status: 403 }); } // instantiate dependencies @@ -32,10 +28,8 @@ async function getHandler(request: NextRequest) { try { await calendarCacheEventService.cleanupStaleCache(); - log.info("Stale calendar cache events cleaned up"); return NextResponse.json({ ok: true }); } catch (e) { - log.error("Error cleaning up stale calendar cache events", { error: e }); const message = e instanceof Error ? e.message : "Unknown error"; return NextResponse.json({ message }, { status: 500 }); } diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts index 1694c5c0a04c1b..cefcb61c54371b 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts @@ -51,27 +51,27 @@ describe("/api/cron/calendar-subscriptions", () => { }); describe("Authentication", () => { - test("should return 401 when no API key is provided", async () => { + test("should return 403 when no API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(401); + expect(response.status).toBe(403); const body = await response.json(); - expect(body.message).toBe("Not authenticated"); + expect(body.message).toBe("Forbiden"); }, 10000); - test("should return 401 when invalid API key is provided", async () => { + test("should return 403 when invalid API key is provided", async () => { const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions"); request.headers.set("authorization", "invalid-key"); const { GET } = await import("../route"); const response = await GET(request, { params: Promise.resolve({}) }); - expect(response.status).toBe(401); + expect(response.status).toBe(403); const body = await response.json(); - expect(body.message).toBe("Not authenticated"); + expect(body.message).toBe("Forbiden"); }); test("should accept valid API key", async () => { diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 3840d8ce653874..8e2d09eaa5c9c8 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -7,13 +7,10 @@ import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscrip import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; -const log = logger.getSubLogger({ prefix: ["cron"] }); - /** * Cron webhook * Checks for new calendar subscriptions (rollouts) @@ -22,11 +19,10 @@ const log = logger.getSubLogger({ prefix: ["cron"] }); * @returns */ async function getHandler(request: NextRequest) { - log.info("Checking for new calendar subscriptions"); 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 }); + return NextResponse.json({ message: "Forbiden" }, { status: 403 }); } // instantiate dependencies @@ -51,16 +47,13 @@ async function getHandler(request: NextRequest) { ]); if (!isCacheEnabled && !isSyncEnabled) { - log.info("Calendar subscriptions are disabled"); return NextResponse.json({ ok: true }); } try { await calendarSubscriptionService.checkForNewSubscriptions(); - log.info("Checked for new calendar subscriptions successfully"); return NextResponse.json({ ok: true }); } catch (e) { - log.error("Error checking for new calendar subscriptions", { error: e }); const message = e instanceof Error ? e.message : "Unknown error"; return NextResponse.json({ message }, { status: 500 }); } diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 279ebb7bdee0da..3a69a0ce4bf976 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -27,13 +27,16 @@ const log = logger.getSubLogger({ prefix: ["calendar-webhook"] }); * @param {Promise} context.params - A promise that resolves to the route parameters. * @returns {Promise} - A promise that resolves to the response object. */ -async function postHandler(request: NextRequest, context: { params: Promise }) { - log.debug("Received webhook"); +function isCalendarSubscriptionProvider(provider: string): provider is CalendarSubscriptionProvider { + return provider === "google_calendar" || provider === "office365_calendar"; +} + +async function postHandler(request: NextRequest, context: { params: Promise }) { // extract and validate provider - const provider = (await context.params).provider as string[][0] as CalendarSubscriptionProvider; - const allowed = new Set(["google_calendar", "office365_calendar"]); - if (!allowed.has(provider as CalendarSubscriptionProvider)) { + const providerFromParams = (await context.params).provider as string[][0]; + + if (!isCalendarSubscriptionProvider(providerFromParams)) { return NextResponse.json({ message: "Unsupported provider" }, { status: 400 }); } @@ -64,7 +67,7 @@ async function postHandler(request: NextRequest, context: { params: Promise { log.debug("subscribe", { selectedCalendarId }); - const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId); + const selectedCalendar = await this.deps.selectedCalendarRepository.findByIdWithCredentials( + selectedCalendarId + ); if (!selectedCalendar?.credentialId) { log.debug("Selected calendar not found", { selectedCalendarId }); return; @@ -70,7 +72,9 @@ export class CalendarSubscriptionService { */ async unsubscribe(selectedCalendarId: string): Promise { log.debug("unsubscribe", { selectedCalendarId }); - const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId); + const selectedCalendar = await this.deps.selectedCalendarRepository.findByIdWithCredentials( + selectedCalendarId + ); if (!selectedCalendar?.credentialId) return; const credential = await this.getCredential(selectedCalendar.credentialId); diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts index 93432b9d146309..f1ad831461f94e 100644 --- a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -115,7 +115,7 @@ describe("CalendarSubscriptionService", () => { }; mockSelectedCalendarRepository = { - findById: vi.fn().mockResolvedValue(mockSelectedCalendar), + findByIdWithCredentials: vi.fn().mockResolvedValue(mockSelectedCalendar), findByChannelId: vi.fn().mockResolvedValue(mockSelectedCalendar), findNextSubscriptionBatch: vi.fn().mockResolvedValue([mockSelectedCalendar]), updateSyncStatus: vi.fn().mockResolvedValue(mockSelectedCalendar), @@ -153,7 +153,7 @@ describe("CalendarSubscriptionService", () => { test("should successfully subscribe to a calendar", async () => { await service.subscribe("test-calendar-id"); - expect(mockSelectedCalendarRepository.findById).toHaveBeenCalledWith("test-calendar-id"); + expect(mockSelectedCalendarRepository.findByIdWithCredentials).toHaveBeenCalledWith("test-calendar-id"); expect(mockAdapterFactory.get).toHaveBeenCalledWith("google_calendar"); expect(mockAdapter.subscribe).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential); expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", { @@ -167,7 +167,7 @@ describe("CalendarSubscriptionService", () => { }); test("should return early if selected calendar not found", async () => { - mockSelectedCalendarRepository.findById.mockResolvedValue(null); + mockSelectedCalendarRepository.findByIdWithCredentials.mockResolvedValue(null); await service.subscribe("non-existent-id"); @@ -176,7 +176,7 @@ describe("CalendarSubscriptionService", () => { }); test("should return early if selected calendar has no credentialId", async () => { - mockSelectedCalendarRepository.findById.mockResolvedValue({ + mockSelectedCalendarRepository.findByIdWithCredentials.mockResolvedValue({ ...mockSelectedCalendar, credentialId: null, }); @@ -194,7 +194,7 @@ describe("CalendarSubscriptionService", () => { await service.unsubscribe("test-calendar-id"); - expect(mockSelectedCalendarRepository.findById).toHaveBeenCalledWith("test-calendar-id"); + expect(mockSelectedCalendarRepository.findByIdWithCredentials).toHaveBeenCalledWith("test-calendar-id"); expect(mockAdapter.unsubscribe).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential); expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", { syncSubscribedAt: null, @@ -211,7 +211,7 @@ describe("CalendarSubscriptionService", () => { }); test("should return early if selected calendar not found", async () => { - mockSelectedCalendarRepository.findById.mockResolvedValue(null); + mockSelectedCalendarRepository.findByIdWithCredentials.mockResolvedValue(null); await service.unsubscribe("non-existent-id"); diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index d239e9d03c9d10..2e3faaddd1cac8 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -25,12 +25,12 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async upsertMany(events: CalendarCacheEvent[]): Promise { + async upsertMany(events: CalendarCacheEvent[]) { if (events.length === 0) { return; } // lack of upsertMany in prisma - return Promise.all( + return Promise.allSettled( events.map((event) => { return this.prismaClient.calendarCacheEvent.upsert({ where: { @@ -54,9 +54,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito ); } - async deleteMany( - events: Pick[] - ): Promise { + async deleteMany(events: Pick[]) { // Only delete events with externalId and selectedCalendarId const conditions = events.filter((c) => c.externalId && c.selectedCalendarId); if (conditions.length === 0) { @@ -70,7 +68,11 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise { + async deleteAllBySelectedCalendarId(selectedCalendarId: string) { + if (!selectedCalendarId) { + return; + } + return this.prismaClient.calendarCacheEvent.deleteMany({ where: { selectedCalendarId, @@ -78,7 +80,7 @@ export class CalendarCacheEventRepository implements ICalendarCacheEventReposito }); } - async deleteStale(): Promise { + async deleteStale() { return this.prismaClient.calendarCacheEvent.deleteMany({ where: { end: { diff --git a/packages/lib/server/repository/SelectedCalendarRepository.interface.ts b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts index 55b49b7b5ecebd..45939e99be5dc3 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.interface.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts @@ -2,11 +2,11 @@ import type { Prisma, SelectedCalendar } from "@calcom/prisma/client"; export interface ISelectedCalendarRepository { /** - * Find selected calendar by id + * Find selected calendar by id with credentials * * @param id */ - findById(id: string): Promise; + findByIdWithCredentials(id: string): Promise; /** * Find selected calendar by channel id diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts index ce958e752360d6..69d203f62789de 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -5,7 +5,7 @@ import type { Prisma } from "@calcom/prisma/client"; export class SelectedCalendarRepository implements ISelectedCalendarRepository { constructor(private prismaClient: PrismaClient) {} - async findById(id: string) { + async findByIdWithCredentials(id: string) { return this.prismaClient.selectedCalendar.findUnique({ where: { id }, include: { diff --git a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts index 2584b902a2c383..123bebf462113b 100644 --- a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts +++ b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts @@ -55,7 +55,7 @@ describe("SelectedCalendarRepository", () => { vi.clearAllMocks(); }); - describe("findById", () => { + describe("findByIdWithCredentials", () => { test("should find selected calendar by id with credential delegation", async () => { const mockCalendarWithCredential = { ...mockSelectedCalendar, @@ -69,7 +69,7 @@ describe("SelectedCalendarRepository", () => { vi.mocked(mockPrismaClient.selectedCalendar.findUnique).mockResolvedValue(mockCalendarWithCredential); - const result = await repository.findById("test-calendar-id"); + const result = await repository.findByIdWithCredentials("test-calendar-id"); expect(mockPrismaClient.selectedCalendar.findUnique).toHaveBeenCalledWith({ where: { id: "test-calendar-id" }, @@ -88,7 +88,7 @@ describe("SelectedCalendarRepository", () => { test("should return null when calendar not found", async () => { vi.mocked(mockPrismaClient.selectedCalendar.findUnique).mockResolvedValue(null); - const result = await repository.findById("non-existent-id"); + const result = await repository.findByIdWithCredentials("non-existent-id"); expect(result).toBeNull(); }); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 79147c00a46308..a7b6b9cd139b17 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -901,22 +901,30 @@ model Availability { } model SelectedCalendar { - id String @id @default(uuid()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - integration String - externalId String - credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) - credentialId Int? - createdAt DateTime? @default(now()) - updatedAt DateTime? @updatedAt + id String @id @default(uuid()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + credentialId Int? + createdAt DateTime? @default(now()) + updatedAt DateTime? @updatedAt // Used to identify a watched calendar channel in Google Calendar - // @deprecated("") - googleChannelId String? - googleChannelKind String? - googleChannelResourceId String? + // @deprecated use channelId instead + googleChannelId String? + + // @deprecated use channelKind instead + googleChannelKind String? + + // @deprecated use channelResourceId instead + googleChannelResourceId String? + + // @deprecated use channelResourceUri instead googleChannelResourceUri String? - googleChannelExpiration String? + + // @deprecated use channelExpoiration instead + googleChannelExpiration String? // Used to identify a watched calendar channelId String? @@ -925,7 +933,7 @@ model SelectedCalendar { channelResourceUri String? channelExpiration DateTime? @db.Timestamp(3) - // Used to calendar cache and sync + // Used to calendar cache and sync syncSubscribedAt DateTime? @db.Timestamp(3) syncToken String? syncedAt DateTime? From 68bde1e7af81811aeac33296af69edd2285d44f4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:13:10 +0000 Subject: [PATCH 29/49] feat: add database migrations for calendar cache and sync fields - Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values - Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount - Create CalendarCacheEvent table with foreign key to SelectedCalendar - Add necessary indexes and constraints for performance and data integrity Fixes database schema issues causing e2e test failures with 'column does not exist' errors. Co-Authored-By: Volnei Munhoz --- .../migration.sql | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql diff --git a/packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql b/packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql new file mode 100644 index 00000000000000..5b20badb070520 --- /dev/null +++ b/packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql @@ -0,0 +1,58 @@ +/* + Warnings: + + - Added calendar cache and sync fields to SelectedCalendar table + - Created CalendarCacheEvent table with CalendarCacheEventStatus enum + +*/ + +CREATE TYPE "CalendarCacheEventStatus" AS ENUM ('confirmed', 'tentative', 'cancelled'); + +-- AlterTable +ALTER TABLE "SelectedCalendar" ADD COLUMN "channelId" TEXT, +ADD COLUMN "channelKind" TEXT, +ADD COLUMN "channelResourceId" TEXT, +ADD COLUMN "channelResourceUri" TEXT, +ADD COLUMN "channelExpiration" TIMESTAMP(3), +ADD COLUMN "syncSubscribedAt" TIMESTAMP(3), +ADD COLUMN "syncToken" TEXT, +ADD COLUMN "syncedAt" TIMESTAMP(3), +ADD COLUMN "syncErrorAt" TIMESTAMP(3), +ADD COLUMN "syncErrorCount" INTEGER DEFAULT 0; + +CREATE TABLE "CalendarCacheEvent" ( + "id" TEXT NOT NULL, + "selectedCalendarId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "externalEtag" TEXT NOT NULL, + "iCalUID" TEXT, + "iCalSequence" INTEGER NOT NULL DEFAULT 0, + "summary" TEXT, + "description" TEXT, + "location" TEXT, + "start" TIMESTAMP(3) NOT NULL, + "end" TIMESTAMP(3) NOT NULL, + "isAllDay" BOOLEAN NOT NULL DEFAULT false, + "timeZone" TEXT, + "status" "CalendarCacheEventStatus" NOT NULL DEFAULT 'confirmed', + "recurringEventId" TEXT, + "originalStartTime" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "externalCreatedAt" TIMESTAMP(3), + "externalUpdatedAt" TIMESTAMP(3), + + CONSTRAINT "CalendarCacheEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CalendarCacheEvent_selectedCalendarId_externalId_key" ON "CalendarCacheEvent"("selectedCalendarId", "externalId"); + +-- CreateIndex +CREATE INDEX "CalendarCacheEvent_start_end_status_idx" ON "CalendarCacheEvent"("start", "end", "status"); + +-- CreateIndex +CREATE INDEX "CalendarCacheEvent_selectedCalendarId_iCalUID_idx" ON "CalendarCacheEvent"("selectedCalendarId", "iCalUID"); + +-- AddForeignKey +ALTER TABLE "CalendarCacheEvent" ADD CONSTRAINT "CalendarCacheEvent_selectedCalendarId_fkey" FOREIGN KEY ("selectedCalendarId") REFERENCES "SelectedCalendar"("id") ON DELETE CASCADE ON UPDATE CASCADE; From c556e66a7d03d9c78b1375a3dd9c18376f462580 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 23 Sep 2025 11:58:55 +0000 Subject: [PATCH 30/49] only google-calendar for now --- .../lib/cache/CalendarCacheEventService.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index 9be71dfb15cbc0..906103c58758fb 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -88,11 +88,12 @@ export class CalendarCacheEventService { /** * Checks if the app is supported * - * @param appId + * @param type * @returns */ - static isCalendarTypeSupported(appId: string | null): boolean { - if (!appId) return false; - return ["google_calendar", "office365_calendar"].includes(appId); + static isCalendarTypeSupported(type: string | null): boolean { + if (!type) return false; + // return ["google_calendar", "office365_calendar"].includes(type); + return ["google_calendar"].includes(type); } } From d34b87801db8977e433d68f93fab0159d97a0ea5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:03:48 +0000 Subject: [PATCH 31/49] docs: add Calendar Cache and Sync feature documentation - Add comprehensive feature overview and motivation - Document feature flags with SQL examples - Include SQL examples for enabling features for users and teams - Reference technical documentation files Addresses PR #23876 documentation requirements Co-Authored-By: Volnei Munhoz --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/README.md b/README.md index 75ba34a148f87b..de6da448bced44 100644 --- a/README.md +++ b/README.md @@ -613,6 +613,106 @@ following [Follow these steps](./packages/app-store/pipedrive-crm/) +## Calendar Cache and Sync + +The **Calendar Cache and Sync** feature provides efficient calendar synchronization with intelligent caching to reduce API calls and ensure real-time updates across your Cal.com instance. + +### Feature Overview + +This feature introduces two complementary capabilities: + +- **Calendar Cache**: Stores calendar availability data locally to reduce external API calls and improve performance +- **Calendar Sync**: Uses webhooks to automatically listen for calendar updates and apply changes in real-time + +**Key Benefits:** +- **Efficiency**: Reduces API calls with optimized caching strategies +- **Reliability**: Guarantees updates through webhook event delivery +- **Real-Time Sync**: Ensures calendars are always up-to-date with minimal latency +- **Scalability**: Supports multiple calendars and handles high-volume updates seamlessly + +### Environment Variables + +No additional environment variables are required for this feature. It uses the existing Cal.com infrastructure including: +- `DATABASE_URL` - For storing cache data and sync state +- `CALENDSO_ENCRYPTION_KEY` - For secure credential storage +- Existing calendar integration credentials (Google, Office365, etc.) + +### Feature Flags + +This feature is controlled by three feature flags that can be enabled independently: + +#### 1. calendar-cache-serve +Controls whether to serve calendar cache data on a team/user basis. + +```sql +INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") +VALUES ('calendar-cache-serve', false, 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) +ON CONFLICT (slug) DO NOTHING; +``` + +#### 2. calendar-subscription-cache +Enables calendar cache recording and usage through calendars. This flag should be managed individually by teams. + +```sql +INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") +VALUES ('calendar-subscription-cache', false, 'Allow calendar cache to be recorded and used through calendars.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) +ON CONFLICT (slug) DO NOTHING; +``` + +#### 3. calendar-subscription-sync +Enables calendar sync globally for all users regardless of team or organization. + +```sql +INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") +VALUES ('calendar-subscription-sync', false, 'Enable calendar sync for all calendars globally.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) +ON CONFLICT (slug) DO NOTHING; +``` + +### Enabling Features for Specific Users + +To enable calendar cache features for specific users, add entries to the `UserFeatures` table: + +```sql +-- Enable calendar-cache-serve for user ID 123 +INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (123, 'calendar-cache-serve', NOW(), 'admin', NOW()) +ON CONFLICT ("userId", "featureId") DO NOTHING; + +-- Enable calendar-subscription-cache for user ID 123 +INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (123, 'calendar-subscription-cache', NOW(), 'admin', NOW()) +ON CONFLICT ("userId", "featureId") DO NOTHING; +``` + +### Enabling Features for Specific Teams + +To enable calendar cache features for specific teams, add entries to the `TeamFeatures` table: + +```sql +-- Enable calendar-cache-serve for team ID 456 +INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (456, 'calendar-cache-serve', NOW(), 'admin', NOW()) +ON CONFLICT ("teamId", "featureId") DO NOTHING; + +-- Enable calendar-subscription-cache for team ID 456 +INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (456, 'calendar-subscription-cache', NOW(), 'admin', NOW()) +ON CONFLICT ("teamId", "featureId") DO NOTHING; +``` + +### Architecture + +The calendar cache and sync system consists of several key components: + +- **CalendarCacheEvent Table**: Stores cached calendar events with status tracking +- **SelectedCalendar Extensions**: Additional fields for sync state and webhook management +- **Cron Jobs**: Automated processes for cache cleanup and calendar watching +- **Webhook Handlers**: Real-time event processing for calendar updates + +For detailed technical documentation, see: +- [Calendar Cache Documentation](./packages/features/calendar-cache/CalendarCache.md) +- [Calendar Subscription Documentation](./packages/features/calendar-subscription/README.md) + ## Workflows ### Setting up SendGrid for Email reminders From f147197d0d0851561f0b58ae07c19dbf37171315 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:13:37 +0000 Subject: [PATCH 32/49] docs: update calendar subscription README with comprehensive documentation - Undo incorrect changes to main README.md - Update packages/features/calendar-subscription/README.md with: - Feature overview and motivation - Environment variables section - Complete feature flags documentation with SQL examples - SQL examples for enabling features for users and teams - Detailed architecture documentation Addresses PR #23876 documentation requirements Co-Authored-By: Volnei Munhoz --- README.md | 100 ------------ .../features/calendar-subscription/README.md | 146 +++++++++++++++--- 2 files changed, 121 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index de6da448bced44..75ba34a148f87b 100644 --- a/README.md +++ b/README.md @@ -613,106 +613,6 @@ following [Follow these steps](./packages/app-store/pipedrive-crm/) -## Calendar Cache and Sync - -The **Calendar Cache and Sync** feature provides efficient calendar synchronization with intelligent caching to reduce API calls and ensure real-time updates across your Cal.com instance. - -### Feature Overview - -This feature introduces two complementary capabilities: - -- **Calendar Cache**: Stores calendar availability data locally to reduce external API calls and improve performance -- **Calendar Sync**: Uses webhooks to automatically listen for calendar updates and apply changes in real-time - -**Key Benefits:** -- **Efficiency**: Reduces API calls with optimized caching strategies -- **Reliability**: Guarantees updates through webhook event delivery -- **Real-Time Sync**: Ensures calendars are always up-to-date with minimal latency -- **Scalability**: Supports multiple calendars and handles high-volume updates seamlessly - -### Environment Variables - -No additional environment variables are required for this feature. It uses the existing Cal.com infrastructure including: -- `DATABASE_URL` - For storing cache data and sync state -- `CALENDSO_ENCRYPTION_KEY` - For secure credential storage -- Existing calendar integration credentials (Google, Office365, etc.) - -### Feature Flags - -This feature is controlled by three feature flags that can be enabled independently: - -#### 1. calendar-cache-serve -Controls whether to serve calendar cache data on a team/user basis. - -```sql -INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") -VALUES ('calendar-cache-serve', false, 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) -ON CONFLICT (slug) DO NOTHING; -``` - -#### 2. calendar-subscription-cache -Enables calendar cache recording and usage through calendars. This flag should be managed individually by teams. - -```sql -INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") -VALUES ('calendar-subscription-cache', false, 'Allow calendar cache to be recorded and used through calendars.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) -ON CONFLICT (slug) DO NOTHING; -``` - -#### 3. calendar-subscription-sync -Enables calendar sync globally for all users regardless of team or organization. - -```sql -INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") -VALUES ('calendar-subscription-sync', false, 'Enable calendar sync for all calendars globally.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) -ON CONFLICT (slug) DO NOTHING; -``` - -### Enabling Features for Specific Users - -To enable calendar cache features for specific users, add entries to the `UserFeatures` table: - -```sql --- Enable calendar-cache-serve for user ID 123 -INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") -VALUES (123, 'calendar-cache-serve', NOW(), 'admin', NOW()) -ON CONFLICT ("userId", "featureId") DO NOTHING; - --- Enable calendar-subscription-cache for user ID 123 -INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") -VALUES (123, 'calendar-subscription-cache', NOW(), 'admin', NOW()) -ON CONFLICT ("userId", "featureId") DO NOTHING; -``` - -### Enabling Features for Specific Teams - -To enable calendar cache features for specific teams, add entries to the `TeamFeatures` table: - -```sql --- Enable calendar-cache-serve for team ID 456 -INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") -VALUES (456, 'calendar-cache-serve', NOW(), 'admin', NOW()) -ON CONFLICT ("teamId", "featureId") DO NOTHING; - --- Enable calendar-subscription-cache for team ID 456 -INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") -VALUES (456, 'calendar-subscription-cache', NOW(), 'admin', NOW()) -ON CONFLICT ("teamId", "featureId") DO NOTHING; -``` - -### Architecture - -The calendar cache and sync system consists of several key components: - -- **CalendarCacheEvent Table**: Stores cached calendar events with status tracking -- **SelectedCalendar Extensions**: Additional fields for sync state and webhook management -- **Cron Jobs**: Automated processes for cache cleanup and calendar watching -- **Webhook Handlers**: Real-time event processing for calendar updates - -For detailed technical documentation, see: -- [Calendar Cache Documentation](./packages/features/calendar-cache/CalendarCache.md) -- [Calendar Subscription Documentation](./packages/features/calendar-subscription/README.md) - ## Workflows ### Setting up SendGrid for Email reminders diff --git a/packages/features/calendar-subscription/README.md b/packages/features/calendar-subscription/README.md index b2c9ac54152dc6..e7b447b3fd980c 100644 --- a/packages/features/calendar-subscription/README.md +++ b/packages/features/calendar-subscription/README.md @@ -1,40 +1,136 @@ -# Calendar Subscription +# Calendar Cache and Sync -The **Calendar Subscription** feature keeps your calendars synchronized while ensuring efficient caching. +The **Calendar Cache and Sync** feature provides efficient calendar synchronization with intelligent caching to reduce API calls and ensure real-time updates across your Cal.com instance. -When enabled, it uses **webhooks** to automatically listen for updates and apply changes in real time. -This means new events, modifications, or deletions are instantly captured and reflected across the system — no manual refresh or constant polling required. +## Feature Overview -## Key Benefits -- **Efficiency**: reduces API calls with optimized caching strategies. -- **Reliability**: guarantees updates through webhook event delivery. -- **Real-Time Sync**: ensures calendars are always up-to-date with minimal latency. -- **Scalability**: supports multiple calendars and handles high-volume updates seamlessly. +This feature introduces two complementary capabilities: -By subscribing to calendars via webhooks, you gain a smarter, faster, and more resource-friendly way to keep your data in sync. +- **Calendar Cache**: Stores calendar availability data locally to reduce external API calls and improve performance +- **Calendar Sync**: Uses webhooks to automatically listen for calendar updates and apply changes in real-time + +**Key Benefits:** +- **Efficiency**: Reduces API calls with optimized caching strategies +- **Reliability**: Guarantees updates through webhook event delivery +- **Real-Time Sync**: Ensures calendars are always up-to-date with minimal latency +- **Scalability**: Supports multiple calendars and handles high-volume updates seamlessly + +**Motivation:** +By subscribing to calendars via webhooks and implementing intelligent caching, you gain a smarter, faster, and more resource-friendly way to keep your data in sync. This eliminates the need for constant polling and reduces the load on external calendar APIs while ensuring data consistency. + +## Environment Variables + +No additional environment variables are required for this feature. It uses the existing Cal.com infrastructure including: +- `DATABASE_URL` - For storing cache data and sync state +- `CALENDSO_ENCRYPTION_KEY` - For secure credential storage +- Existing calendar integration credentials (Google, Office365, etc.) ## Feature Flags -This feature can be enabled using two feature flags: -1. `calendar-subscription-cache` that will allow calendar cache to be recorded and used thru calendars, this flag is for globally enable this feature that should be managed individually by teams through team_Features +This feature is controlled by three feature flags that can be enabled independently: + +### 1. calendar-cache-serve +Controls whether to serve calendar cache data on a team/user basis. + +```sql +INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") +VALUES ('calendar-cache-serve', false, 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) +ON CONFLICT (slug) DO NOTHING; +``` + +### 2. calendar-subscription-cache +Enables calendar cache recording and usage through calendars. This flag should be managed individually by teams. + +```sql +INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") +VALUES ('calendar-subscription-cache', false, 'Allow calendar cache to be recorded and used through calendars.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) +ON CONFLICT (slug) DO NOTHING; +``` + +### 3. calendar-subscription-sync +Enables calendar sync globally for all users regardless of team or organization. + +```sql +INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") +VALUES ('calendar-subscription-sync', false, 'Enable calendar sync for all calendars globally.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) +ON CONFLICT (slug) DO NOTHING; +``` + +## Enabling Features for Specific Users + +To enable calendar cache features for specific users, add entries to the `UserFeatures` table: + +```sql +-- Enable calendar-cache-serve for user ID 123 +INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (123, 'calendar-cache-serve', NOW(), 'admin', NOW()) +ON CONFLICT ("userId", "featureId") DO NOTHING; + +-- Enable calendar-subscription-cache for user ID 123 +INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (123, 'calendar-subscription-cache', NOW(), 'admin', NOW()) +ON CONFLICT ("userId", "featureId") DO NOTHING; + +-- Enable calendar-subscription-sync for user ID 123 +INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (123, 'calendar-subscription-sync', NOW(), 'admin', NOW()) +ON CONFLICT ("userId", "featureId") DO NOTHING; +``` + +## Enabling Features for Specific Teams + +To enable calendar cache features for specific teams, add entries to the `TeamFeatures` table: + +```sql +-- Enable calendar-cache-serve for team ID 456 +INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (456, 'calendar-cache-serve', NOW(), 'admin', NOW()) +ON CONFLICT ("teamId", "featureId") DO NOTHING; + +-- Enable calendar-subscription-cache for team ID 456 +INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (456, 'calendar-subscription-cache', NOW(), 'admin', NOW()) +ON CONFLICT ("teamId", "featureId") DO NOTHING; + +-- Enable calendar-subscription-sync for team ID 456 +INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") +VALUES (456, 'calendar-subscription-sync', NOW(), 'admin', NOW()) +ON CONFLICT ("teamId", "featureId") DO NOTHING; +``` -### Enabling cache feature -`insert into Feature` -INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") VALUES -('calendar-cache-serve', 'f', 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', 'f', NULL, '2025-09-16 17:02:09.292', '2025-09-16 17:02:09.292', NULL); +## Architecture +The calendar cache and sync system consists of several key components: -`insert into TeamFeatures -INSERT INTO "public"."TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") VALUES -(1, DEFAULT, DEFAULT, DEFAULT, DEFAULT); +### Database Schema +- **CalendarCacheEvent Table**: Stores cached calendar events with status tracking +- **SelectedCalendar Extensions**: Additional fields for sync state and webhook management including: + - `channelId`: Webhook channel identifier + - `channelResourceId`: Resource ID for webhook subscriptions + - `channelResourceUri`: URI for webhook notifications + - `channelKind`: Type of webhook channel + - `channelExpiration`: Webhook subscription expiration time + - `syncToken`: Token for incremental sync + - `syncedAt`: Last successful sync timestamp + - `syncErrorAt`: Last sync error timestamp + - `syncErrorCount`: Number of consecutive sync errors + - `syncSubscribedAt`: Webhook subscription timestamp -### Enabling sync feature -2. `calendar-subscription-sync` this flag will enable canlendar sync for all calendars, diferently from cache it will be enabled globally for all users regardinless it is a team, individual or org. -INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") VALUES -('calendar-cache-serve', 'f', 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', 'f', NULL, '2025-09-16 17:02:09.292', '2025-09-16 17:02:09.292', NULL); +### Core Services +- **CalendarCacheEventRepository**: Manages cached event storage and retrieval +- **CalendarSubscriptionService**: Orchestrates webhook subscriptions and event processing +- **Provider-specific Adapters**: Handle calendar-specific sync logic (Google, Office365) -## Team Feature Flags +### Background Processes +- **Cron Jobs**: Automated processes for cache cleanup and calendar watching +- **Webhook Handlers**: Real-time event processing for calendar updates +### Integration Points +- **Calendar Providers**: Google Calendar, Office365, and other supported integrations +- **Webhook Endpoints**: Receive real-time notifications from calendar providers +- **Cache Layer**: Optimized storage for frequently accessed calendar data -## Architecture +For detailed technical implementation, see: +- [Calendar Cache Documentation](../calendar-cache/CalendarCache.md) +- Database migrations in `packages/prisma/migrations/` From a967b42e83c47e18af9e8b633291713f943e2ca8 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 23 Sep 2025 14:10:18 -0300 Subject: [PATCH 33/49] fix docs --- .../features/calendar-subscription/README.md | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/features/calendar-subscription/README.md b/packages/features/calendar-subscription/README.md index e7b447b3fd980c..6840291c72f01d 100644 --- a/packages/features/calendar-subscription/README.md +++ b/packages/features/calendar-subscription/README.md @@ -20,25 +20,16 @@ By subscribing to calendars via webhooks and implementing intelligent caching, y ## Environment Variables -No additional environment variables are required for this feature. It uses the existing Cal.com infrastructure including: -- `DATABASE_URL` - For storing cache data and sync state -- `CALENDSO_ENCRYPTION_KEY` - For secure credential storage -- Existing calendar integration credentials (Google, Office365, etc.) +- **GOOGLE_WEBHOOK_URL**: Optional, only used for local tests. Default points to application url. +- **GOOGLE_WEBHOOK_TOKEN**: Required, token to validate Google Webhook incoming. +- **MICROSOFT_WEBHOOK_URL**: Optional, only used for local tests. Default points to application url. +- **MICROSOFT_WEBHOOK_TOKEN**: Required, token to validate Microsoft Webhook incoming. ## Feature Flags This feature is controlled by three feature flags that can be enabled independently: -### 1. calendar-cache-serve -Controls whether to serve calendar cache data on a team/user basis. - -```sql -INSERT INTO "Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") -VALUES ('calendar-cache-serve', false, 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', false, NULL, NOW(), NOW(), NULL) -ON CONFLICT (slug) DO NOTHING; -``` - -### 2. calendar-subscription-cache +### 1. calendar-subscription-cache Enables calendar cache recording and usage through calendars. This flag should be managed individually by teams. ```sql @@ -47,7 +38,7 @@ VALUES ('calendar-subscription-cache', false, 'Allow calendar cache to be record ON CONFLICT (slug) DO NOTHING; ``` -### 3. calendar-subscription-sync +### 2. calendar-subscription-sync Enables calendar sync globally for all users regardless of team or organization. ```sql @@ -61,11 +52,6 @@ ON CONFLICT (slug) DO NOTHING; To enable calendar cache features for specific users, add entries to the `UserFeatures` table: ```sql --- Enable calendar-cache-serve for user ID 123 -INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") -VALUES (123, 'calendar-cache-serve', NOW(), 'admin', NOW()) -ON CONFLICT ("userId", "featureId") DO NOTHING; - -- Enable calendar-subscription-cache for user ID 123 INSERT INTO "UserFeatures" ("userId", "featureId", "assignedAt", "assignedBy", "updatedAt") VALUES (123, 'calendar-subscription-cache', NOW(), 'admin', NOW()) @@ -82,11 +68,6 @@ ON CONFLICT ("userId", "featureId") DO NOTHING; To enable calendar cache features for specific teams, add entries to the `TeamFeatures` table: ```sql --- Enable calendar-cache-serve for team ID 456 -INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") -VALUES (456, 'calendar-cache-serve', NOW(), 'admin', NOW()) -ON CONFLICT ("teamId", "featureId") DO NOTHING; - -- Enable calendar-subscription-cache for team ID 456 INSERT INTO "TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") VALUES (456, 'calendar-subscription-cache', NOW(), 'admin', NOW()) @@ -131,6 +112,4 @@ The calendar cache and sync system consists of several key components: - **Cache Layer**: Optimized storage for frequently accessed calendar data For detailed technical implementation, see: -- [Calendar Cache Documentation](../calendar-cache/CalendarCache.md) -- Database migrations in `packages/prisma/migrations/` - +- Database migrations in `packages/prisma/migrations/` From fdbf1709dd1406cfac72714cd5eb21b4710e5088 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 24 Sep 2025 09:28:03 -0300 Subject: [PATCH 34/49] Fix test to available calendars --- .../lib/cache/__tests__/CalendarCacheEventService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts index 8caa82b93b4dc2..4dd1535e790c8b 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -337,10 +337,10 @@ describe("CalendarCacheEventService", () => { describe("isCalendarTypeSupported", () => { test("should return true for supported calendar types", () => { expect(CalendarCacheEventService.isCalendarTypeSupported("google_calendar")).toBe(true); - expect(CalendarCacheEventService.isCalendarTypeSupported("office365_calendar")).toBe(true); }); test("should return false for unsupported calendar types", () => { + expect(CalendarCacheEventService.isCalendarTypeSupported("office365_calendar")).toBe(true); expect(CalendarCacheEventService.isCalendarTypeSupported("outlook_calendar")).toBe(false); expect(CalendarCacheEventService.isCalendarTypeSupported("apple_calendar")).toBe(false); expect(CalendarCacheEventService.isCalendarTypeSupported("unknown_calendar")).toBe(false); From 43e892d0b5f5867c58fbde583458712940d62edf Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 24 Sep 2025 10:00:06 -0300 Subject: [PATCH 35/49] Fix test to available calendars --- .../lib/cache/__tests__/CalendarCacheEventService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts index 4dd1535e790c8b..12488d0b38f3f3 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -340,7 +340,7 @@ describe("CalendarCacheEventService", () => { }); test("should return false for unsupported calendar types", () => { - expect(CalendarCacheEventService.isCalendarTypeSupported("office365_calendar")).toBe(true); + expect(CalendarCacheEventService.isCalendarTypeSupported("office365_calendar")).toBe(false); expect(CalendarCacheEventService.isCalendarTypeSupported("outlook_calendar")).toBe(false); expect(CalendarCacheEventService.isCalendarTypeSupported("apple_calendar")).toBe(false); expect(CalendarCacheEventService.isCalendarTypeSupported("unknown_calendar")).toBe(false); From 5238b0d140681f4d3823214b24e7a23ce774a0dd Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 24 Sep 2025 18:00:35 -0300 Subject: [PATCH 36/49] add migration and sync boilerplate --- .../api/cron/calendar-subscriptions/route.ts | 6 +++- .../calendar-subscription/[provider]/route.ts | 6 +++- .../lib/CalendarSubscriptionService.ts | 4 ++- .../lib/sync/CalendarSyncService.ts | 36 ++++++++++++++++++- .../migration.sql | 8 +++++ 5 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index 8e2d09eaa5c9c8..9de6a7353a584d 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -8,6 +8,7 @@ import { CalendarCacheEventService } from "@calcom/features/calendar-subscriptio import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; @@ -26,7 +27,10 @@ async function getHandler(request: NextRequest) { } // instantiate dependencies - const calendarSyncService = new CalendarSyncService(); + const bookingRepository = new BookingRepository(prisma); + const calendarSyncService = new CalendarSyncService({ + bookingRepository, + }); const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); const calendarCacheEventService = new CalendarCacheEventService({ calendarCacheEventRepository, diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 3a69a0ce4bf976..95b1ea1b223508 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -11,6 +11,7 @@ import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/ import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; @@ -42,7 +43,10 @@ async function postHandler(request: NextRequest, context: { params: Promise { if (e.status === "cancelled") { @@ -52,7 +60,20 @@ export class CalendarSyncService { */ async cancelBooking(event: CalendarSubscriptionEventItem) { log.debug("cancelBooking", { event }); - // TODO implement (reference needed) + log.debug("rescheduleBooking", { event }); + const [bookingUid] = event.iCalUID?.split("@") ?? [undefined]; + if (!bookingUid) { + log.debug("Unable to sync, booking not found"); + return; + } + + const booking = await this.deps.bookingRepository.findBookingByUidWithEventType({ bookingUid }); + if (!booking) { + log.debug("Unable to sync, booking not found"); + return; + } + + // todo handle cancel booking } /** @@ -61,5 +82,18 @@ export class CalendarSyncService { */ async rescheduleBooking(event: CalendarSubscriptionEventItem) { log.debug("rescheduleBooking", { event }); + const [bookingUid] = event.iCalUID?.split("@") ?? [undefined]; + if (!bookingUid) { + log.debug("Unable to sync, booking not found"); + return; + } + + const booking = await this.deps.bookingRepository.findBookingByUidWithEventType({ bookingUid }); + if (!booking) { + log.debug("Unable to sync, booking not found"); + return; + } + + // todo handle update booking } } diff --git a/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql b/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql new file mode 100644 index 00000000000000..7d25c4bc614384 --- /dev/null +++ b/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql @@ -0,0 +1,8 @@ +-- FeatureFlags +INSERT INTO "Feature" (slug, enabled, description, "type") +VALUES ('calendar-subscription-sync', false, 'Enable calendar subscription syncronization', 'OPERATIONAL') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO "Feature" (slug, enabled, description, "type") +VALUES ('calendar-subscription-cache', false, 'Enable calendar subscription cache', 'OPERATIONAL') +ON CONFLICT (slug) DO NOTHING; From 153a85e194a530511b3f6dd80ca594108a30020e Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 25 Sep 2025 08:21:41 -0300 Subject: [PATCH 37/49] fix typo --- .../20250924205500_calendar_subscription_features/migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql b/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql index 7d25c4bc614384..1b9f330e71ad2e 100644 --- a/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql +++ b/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql @@ -1,6 +1,6 @@ -- FeatureFlags INSERT INTO "Feature" (slug, enabled, description, "type") -VALUES ('calendar-subscription-sync', false, 'Enable calendar subscription syncronization', 'OPERATIONAL') +VALUES ('calendar-subscription-sync', false, 'Enable calendar subscription synchronization', 'OPERATIONAL') ON CONFLICT (slug) DO NOTHING; INSERT INTO "Feature" (slug, enabled, description, "type") From f707d1e5f43e4c9395c72571ef10ed94fc65ac04 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 25 Sep 2025 08:34:21 -0300 Subject: [PATCH 38/49] remove double log --- .../calendar-subscription/lib/sync/CalendarSyncService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts index 4fc6df55f7aa7d..0f4e425e993429 100644 --- a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -60,7 +60,6 @@ export class CalendarSyncService { */ async cancelBooking(event: CalendarSubscriptionEventItem) { log.debug("cancelBooking", { event }); - log.debug("rescheduleBooking", { event }); const [bookingUid] = event.iCalUID?.split("@") ?? [undefined]; if (!bookingUid) { log.debug("Unable to sync, booking not found"); From a251399f20a3d8cf9d95c02b84124bc112155311 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 25 Sep 2025 08:36:32 -0300 Subject: [PATCH 39/49] sync boilerplate --- packages/lib/server/repository/booking.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 67adbf554c9e12..67e18e8d367823 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -387,6 +387,17 @@ export class BookingRepository { }); } + async findBookingByUidWithEventType({ bookingUid }: { bookingUid: string }) { + return await this.prismaClient.booking.findUnique({ + where: { + uid: bookingUid, + }, + include: { + eventType: true, + }, + }); + } + async findByIdIncludeUserAndAttendees(bookingId: number) { return await this.prismaClient.booking.findUnique({ where: { @@ -725,13 +736,9 @@ export class BookingRepository { }, }, }, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true destinationCalendar: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true payment: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true references: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true workflowReminders: true, }, }); @@ -873,13 +880,9 @@ export class BookingRepository { status: filterForUnconfirmed ? BookingStatus.PENDING : BookingStatus.ACCEPTED, }, include: { - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true attendees: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true references: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true user: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true payment: true, }, }); From 87d4e0d21aee4e45d3110873b862d352f6e0b86c Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 25 Sep 2025 10:13:49 -0300 Subject: [PATCH 40/49] remove console.log --- .../calendar-subscription/lib/CalendarSubscriptionService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index ea2c90fabb4a66..ce2e5ccdc92cbd 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -165,8 +165,6 @@ export class CalendarSubscriptionService { throw err; } - console.log(JSON.stringify(events)); - if (!events?.items?.length) { log.debug("No events fetched", { channelId: selectedCalendar.channelId }); return; From cf10b3383f9f3ba171fa07eb3158c837dc4cc046 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 25 Sep 2025 10:14:59 -0300 Subject: [PATCH 41/49] only subscribe for google calendar --- .../features/calendar-subscription/adapters/AdaptersFactory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts index 7cddf1fc768853..6880234ae0ac59 100644 --- a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -38,7 +38,7 @@ export class DefaultAdapterFactory implements AdapterFactory { * @returns */ getProviders(): CalendarSubscriptionProvider[] { - const providers: CalendarSubscriptionProvider[] = ["google_calendar", "office365_calendar"]; + const providers: CalendarSubscriptionProvider[] = ["google_calendar"]; return providers; } } From 81ffd2426fa01bc396bcdba48580360486e65dab Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 26 Sep 2025 10:54:42 -0300 Subject: [PATCH 42/49] adjust for 3 months fetch --- .../adapters/GoogleCalendarSubscription.adapter.ts | 11 ++++++----- .../adapters/__tests__/AdaptersFactory.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts index 80f709528956a0..3186392620e365 100644 --- a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -2,6 +2,7 @@ import type { calendar_v3 } from "@googleapis/calendar"; import { v4 as uuid } from "uuid"; import { CalendarAuth } from "@calcom/app-store/googlecalendar/lib/CalendarAuth"; +import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; @@ -54,7 +55,6 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP credential: CalendarCredential ): Promise { log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId }); - selectedCalendar; const MONTH_IN_SECONDS = 60 * 60 * 24 * 30; @@ -117,11 +117,12 @@ export class GoogleCalendarSubscriptionAdapter implements ICalendarSubscriptionP }; if (!syncToken) { - // first sync or unsync (30 days) - const DAYS_30_IN_MS = 30 * 24 * 60 * 60 * 1000; - const now = new Date(); + const now = dayjs(); + // first sync or unsync (3 months) + const threeMonths = now.add(3, "month"); + const timeMinISO = now.toISOString(); - const timeMaxISO = new Date(now.getTime() + DAYS_30_IN_MS).toISOString(); + const timeMaxISO = threeMonths.toISOString(); params.timeMin = timeMinISO; params.timeMax = timeMaxISO; } else { diff --git a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts index 0d37211ed7705c..ff75976946c723 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts @@ -42,13 +42,13 @@ describe("DefaultAdapterFactory", () => { test("should return all available providers", () => { const providers = factory.getProviders(); - expect(providers).toEqual(["google_calendar", "office365_calendar"]); + expect(providers).toEqual(["google_calendar"]); }); test("should return array with correct length", () => { const providers = factory.getProviders(); - expect(providers).toHaveLength(2); + expect(providers).toHaveLength(1); }); }); }); From 0b887e14be22e8db733426f703f4e8bcdb6aca72 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 26 Sep 2025 18:02:38 -0300 Subject: [PATCH 43/49] only subscribe for teams that have feature enabled --- .../lib/CalendarSubscriptionService.ts | 2 +- .../repository/SelectedCalendarRepository.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index ce2e5ccdc92cbd..293f0c84dc58ec 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -194,11 +194,11 @@ export class CalendarSubscriptionService { * Subscribe periodically to new calendars */ async checkForNewSubscriptions() { - log.debug("checkForNewSubscriptions"); const rows = await this.deps.selectedCalendarRepository.findNextSubscriptionBatch({ take: 100, integrations: this.deps.adapterFactory.getProviders(), }); + log.debug("checkForNewSubscriptions", { count: rows.length }); await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); } diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts index 69d203f62789de..5eb6c585b19d49 100644 --- a/packages/lib/server/repository/SelectedCalendarRepository.ts +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -27,6 +27,24 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { where: { integration: { in: integrations }, OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: new Date() } }], + // initially we will run subscription only for teams that have + // the feature flags enabled and it should be removed later + user: { + teams: { + some: { + team: { + features: { + some: { + OR: [ + { featureId: "calendar-subscription-cache" }, + { featureId: "calendar-subscription-sync" }, + ], + }, + }, + }, + }, + }, + }, }, take, }); From 893d1e5102b6b3a0a123f763922cc68003aa548d Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 26 Sep 2025 18:08:28 -0300 Subject: [PATCH 44/49] adjust tests --- .../SelectedCalendarRepository.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts index 123bebf462113b..032454a7f189d7 100644 --- a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts +++ b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts @@ -130,6 +130,22 @@ describe("SelectedCalendarRepository", () => { where: { integration: { in: ["google_calendar", "office365_calendar"] }, OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], + user: { + teams: { + some: { + team: { + features: { + some: { + OR: [ + { featureId: "calendar-subscription-cache" }, + { featureId: "calendar-subscription-sync" }, + ], + }, + }, + }, + }, + }, + }, }, take: 10, }); @@ -150,6 +166,22 @@ describe("SelectedCalendarRepository", () => { where: { integration: { in: [] }, OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], + user: { + teams: { + some: { + team: { + features: { + some: { + OR: [ + { featureId: "calendar-subscription-cache" }, + { featureId: "calendar-subscription-sync" }, + ], + }, + }, + }, + }, + }, + }, }, take: 5, }); From 1e430fe48261f810605134897be6e8b1e35be3f9 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sat, 27 Sep 2025 08:07:10 -0300 Subject: [PATCH 45/49] chore: safe increment error count Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../calendar-subscription/lib/CalendarSubscriptionService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 293f0c84dc58ec..e1408259f962e2 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -160,7 +160,7 @@ export class CalendarSubscriptionService { log.debug("Error fetching events", { channelId: selectedCalendar.channelId, err }); await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, { syncErrorAt: new Date(), - syncErrorCount: (selectedCalendar.syncErrorCount || 0) + 1, + syncErrorCount: { increment: 1 }, }); throw err; } From 9d564e45fac6e62bb23c7e784c41475eca4b5832 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sat, 27 Sep 2025 19:22:28 -0300 Subject: [PATCH 46/49] Fix test --- .../lib/__tests__/CalendarSubscriptionService.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts index f1ad831461f94e..539ca71d23e5d0 100644 --- a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -333,7 +333,9 @@ describe("CalendarSubscriptionService", () => { expect(mockSelectedCalendarRepository.updateSyncStatus).toHaveBeenCalledWith(mockSelectedCalendar.id, { syncErrorAt: expect.any(Date), - syncErrorCount: 1, + syncErrorCount: { + increment: 1, + }, }); }); From 9dbf30a1c159b71a39bf4c39e08c6ba2881dbaf9 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 29 Sep 2025 13:14:20 +0100 Subject: [PATCH 47/49] Rename findAllBySelectedCalendarIds to findAllBySelectedCalendarIdsbetween + minor nits --- .../calendar-subscriptions-cleanup/route.ts | 5 +++-- .../CalendarCacheEventRepository.interface.ts | 2 +- .../lib/cache/CalendarCacheWrapper.ts | 17 +++++++---------- .../CalendarCacheEventRepository.test.ts | 4 ++-- .../__tests__/CalendarCacheEventService.test.ts | 2 +- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts index 428582bd3eecd4..9ef8a80127f4d9 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts @@ -17,7 +17,7 @@ async function getHandler(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: "Forbiden" }, { status: 403 }); + return NextResponse.json({ message: "Forbidden" }, { status: 403 }); } // instantiate dependencies @@ -29,8 +29,9 @@ async function getHandler(request: NextRequest) { try { await calendarCacheEventService.cleanupStaleCache(); return NextResponse.json({ ok: true }); - } catch (e) { + } catch (e: unknown) { const message = e instanceof Error ? e.message : "Unknown error"; + console.error(`[calendar-subscriptions-cleanup] ${message}:`, e); return NextResponse.json({ message }, { status: 500 }); } } diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts index e93a753fc8347c..3ba7b760dc0b75 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts @@ -33,7 +33,7 @@ export interface ICalendarCacheEventRepository { * @param start * @param end */ - findAllBySelectedCalendarIds( + findAllBySelectedCalendarIdsBetween( selectedCalendarId: string[], start: Date, end: Date diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts index 225834850d7883..be7ea8dcf2ed39 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -55,16 +55,14 @@ export class CalendarCacheWrapper implements Calendar { * @param dateFrom * @param dateTo * @param selectedCalendars - * @param _shouldServeCache - * @param _fallbackToPrimary - * @returns + * @param shouldServeCache */ async getAvailability( dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[], - shouldServeCache?: boolean, - _fallbackToPrimary?: boolean + shouldServeCache?: boolean + // _fallbackToPrimary?: boolean ): Promise { if (!shouldServeCache) { return this.deps.originalCalendar.getAvailability(dateFrom, dateTo, selectedCalendars); @@ -75,7 +73,7 @@ export class CalendarCacheWrapper implements Calendar { if (!selectedCalendarIds.length) { return Promise.resolve([]); } - return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIds( + return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( selectedCalendarIds, new Date(dateFrom), new Date(dateTo) @@ -88,18 +86,17 @@ export class CalendarCacheWrapper implements Calendar { * @param dateFrom * @param dateTo * @param selectedCalendars - * @param _fallbackToPrimary * @returns */ async getAvailabilityWithTimeZones?( dateFrom: string, dateTo: string, - selectedCalendars: IntegrationCalendar[], - _fallbackToPrimary?: boolean + selectedCalendars: IntegrationCalendar[] + // _fallbackToPrimary?: boolean ): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> { log.debug("getAvailabilityWithTimeZones from cache", { dateFrom, dateTo, selectedCalendars }); const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); - const result = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIds( + const result = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween( selectedCalendarIds, new Date(dateFrom), new Date(dateTo) diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts index dce36e3b83c6b3..88f1450dbec7bf 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts @@ -44,7 +44,7 @@ describe("CalendarCacheEventRepository", () => { vi.clearAllMocks(); }); - describe("findAllBySelectedCalendarIds", () => { + describe("findAllBySelectedCalendarIdsBetween", () => { test("should find events by selected calendar IDs and date range", async () => { const mockEvents = [ { @@ -56,7 +56,7 @@ describe("CalendarCacheEventRepository", () => { vi.mocked(mockPrismaClient.calendarCacheEvent.findMany).mockResolvedValue(mockEvents); - const result = await repository.findAllBySelectedCalendarIds( + const result = await repository.findAllBySelectedCalendarIdsBetween( ["calendar-1", "calendar-2"], new Date("2023-12-01T00:00:00Z"), new Date("2023-12-01T23:59:59Z") diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts index 12488d0b38f3f3..88b93174c259f1 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -49,7 +49,7 @@ describe("CalendarCacheEventService", () => { deleteMany: vi.fn().mockResolvedValue(undefined), deleteAllBySelectedCalendarId: vi.fn().mockResolvedValue(undefined), deleteStale: vi.fn().mockResolvedValue(undefined), - findAllBySelectedCalendarIds: vi.fn().mockResolvedValue([]), + findAllBySelectedCalendarIdsBetween: vi.fn().mockResolvedValue([]), }; service = new CalendarCacheEventService({ From a05a3c6c41488e7ecffc79528fcac678e6da6bd1 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Mon, 29 Sep 2025 10:14:53 -0300 Subject: [PATCH 48/49] Fix tests --- .../calendar-subscriptions-cleanup/__tests__/route.test.ts | 4 ++-- .../lib/cache/CalendarCacheEventRepository.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts index f297d360736516..e760f4e5a3b5c3 100644 --- a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts @@ -86,7 +86,7 @@ describe("/api/cron/calendar-subscriptions-cleanup", () => { expect(response.status).toBe(403); const body = await response.json(); - expect(body.message).toBe("Forbiden"); + expect(body.message).toBe("Forbidden"); }); test("should return 403 when invalid API key is provided", async () => { @@ -98,7 +98,7 @@ describe("/api/cron/calendar-subscriptions-cleanup", () => { expect(response.status).toBe(403); const body = await response.json(); - expect(body.message).toBe("Forbiden"); + expect(body.message).toBe("Forbidden"); }); test("should accept CRON_API_KEY in authorization header", async () => { diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts index 2e3faaddd1cac8..bece5e1420762f 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -5,7 +5,7 @@ import type { CalendarCacheEvent } from "@calcom/prisma/client"; export class CalendarCacheEventRepository implements ICalendarCacheEventRepository { constructor(private prismaClient: PrismaClient) {} - async findAllBySelectedCalendarIds( + async findAllBySelectedCalendarIdsBetween( selectedCalendarId: string[], start: Date, end: Date From 618497852b5e61d21cf5462aea2c3761cca6bf42 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 29 Sep 2025 14:38:27 +0100 Subject: [PATCH 49/49] Add more declaritive code for increased clarity --- .../calendar-subscription/[provider]/route.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 95b1ea1b223508..3cd6fc53ba9976 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -17,6 +17,17 @@ import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderF const log = logger.getSubLogger({ prefix: ["calendar-webhook"] }); +function extractAndValidateProviderFromParams(params: Params): CalendarSubscriptionProvider | null { + if (!("provider" in params)) { + return null; + } + const { provider } = params; + if (provider === "google_calendar" || provider === "office365_calendar") { + return provider; + } + return null; +} + /** * Handles incoming POST requests for calendar webhooks. * It processes the webhook based on the calendar provider specified in the URL. @@ -28,16 +39,9 @@ const log = logger.getSubLogger({ prefix: ["calendar-webhook"] }); * @param {Promise} context.params - A promise that resolves to the route parameters. * @returns {Promise} - A promise that resolves to the response object. */ - -function isCalendarSubscriptionProvider(provider: string): provider is CalendarSubscriptionProvider { - return provider === "google_calendar" || provider === "office365_calendar"; -} - -async function postHandler(request: NextRequest, context: { params: Promise }) { - // extract and validate provider - const providerFromParams = (await context.params).provider as string[][0]; - - if (!isCalendarSubscriptionProvider(providerFromParams)) { +async function postHandler(request: NextRequest, ctx: { params: Promise }) { + const providerFromParams = extractAndValidateProviderFromParams(await ctx.params); + if (!providerFromParams) { return NextResponse.json({ message: "Unsupported provider" }, { status: 400 }); }