feat: Calendar Cache and Sync (#23876)

* feat: calendar cache and sync - wip

* Add env.example

* refactor on CalendarCacheEventService

* remove test console.log

* Fix type checks errors

* chore: remove pt comment

* add route.ts

* chore: fix tests

* Improve cache impl

* chore: update recurring event id

* chore: small improvements

* calendar cache improvements

* Fix remove dynamic imports

* Add cleanup stale cache

* Fix tests

* add event update

* type fixes

* 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 <volnei.munhoz@gmail.com>

* 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 <volnei.munhoz@gmail.com>

* 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 <volnei.munhoz@gmail.com>

* Fix tests

* Fix tests

* type fix

* Fix coderabbit comments

* Fix types

* Fix test

* Update apps/web/app/api/cron/calendar-subscriptions/route.ts

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* Fixes by first review

* 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 <volnei.munhoz@gmail.com>

* only google-calendar for now

* 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 <volnei.munhoz@gmail.com>

* 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 <volnei.munhoz@gmail.com>

* fix docs

* Fix test to available calendars

* Fix test to available calendars

* add migration and sync boilerplate

* fix typo

* remove double log

* sync boilerplate

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Volnei Munhoz
2025-09-29 14:26:14 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Alex van Andel Keith Williams
parent 855a02091f
commit e6b2116a2b
42 changed files with 4723 additions and 42 deletions
+6
View File
@@ -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=
@@ -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);
@@ -0,0 +1,213 @@
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<string, string>;
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/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: {},
}));
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 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(403);
const body = await response.json();
expect(body.message).toBe("Forbidden");
});
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(403);
const body = await response.json();
expect(body.message).toBe("Forbidden");
});
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),
});
});
});
});
@@ -0,0 +1,39 @@
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 { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
/**
* Cron webhook
* Cleanup stale calendar cache
*
* @param request
* @returns
*/
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: "Forbidden" }, { status: 403 });
}
// instantiate dependencies
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
const calendarCacheEventService = new CalendarCacheEventService({
calendarCacheEventRepository,
});
try {
await calendarCacheEventService.cleanupStaleCache();
return NextResponse.json({ ok: true });
} 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 });
}
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -0,0 +1,229 @@
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<string, string>;
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 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(403);
const body = await response.json();
expect(body.message).toBe("Forbiden");
}, 10000);
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(403);
const body = await response.json();
expect(body.message).toBe("Forbiden");
});
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 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(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),
});
});
});
});
@@ -0,0 +1,66 @@
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 { 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 { 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";
/**
* Cron webhook
* Checks for new calendar subscriptions (rollouts)
*
* @param request
* @returns
*/
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 });
}
// instantiate dependencies
const bookingRepository = new BookingRepository(prisma);
const calendarSyncService = new CalendarSyncService({
bookingRepository,
});
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
const calendarCacheEventService = new CalendarCacheEventService({
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(),
calendarSubscriptionService.isSyncEnabled(),
]);
if (!isCacheEnabled && !isSyncEnabled) {
return NextResponse.json({ ok: true });
}
try {
await calendarSubscriptionService.checkForNewSubscriptions();
return NextResponse.json({ ok: true });
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ message }, { status: 500 });
}
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -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<string, string>;
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),
});
});
});
});
@@ -0,0 +1,87 @@
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 { 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";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
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.
* 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<Params>} context.params - A promise that resolves to the route parameters.
* @returns {Promise<NextResponse>} - A promise that resolves to the response object.
*/
async function postHandler(request: NextRequest, ctx: { params: Promise<Params> }) {
const providerFromParams = extractAndValidateProviderFromParams(await ctx.params);
if (!providerFromParams) {
return NextResponse.json({ message: "Unsupported provider" }, { status: 400 });
}
try {
// instantiate dependencies
const bookingRepository = new BookingRepository(prisma);
const calendarSyncService = new CalendarSyncService({
bookingRepository,
});
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
const calendarCacheEventService = new CalendarCacheEventService({
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(),
calendarSubscriptionService.isSyncEnabled(),
]);
if (!isCacheEnabled && !isSyncEnabled) {
log.debug("No cache or sync enabled");
return NextResponse.json({ message: "No cache or sync enabled" }, { status: 200 });
}
await calendarSubscriptionService.processWebhook(providerFromParams, request);
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);
Regular → Executable
+2 -2
View File
@@ -23,9 +23,9 @@ try {
"*/5 * * * * *",
async function () {
await Promise.allSettled([
fetchCron("/calendar-cache/cron"),
fetchCron("/cron/calendar-subscriptions"),
// fetchCron("/calendar-cache/cron"),
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
//
fetchCron("/tasks/cron"),
]);
},
+8
View File
@@ -4,6 +4,14 @@
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
},
{
"path": "/api/cron/calendar-subscriptions",
"schedule": "*/5 * * * *"
},
{
"path": "/api/cron/calendar-subscriptions-cleanup",
"schedule": "0 3 * * *"
},
{
"path": "/api/cron/queuedFormResponseCleanup",
"schedule": "0 */12 * * *"
+41 -18
View File
@@ -1,28 +1,17 @@
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 { 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<Calendar | null> => {
@@ -53,5 +42,39 @@ export const getCalendar = async (
return null;
}
// check if Calendar Cache is supported and enabled
if (CalendarCacheEventService.isCalendarTypeSupported(calendarType)) {
log.debug(
`Using regular CalendarService for credential ${credential.id} (not Google or Office365 Calendar)`
);
const featuresRepository = new FeaturesRepository(prisma);
const [isCalendarSubscriptionCacheEnabled, isCalendarSubscriptionCacheEnabledForUser] = await Promise.all(
[
featuresRepository.checkIfFeatureIsEnabledGlobally(
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
),
featuresRepository.checkIfUserHasFeature(
credential.userId as number,
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
),
]
);
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);
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);
};
@@ -3,6 +3,7 @@ import { vi } from "vitest";
const featuresRepositoryModuleMock = {
FeaturesRepository: vi.fn().mockImplementation(() => ({
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
})),
};
@@ -0,0 +1,115 @@
# 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
**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
- **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-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;
```
### 2. 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-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-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;
```
## Architecture
The calendar cache and sync system consists of several key components:
### 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
### 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)
### 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
For detailed technical implementation, see:
- Database migrations in `packages/prisma/migrations/`
@@ -0,0 +1,44 @@
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";
export interface AdapterFactory {
get(provider: CalendarSubscriptionProvider): ICalendarSubscriptionPort;
getProviders(): CalendarSubscriptionProvider[];
}
/**
* Default adapter factory
*/
export class DefaultAdapterFactory implements AdapterFactory {
private singletons = {
google_calendar: new GoogleCalendarSubscriptionAdapter(),
office365_calendar: new Office365CalendarSubscriptionAdapter(),
} as const;
/**
* Returns the adapter for the given provider
*
* @param provider
* @returns
*/
get(provider: CalendarSubscriptionProvider): ICalendarSubscriptionPort {
const adapter = this.singletons[provider];
if (!adapter) {
throw new Error(`No adapter found for provider ${provider}`);
}
return adapter;
}
/**
* Returns all available providers
*
* @returns
*/
getProviders(): CalendarSubscriptionProvider[] {
const providers: CalendarSubscriptionProvider[] = ["google_calendar"];
return providers;
}
}
@@ -0,0 +1,199 @@
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";
import type {
ICalendarSubscriptionPort,
CalendarSubscriptionResult,
CalendarSubscriptionEvent,
CalendarSubscriptionEventItem,
CalendarCredential,
} 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 || process.env.NEXT_PUBLIC_WEBAPP_URL
}/api/webhooks/calendar-subscription/google_calendar`;
async validate(request: Request): Promise<boolean> {
const token = request?.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");
return false;
}
return true;
}
async extractChannelId(request: Request): Promise<string | null> {
const channelId = request?.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<CalendarSubscriptionResult> {
log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId });
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<void> {
log.debug("Attempt to unsubscribe from Google Calendar", { externalId: selectedCalendar.externalId });
const client = await this.getClient(credential);
await client.channels
.stop({
requestBody: {
id: selectedCalendar.channelId as string,
resourceId: selectedCalendar.channelResourceId as string,
},
})
.catch((err) => {
log.error("Error unsubscribing from Google Calendar", err);
throw err;
});
}
async fetchEvents(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionEvent> {
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) {
const now = dayjs();
// first sync or unsync (3 months)
const threeMonths = now.add(3, "month");
const timeMinISO = now.toISOString();
const timeMaxISO = threeMonths.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(params);
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[] {
return events
.filter((event) => typeof event.id === "string" && !!event.id)
.map((event) => {
// empty or opaque is busy
const busy = !event.transparency || event.transparency === "opaque";
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 as string,
iCalUID: event.iCalUID ?? null,
start,
end,
busy,
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
? new Date(event.originalStartTime.date)
: null,
createdAt: event.created ? new Date(event.created) : null,
updatedAt: event.updated ? new Date(event.updated) : null,
};
});
}
private async getClient(credential: CalendarCredential) {
const auth = new CalendarAuth(credential);
return await auth.getClient();
}
}
@@ -0,0 +1,262 @@
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type {
CalendarSubscriptionEvent,
ICalendarSubscriptionPort,
CalendarSubscriptionResult,
CalendarCredential,
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;
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(request: Request): Promise<boolean> {
// validate handshake
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
const 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");
return false;
}
if (clientState !== this.webhookToken) {
log.warn("Invalid clientState");
return false;
}
return true;
}
async extractChannelId(request: Request): Promise<string | null> {
let id: string | null = null;
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");
}
return id;
}
async subscribe(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionResult> {
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<MicrosoftGraphSubscriptionRes>(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<void> {
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<CalendarSubscriptionEvent> {
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<MicrosoftGraphEventsResponse>(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<MicrosoftGraphEventsResponse>(
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 ?? null,
start,
end,
busy,
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 ?? false,
timeZone: e.start?.timeZone ?? null,
recurringEventId: null,
originalStartDate: null,
createdAt: null,
updatedAt: null,
};
})
.filter(({ id }) => !!id);
}
private async getGraphClient(credential: CalendarCredential): Promise<GraphClient> {
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<T = unknown>(
client: GraphClient,
method: HttpMethod,
endpoint: string,
data?: unknown
): Promise<T> {
const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
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;
}
}
@@ -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,
}));
@@ -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"]);
});
test("should return array with correct length", () => {
const providers = factory.getProviders();
expect(providers).toHaveLength(1);
});
});
});
@@ -0,0 +1,497 @@
import "../__mocks__/CalendarAuth";
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";
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,
type: null,
teamId: null,
} as unknown as CredentialForCalendarServiceWithEmail;
describe("GoogleCalendarSubscriptionAdapter", () => {
let adapter: GoogleCalendarSubscriptionAdapter;
let mockClient: {
events: {
watch: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
};
channels: {
stop: ReturnType<typeof vi.fn>;
};
};
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 unknown 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 unknown 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 unknown 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 unknown 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 unknown 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 unknown 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: {
id: "test-channel-id",
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: false,
timeZone: "UTC",
recurringEventId: null,
originalStartDate: null,
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");
});
});
});
@@ -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);
});
});
@@ -0,0 +1,83 @@
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";
export type CalendarSubscriptionResult = {
provider: CalendarSubscriptionProvider;
id?: string | null;
resourceId?: string | null;
resourceUri?: string | null;
expiration?: Date | null;
};
export type CalendarSubscriptionEventItem = {
id: string;
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;
};
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: Request): Promise<boolean>;
/**
* Extracts channel ID from a webhook request
* @param request
*/
extractChannelId(context: Request): Promise<string | null>;
/**
* Subscribes to a calendar
* @param selectedCalendar
*/
subscribe(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionResult>;
/**
* Unsubscribes from a calendar
* @param selectedCalendar
*/
unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise<void>;
/**
* Pulls events from a calendar
* @param selectedCalendar
*/
fetchEvents(
selectedCalendar: SelectedCalendar,
credential: CredentialForCalendarService
): Promise<CalendarSubscriptionEvent>;
}
@@ -0,0 +1,255 @@
import type {
AdapterFactory,
CalendarSubscriptionProvider,
} from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
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";
import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface";
import type { SelectedCalendar } from "@calcom/prisma/client";
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;
selectedCalendarRepository: ISelectedCalendarRepository;
featuresRepository: FeaturesRepository;
calendarCacheEventService: CalendarCacheEventService;
calendarSyncService: CalendarSyncService;
}
) {}
/**
* Subscribe to a calendar
*/
async subscribe(selectedCalendarId: string): Promise<void> {
log.debug("subscribe", { selectedCalendarId });
const selectedCalendar = await this.deps.selectedCalendarRepository.findByIdWithCredentials(
selectedCalendarId
);
if (!selectedCalendar?.credentialId) {
log.debug("Selected calendar not found", { selectedCalendarId });
return;
}
const credential = await this.getCredential(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.updateSubscription(selectedCalendarId, {
channelId: res?.id,
channelResourceId: res?.resourceId,
channelResourceUri: res?.resourceUri,
channelKind: res?.provider,
channelExpiration: res?.expiration,
syncSubscribedAt: new Date(),
});
// initial event loading
await this.processEvents(selectedCalendar);
}
/**
* Unsubscribe from a calendar
*/
async unsubscribe(selectedCalendarId: string): Promise<void> {
log.debug("unsubscribe", { selectedCalendarId });
const selectedCalendar = await this.deps.selectedCalendarRepository.findByIdWithCredentials(
selectedCalendarId
);
if (!selectedCalendar?.credentialId) return;
const credential = await this.getCredential(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.updateSubscription(selectedCalendarId, {
syncSubscribedAt: null,
}),
]);
// cleanup cache after unsubscribe
if (await this.isCacheEnabled()) {
log.debug("cleanupCache", { selectedCalendarId });
await this.deps.calendarCacheEventService.cleanupCache(selectedCalendar);
}
}
/**
* Process webhook
*/
async processWebhook(provider: CalendarSubscriptionProvider, request: Request) {
log.debug("processWebhook", { provider });
const calendarSubscriptionAdapter = this.deps.adapterFactory.get(provider);
const isValid = await calendarSubscriptionAdapter.validate(request);
if (!isValid) throw new Error("Invalid webhook request");
const channelId = await calendarSubscriptionAdapter.extractChannelId(request);
if (!channelId) throw new Error("Missing channel ID in webhook");
log.debug("Processing webhook", { channelId });
const selectedCalendar = await this.deps.selectedCalendarRepository.findByChannelId(channelId);
// it maybe caused by an old subscription being triggered
if (!selectedCalendar) return null;
// 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<void> {
const calendarSubscriptionAdapter = this.deps.adapterFactory.get(
selectedCalendar.integration as CalendarSubscriptionProvider
);
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.info("Cache and sync are globally disabled", { channelId: selectedCalendar.channelId });
return;
}
log.debug("Processing events", { channelId: selectedCalendar.channelId });
const credential = await this.getCredential(selectedCalendar.credentialId);
if (!credential) return;
let events: CalendarSubscriptionEvent | null = null;
try {
events = await calendarSubscriptionAdapter.fetchEvents(selectedCalendar, credential);
} catch (err) {
log.debug("Error fetching events", { channelId: selectedCalendar.channelId, err });
await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, {
syncErrorAt: new Date(),
syncErrorCount: { increment: 1 },
});
throw err;
}
if (!events?.items?.length) {
log.debug("No events fetched", { channelId: selectedCalendar.channelId });
return;
}
log.debug("Processing events", { channelId: selectedCalendar.channelId, count: events.items.length });
await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, {
syncToken: events.syncToken || selectedCalendar.syncToken,
syncedAt: new Date(),
syncErrorAt: null,
syncErrorCount: 0,
});
// it requires both global and team/user feature cache enabled
if (cacheEnabled && cacheEnabledForUser) {
log.debug("Caching events", { count: events.items.length });
await this.deps.calendarCacheEventService.handleEvents(selectedCalendar, events.items);
}
if (syncEnabled) {
log.debug("Syncing events", { count: events.items.length });
await this.deps.calendarSyncService.handleEvents(selectedCalendar, events.items);
}
}
/**
* Subscribe periodically to new calendars
*/
async 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)));
}
/**
* Check if cache is enabled
* @returns true if cache is enabled
*/
async isCacheEnabled(): Promise<boolean> {
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<boolean> {
return this.deps.featuresRepository.checkIfUserHasFeature(
userId,
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
);
}
/**
* Check if sync is enabled
* @returns true if sync is enabled
*/
async isSyncEnabled(): Promise<boolean> {
return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_SYNC_FEATURE
);
}
/**
* Get credential with delegation if available
*/
private async getCredential(credentialId: number): Promise<CalendarCredential | null> {
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,
};
}
}
@@ -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,
}));
@@ -0,0 +1,420 @@
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<typeof vi.fn>;
unsubscribe: ReturnType<typeof vi.fn>;
validate: ReturnType<typeof vi.fn>;
extractChannelId: ReturnType<typeof vi.fn>;
fetchEvents: ReturnType<typeof vi.fn>;
};
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 = {
findByIdWithCredentials: 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.findByIdWithCredentials).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.findByIdWithCredentials.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.findByIdWithCredentials.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.findByIdWithCredentials).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.findByIdWithCredentials.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: {
increment: 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"
);
});
});
});
@@ -0,0 +1,41 @@
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<CalendarCacheEvent>[]): Promise<unknown>;
/**
* Deletes many events
* @param events the list of events to delete
*/
deleteMany(events: Pick<CalendarCacheEvent, "externalId" | "selectedCalendarId">[]): Promise<unknown>;
/**
* Deletes all events for a selected calendar
* @param selectedCalendarId the id of the calendar
*/
deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise<unknown>;
/**
* Deletes all stale events
*/
deleteStale(): Promise<unknown>;
/**
*
* @param selectedCalendarId
* @param start
* @param end
*/
findAllBySelectedCalendarIdsBetween(
selectedCalendarId: string[],
start: Date,
end: Date
): Promise<Pick<CalendarCacheEvent, "start" | "end" | "timeZone">[]>;
}
@@ -0,0 +1,92 @@
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";
export class CalendarCacheEventRepository implements ICalendarCacheEventRepository {
constructor(private prismaClient: PrismaClient) {}
async findAllBySelectedCalendarIdsBetween(
selectedCalendarId: string[],
start: Date,
end: Date
): Promise<Pick<CalendarCacheEvent, "start" | "end" | "timeZone">[]> {
return this.prismaClient.calendarCacheEvent.findMany({
where: {
selectedCalendarId: {
in: selectedCalendarId,
},
AND: [{ start: { lt: end } }, { end: { gt: start } }],
},
select: {
start: true,
end: true,
timeZone: true,
},
});
}
async upsertMany(events: CalendarCacheEvent[]) {
if (events.length === 0) {
return;
}
// lack of upsertMany in prisma
return Promise.allSettled(
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<CalendarCacheEvent, "externalId" | "selectedCalendarId">[]) {
// Only delete events with externalId and selectedCalendarId
const conditions = events.filter((c) => c.externalId && c.selectedCalendarId);
if (conditions.length === 0) {
return;
}
return this.prismaClient.calendarCacheEvent.deleteMany({
where: {
OR: conditions,
},
});
}
async deleteAllBySelectedCalendarId(selectedCalendarId: string) {
if (!selectedCalendarId) {
return;
}
return this.prismaClient.calendarCacheEvent.deleteMany({
where: {
selectedCalendarId,
},
});
}
async deleteStale() {
return this.prismaClient.calendarCacheEvent.deleteMany({
where: {
end: {
lte: new Date(),
},
},
});
}
}
@@ -0,0 +1,99 @@
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 { CalendarCacheEvent, SelectedCalendar } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["CalendarCacheEventService"] });
/**
* Service to handle calendar cache
*/
export class CalendarCacheEventService {
constructor(
private deps: {
calendarCacheEventRepository: ICalendarCacheEventRepository;
}
) {}
/**
* Handle calendar events from provider and update the cache
*
* @param selectedCalendar
* @param calendarSubscriptionEvents
*/
async handleEvents(
selectedCalendar: SelectedCalendar,
calendarSubscriptionEvents: CalendarSubscriptionEventItem[]
): Promise<void> {
log.debug("handleEvents", { count: calendarSubscriptionEvents.length });
const toUpsert: Partial<CalendarCacheEvent>[] = [];
const toDelete: Pick<CalendarCacheEvent, "externalId" | "selectedCalendarId">[] = [];
for (const event of calendarSubscriptionEvents) {
// not storing free or cancelled events
if (event.busy && event.status !== "cancelled") {
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,
recurringEventId: event.recurringEventId,
externalEtag: event.etag || "",
externalCreatedAt: event.createdAt,
externalUpdatedAt: event.updatedAt,
});
} else {
toDelete.push({
selectedCalendarId: selectedCalendar.id,
externalId: event.id,
});
}
}
log.info("handleEvents: applying changes to the database", {
received: calendarSubscriptionEvents.length,
toUpsert: toUpsert.length,
toDelete: toDelete.length,
});
await Promise.all([
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<void> {
log.debug("cleanupCache", { selectedCalendarId: selectedCalendar.id });
await this.deps.calendarCacheEventRepository.deleteAllBySelectedCalendarId(selectedCalendar.id);
}
/**
* Removes stale events from the cache
*/
async cleanupStaleCache(): Promise<void> {
log.debug("cleanupStaleCache");
await this.deps.calendarCacheEventRepository.deleteStale();
}
/**
* Checks if the app is supported
*
* @param type
* @returns
*/
static isCalendarTypeSupported(type: string | null): boolean {
if (!type) return false;
// return ["google_calendar", "office365_calendar"].includes(type);
return ["google_calendar"].includes(type);
}
}
@@ -0,0 +1,132 @@
import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface";
import logger from "@calcom/lib/logger";
import type {
Calendar,
CalendarEvent,
CalendarServiceEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
SelectedCalendarEventTypeIds,
} from "@calcom/types/Calendar";
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<NewCalendarEventType> {
return this.deps.originalCalendar.createEvent(event, credentialId, externalCalendarId);
}
updateEvent(
uid: string,
event: CalendarServiceEvent,
externalCalendarId?: string | null
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
return this.deps.originalCalendar.updateEvent(uid, event, externalCalendarId);
}
deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise<unknown> {
return this.deps.originalCalendar.deleteEvent(uid, event, externalCalendarId);
}
/**
* Override this method to use cache
*
* @param dateFrom
* @param dateTo
* @param selectedCalendars
* @param shouldServeCache
*/
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[],
shouldServeCache?: boolean
// _fallbackToPrimary?: boolean
): Promise<EventBusyDate[]> {
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) {
return Promise.resolve([]);
}
return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
selectedCalendarIds,
new Date(dateFrom),
new Date(dateTo)
);
}
/**
* Override this method to use cache
*
* @param dateFrom
* @param dateTo
* @param selectedCalendars
* @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.findAllBySelectedCalendarIdsBetween(
selectedCalendarIds,
new Date(dateFrom),
new Date(dateTo)
);
return result.map(({ start, end, timeZone }) => ({ start, end, timeZone: timeZone || "UTC" }));
}
fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise<unknown> {
return this.deps.originalCalendar.fetchAvailabilityAndSetCache?.(selectedCalendars) || Promise.resolve();
}
listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return this.deps.originalCalendar.listCalendars(event);
}
testDelegationCredentialSetup?(): Promise<boolean> {
return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false);
}
watchCalendar?(options: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}): Promise<unknown> {
return this.deps.originalCalendar.watchCalendar?.(options) || Promise.resolve();
}
unwatchCalendar?(options: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}): Promise<void> {
return this.deps.originalCalendar.unwatchCalendar?.(options) || Promise.resolve();
}
}
@@ -0,0 +1,249 @@
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",
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",
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("findAllBySelectedCalendarIdsBetween", () => {
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",
},
] as unknown as CalendarCacheEvent[];
vi.mocked(mockPrismaClient.calendarCacheEvent.findMany).mockResolvedValue(mockEvents);
const result = await repository.findAllBySelectedCalendarIdsBetween(
["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"],
},
AND: [
{ start: { lt: new Date("2023-12-01T23:59:59Z") } },
{ end: { gt: new Date("2023-12-01T00:00:00Z") } },
],
},
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),
},
},
});
});
});
});
@@ -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),
findAllBySelectedCalendarIdsBetween: 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);
});
test("should return false for unsupported calendar types", () => {
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);
});
test("should return false for null or undefined", () => {
expect(CalendarCacheEventService.isCalendarTypeSupported(null)).toBe(false);
expect(CalendarCacheEventService.isCalendarTypeSupported(undefined)).toBe(false);
});
});
});
@@ -0,0 +1,98 @@
import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface";
import logger from "@calcom/lib/logger";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { SelectedCalendar } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["CalendarSyncService"] });
/**
* Service to handle synchronization of calendar events.
*/
export class CalendarSyncService {
constructor(
private deps: {
bookingRepository: BookingRepository;
}
) {}
/**
* 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 });
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
}
/**
* Reschedule a booking
* @param event
*/
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
}
}
+2
View File
@@ -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<keyof AppFlags, boolean>;
@@ -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) {}
+2
View File
@@ -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) {
@@ -0,0 +1,62 @@
import type { Prisma, SelectedCalendar } from "@calcom/prisma/client";
export interface ISelectedCalendarRepository {
/**
* Find selected calendar by id with credentials
*
* @param id
*/
findByIdWithCredentials(id: string): Promise<SelectedCalendar | null>;
/**
* Find selected calendar by channel id
*
* @param channelId
*/
findByChannelId(channelId: string): Promise<SelectedCalendar | null>;
/**
* 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<SelectedCalendar[]>;
/**
* Update status of sync for selected calendar
*
* @param id
* @param data
*/
updateSyncStatus(
id: string,
data: Pick<
Prisma.SelectedCalendarUpdateInput,
"syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount"
>
): Promise<SelectedCalendar>;
/**
* Update subscription status for selected calendar
*/
updateSubscription(
id: string,
data: Pick<
Prisma.SelectedCalendarUpdateInput,
| "channelId"
| "channelResourceId"
| "channelResourceUri"
| "channelKind"
| "channelExpiration"
| "syncSubscribedAt"
>
): Promise<SelectedCalendar>;
}
@@ -0,0 +1,83 @@
import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
export class SelectedCalendarRepository implements ISelectedCalendarRepository {
constructor(private prismaClient: PrismaClient) {}
async findByIdWithCredentials(id: string) {
return this.prismaClient.selectedCalendar.findUnique({
where: { id },
include: {
credential: {
select: {
delegationCredential: true,
},
},
},
});
}
async findByChannelId(channelId: string) {
return this.prismaClient.selectedCalendar.findFirst({ where: { channelId } });
}
async findNextSubscriptionBatch({ take, integrations }: { take: number; integrations: string[] }) {
return this.prismaClient.selectedCalendar.findMany({
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,
});
}
async updateSyncStatus(
id: string,
data: Pick<
Prisma.SelectedCalendarUpdateInput,
"syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount"
>
) {
return this.prismaClient.selectedCalendar.update({
where: { id },
data,
});
}
async updateSubscription(
id: string,
data: Pick<
Prisma.SelectedCalendarUpdateInput,
| "channelId"
| "channelResourceId"
| "channelResourceUri"
| "channelKind"
| "channelExpiration"
| "syncSubscribedAt"
>
) {
return this.prismaClient.selectedCalendar.update({
where: { id },
data,
});
}
}
@@ -0,0 +1,315 @@
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("findByIdWithCredentials", () => {
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.findByIdWithCredentials("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.findByIdWithCredentials("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: { lte: expect.any(Date) } }],
user: {
teams: {
some: {
team: {
features: {
some: {
OR: [
{ featureId: "calendar-subscription-cache" },
{ featureId: "calendar-subscription-sync" },
],
},
},
},
},
},
},
},
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: { lte: expect.any(Date) } }],
user: {
teams: {
some: {
team: {
features: {
some: {
OR: [
{ featureId: "calendar-subscription-cache" },
{ featureId: "calendar-subscription-sync" },
],
},
},
},
},
},
},
},
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);
});
});
});
+11 -8
View File
@@ -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,
},
});
@@ -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;
@@ -0,0 +1,8 @@
-- FeatureFlags
INSERT INTO "Feature" (slug, enabled, description, "type")
VALUES ('calendar-subscription-sync', false, 'Enable calendar subscription synchronization', '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;
+76 -13
View File
@@ -904,21 +904,44 @@ 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
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?
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?
@@ -935,6 +958,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}
@@ -2571,3 +2596,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])
}
+3
View File
@@ -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",
@@ -280,6 +281,8 @@
"ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE",
"INTERCOM_API_TOKEN",
"NEXT_PUBLIC_INTERCOM_APP_ID",
"MICROSOFT_WEBHOOK_TOKEN",
"MICROSOFT_WEBHOOK_URL",
"_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS",
"ENTERPRISE_SLUGS",
"PLATFORM_ENTERPRISE_SLUGS"