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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Alex van Andel
Keith Williams
parent
855a02091f
commit
e6b2116a2b
@@ -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
@@ -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"),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -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 * * *"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+199
@@ -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();
|
||||
}
|
||||
}
|
||||
+262
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+497
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+49
@@ -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,
|
||||
}));
|
||||
+420
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+41
@@ -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">[]>;
|
||||
}
|
||||
+92
@@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+99
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+249
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+354
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+58
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user