diff --git a/.env.example b/.env.example index dd1fa49e19e944..e156f609a4466c 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN= # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. GOOGLE_WEBHOOK_URL= +# Token to verify incoming webhooks from Microsoft Calendar +MICROSOFT_WEBHOOK_TOKEN= + +# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. +MICROSOFT_WEBHOOK_URL= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= diff --git a/apps/api/v1/test/lib/selected-calendars/_post.test.ts b/apps/api/v1/test/lib/selected-calendars/_post.test.ts index 1eac8010b68226..45d65084d150b4 100644 --- a/apps/api/v1/test/lib/selected-calendars/_post.test.ts +++ b/apps/api/v1/test/lib/selected-calendars/_post.test.ts @@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http"; import { describe, expect, test } from "vitest"; import { HttpError } from "@calcom/lib/http-error"; +import type { User } from "@calcom/prisma/client"; import handler from "../../../pages/api/selected-calendars/_post"; @@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => { prismaMock.user.findFirstOrThrow.mockResolvedValue({ id: 444444, - } as any); + } as User); prismaMock.selectedCalendar.create.mockResolvedValue({ credentialId: 1, @@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => { unwatchAttempts: 0, createdAt: new Date(), updatedAt: new Date(), + channelId: null, + channelKind: null, + channelResourceId: null, + channelResourceUri: null, + channelExpiration: null, + syncSubscribedAt: null, + syncToken: null, + syncedAt: null, + syncErrorAt: null, + syncErrorCount: null, }); await handler(req, res); @@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => { unwatchAttempts: 0, createdAt: new Date(), updatedAt: new Date(), + channelId: null, + channelKind: null, + channelResourceId: null, + channelResourceUri: null, + channelExpiration: null, + syncSubscribedAt: null, + syncToken: null, + syncedAt: null, + syncErrorAt: null, + syncErrorCount: null, }); await handler(req, res); diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts new file mode 100644 index 00000000000000..e760f4e5a3b5c3 --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts @@ -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; + + 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), + }); + }); + }); +}); diff --git a/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts new file mode 100644 index 00000000000000..9ef8a80127f4d9 --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts @@ -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); diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts new file mode 100644 index 00000000000000..cefcb61c54371b --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts @@ -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; + + 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), + }); + }); + }); +}); diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts new file mode 100644 index 00000000000000..9de6a7353a584d --- /dev/null +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -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); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts new file mode 100644 index 00000000000000..da7a6c64d0faad --- /dev/null +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts @@ -0,0 +1,315 @@ +import { NextRequest } from "next/server"; +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; + +vi.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + url: string; + method: string; + nextUrl: { searchParams: URLSearchParams }; + private _headers: Map; + + constructor(url: string, options: { method?: string } = {}) { + this.url = url; + this.method = options.method || "POST"; + this._headers = new Map(); + this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") }; + } + + headers = { + get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null, + set: (key: string, value: string): void => { + this._headers.set(key.toLowerCase(), value); + }, + has: (key: string): boolean => this._headers.has(key.toLowerCase()), + }; + }, + NextResponse: { + json: vi.fn((body, init) => ({ + json: vi.fn().mockResolvedValue(body), + status: init?.status || 200, + })), + }, +})); + +vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"); +vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"); +vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"); +vi.mock("@calcom/prisma", () => ({ + prisma: {}, +})); + +const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService); + +describe("/api/webhooks/calendar-subscription/[provider]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + describe("Provider validation", () => { + test("should accept google_calendar provider", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }, 10000); + + test("should accept office365_calendar provider", async () => { + const request = new NextRequest( + "http://localhost/api/webhooks/calendar-subscription/office365_calendar", + { + method: "POST", + } + ); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "office365_calendar" }), + }); + + expect(response.status).toBe(200); + expect(mockProcessWebhook).toHaveBeenCalledWith("office365_calendar", request); + }); + + test("should reject unsupported provider", async () => { + const request = new NextRequest( + "http://localhost/api/webhooks/calendar-subscription/unsupported_calendar", + { + method: "POST", + } + ); + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "unsupported_calendar" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Unsupported provider"); + }); + }); + + describe("Feature flag handling", () => { + test("should return 200 when neither cache nor sync is enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(false); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn(); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("No cache or sync enabled"); + expect(mockProcessWebhook).not.toHaveBeenCalled(); + }); + + test("should process webhook when cache is enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("Webhook processed"); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }); + + test("should process webhook when sync is enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(false); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("Webhook processed"); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }); + + test("should process webhook when both cache and sync are enabled", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(true); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.message).toBe("Webhook processed"); + expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); + }); + }); + + describe("Error handling", () => { + test("should handle webhook processing errors gracefully", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockError = new Error("Webhook validation failed"); + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockRejectedValue(mockError); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Webhook validation failed"); + }); + + test("should handle non-Error exceptions", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockRejectedValue("String error"); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Unknown error"); + }); + + test("should handle feature flag check errors", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockError = new Error("Feature flag service unavailable"); + const mockIsCacheEnabled = vi.fn().mockRejectedValue(mockError); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Feature flag service unavailable"); + }); + }); + + describe("Service instantiation", () => { + test("should instantiate all services with correct dependencies", async () => { + const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", { + method: "POST", + }); + + const mockIsCacheEnabled = vi.fn().mockResolvedValue(true); + const mockIsSyncEnabled = vi.fn().mockResolvedValue(false); + const mockProcessWebhook = vi.fn().mockResolvedValue(undefined); + + mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled; + mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled; + mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook; + + const { POST } = await import("../route"); + await POST(request, { + params: Promise.resolve({ provider: "google_calendar" }), + }); + + expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({ + adapterFactory: expect.any(Object), + selectedCalendarRepository: expect.any(Object), + featuresRepository: expect.any(Object), + calendarSyncService: expect.any(Object), + calendarCacheEventService: expect.any(Object), + }); + }); + }); +}); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts new file mode 100644 index 00000000000000..3cd6fc53ba9976 --- /dev/null +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -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} context.params - A promise that resolves to the route parameters. + * @returns {Promise} - A promise that resolves to the response object. + */ +async function postHandler(request: NextRequest, ctx: { params: Promise }) { + 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); diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts old mode 100644 new mode 100755 index 693ac7aafa099d..808b83a70828db --- a/apps/web/cron-tester.ts +++ b/apps/web/cron-tester.ts @@ -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"), ]); }, diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 26b3a7d3901172..8bbd2b3daca6cf 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -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 * * *" diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 1c3d3d3e6ab111..8c9d1afb031c35 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -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 => { @@ -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); }; diff --git a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts index d84d6f80d350dd..5cdde7917680fc 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/features.repository.ts @@ -3,6 +3,7 @@ import { vi } from "vitest"; const featuresRepositoryModuleMock = { FeaturesRepository: vi.fn().mockImplementation(() => ({ checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + checkIfUserHasFeature: vi.fn().mockResolvedValue(true), })), }; diff --git a/packages/features/calendar-subscription/README.md b/packages/features/calendar-subscription/README.md new file mode 100644 index 00000000000000..6840291c72f01d --- /dev/null +++ b/packages/features/calendar-subscription/README.md @@ -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/` diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts new file mode 100644 index 00000000000000..6880234ae0ac59 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -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; + } +} diff --git a/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts new file mode 100644 index 00000000000000..3186392620e365 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts new file mode 100644 index 00000000000000..2338ad5d0b98c4 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -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 { + // 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 { + 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 { + if (!this.webhookUrl || !this.webhookToken) { + throw new Error("Webhook config missing (MICROSOFT_WEBHOOK_URL/TOKEN)"); + } + + const expirationDateTime = new Date(Date.now() + this.subscriptionTtlMs).toISOString(); + + const body: MicrosoftGraphSubscriptionReq = { + resource: `me/calendars/${selectedCalendar.externalId}/events`, + changeType: "created,updated,deleted", + notificationUrl: this.webhookUrl, + expirationDateTime, + clientState: this.webhookToken, + }; + + const client = await this.getGraphClient(credential); + const res = await this.request(client, "POST", "/subscriptions", body); + + return { + provider: "office365_calendar", + id: res.id, + resourceId: res.resource, + resourceUri: `${this.baseUrl}/${res.resource}`, + expiration: new Date(res.expirationDateTime), + }; + } + + async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise { + const subId = selectedCalendar.channelResourceId; + if (!subId) return; + + const client = await this.getGraphClient(credential); + await this.request(client, "DELETE", `/subscriptions/${subId}`); + } + + async fetchEvents( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise { + const client = await this.getGraphClient(credential); + + let deltaLink = selectedCalendar.syncToken ?? null; + const items: MicrosoftGraphEvent[] = []; + + if (deltaLink) { + const path = this.stripBase(deltaLink); + const r = await this.request(client, "GET", path); + items.push(...r.value); + deltaLink = r["@odata.deltaLink"] ?? deltaLink; + } else { + let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`; + while (next) { + const r: MicrosoftGraphEventsResponse = await this.request( + client, + "GET", + next + ); + items.push(...r.value); + deltaLink = r["@odata.deltaLink"] ?? deltaLink; + next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null; + } + } + + return { + provider: "office365_calendar", + syncToken: deltaLink, + items: this.parseEvents(items), + }; + } + + private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] { + return events + .map((e) => { + const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof"; + const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date(); + const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date(); + + return { + id: e.id, + iCalUID: e.iCalUId ?? 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 { + const accessToken = credential.delegatedTo?.serviceAccountKey?.private_key ?? (credential.key as string); + if (!accessToken) throw new Error("Missing Microsoft access token"); + return { accessToken }; + } + + private stripBase(urlOrPath: string): string { + return urlOrPath.startsWith("http") ? urlOrPath.replace(this.baseUrl, "") : urlOrPath; + } + + private async request( + client: GraphClient, + method: HttpMethod, + endpoint: string, + data?: unknown + ): Promise { + const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`; + const headers: Record = { + Authorization: `Bearer ${client.accessToken}`, + "Content-Type": "application/json", + }; + + const init: RequestInit = { method, headers }; + if (data && (method === "POST" || method === "PUT" || method === "PATCH")) { + init.body = JSON.stringify(data); + } + + const res = await fetch(url, init); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + log.error("Graph API error", { method, endpoint: this.stripBase(url), status: res.status, text }); + throw new Error(`Graph ${res.status} ${res.statusText}`); + } + + if (method === "DELETE" || res.status === 204) return {} as T; + + return (await res.json()) as T; + } +} diff --git a/packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts b/packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts new file mode 100644 index 00000000000000..6cc339506de044 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__mocks__/CalendarAuth.ts @@ -0,0 +1,17 @@ +import { vi } from "vitest"; + +export const CalendarAuth = vi.fn().mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue({ + events: { + watch: vi.fn(), + list: vi.fn(), + }, + channels: { + stop: vi.fn(), + }, + }), +})); + +vi.doMock("@calcom/app-store/googlecalendar/lib/CalendarAuth", () => ({ + CalendarAuth, +})); diff --git a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts new file mode 100644 index 00000000000000..ff75976946c723 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, beforeEach } from "vitest"; + +import { DefaultAdapterFactory } from "../AdaptersFactory"; +import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter"; +import { Office365CalendarSubscriptionAdapter } from "../Office365CalendarSubscription.adapter"; + +describe("DefaultAdapterFactory", () => { + let factory: DefaultAdapterFactory; + + beforeEach(() => { + factory = new DefaultAdapterFactory(); + }); + + describe("get", () => { + test("should return GoogleCalendarSubscriptionAdapter for google_calendar", () => { + const adapter = factory.get("google_calendar"); + + expect(adapter).toBeInstanceOf(GoogleCalendarSubscriptionAdapter); + }); + + test("should return Office365CalendarSubscriptionAdapter for office365_calendar", () => { + const adapter = factory.get("office365_calendar"); + + expect(adapter).toBeInstanceOf(Office365CalendarSubscriptionAdapter); + }); + + test("should return the same instance for multiple calls (singleton)", () => { + const adapter1 = factory.get("google_calendar"); + const adapter2 = factory.get("google_calendar"); + + expect(adapter1).toBe(adapter2); + }); + + test("should throw error for unsupported provider", () => { + expect(() => { + factory.get("unsupported_calendar" as never); + }).toThrow("No adapter found for provider unsupported_calendar"); + }); + }); + + describe("getProviders", () => { + test("should return all available providers", () => { + const providers = factory.getProviders(); + + expect(providers).toEqual(["google_calendar"]); + }); + + test("should return array with correct length", () => { + const providers = factory.getProviders(); + + expect(providers).toHaveLength(1); + }); + }); +}); diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts new file mode 100644 index 00000000000000..9ff5fb664d496c --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -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; + list: ReturnType; + }; + channels: { + stop: ReturnType; + }; + }; + + beforeEach(async () => { + process.env.GOOGLE_WEBHOOK_TOKEN = "test-webhook-token"; + process.env.GOOGLE_WEBHOOK_URL = "https://example.com"; + + mockClient = { + events: { + watch: vi.fn(), + list: vi.fn(), + }, + channels: { + stop: vi.fn(), + }, + }; + + const { CalendarAuth } = await import("../__mocks__/CalendarAuth"); + vi.mocked(CalendarAuth).mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue(mockClient), + })); + + adapter = new GoogleCalendarSubscriptionAdapter(); + vi.clearAllMocks(); + }); + + describe("validate", () => { + test("should validate webhook with correct token", async () => { + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue("test-webhook-token"), + }, + } as 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"); + }); + }); +}); diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts new file mode 100644 index 00000000000000..e109056a452870 --- /dev/null +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from "vitest"; + +import type { SelectedCalendar } from "@calcom/prisma/client"; + +const _mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "office365_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +const _mockCredential = { + id: 1, + key: { access_token: "test-token" }, + user: { email: "test@example.com" }, + delegatedTo: null, +}; + +describe("Office365CalendarSubscriptionAdapter", () => { + test("should be a placeholder test", () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts new file mode 100644 index 00000000000000..e2a1dec7da7b0a --- /dev/null +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts @@ -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; + + /** + * Extracts channel ID from a webhook request + * @param request + */ + extractChannelId(context: Request): Promise; + + /** + * Subscribes to a calendar + * @param selectedCalendar + */ + subscribe( + selectedCalendar: SelectedCalendar, + credential: CalendarCredential + ): Promise; + + /** + * Unsubscribes from a calendar + * @param selectedCalendar + */ + unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise; + + /** + * Pulls events from a calendar + * @param selectedCalendar + */ + fetchEvents( + selectedCalendar: SelectedCalendar, + credential: CredentialForCalendarService + ): Promise; +} diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts new file mode 100644 index 00000000000000..e1408259f962e2 --- /dev/null +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -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 { + 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 { + 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 { + 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 { + return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE + ); + } + + /** + * Check if cache is enabled for user + * @returns true if cache is enabled + */ + async isCacheEnabledForUser(userId: number): Promise { + return this.deps.featuresRepository.checkIfUserHasFeature( + userId, + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE + ); + } + + /** + * Check if sync is enabled + * @returns true if sync is enabled + */ + async isSyncEnabled(): Promise { + return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_SYNC_FEATURE + ); + } + + /** + * Get credential with delegation if available + */ + private async getCredential(credentialId: number): Promise { + const credential = await getCredentialForCalendarCache({ credentialId }); + if (!credential) return null; + return { + ...credential, + delegatedTo: credential.delegatedTo?.serviceAccountKey?.client_email + ? { + serviceAccountKey: { + client_email: credential.delegatedTo.serviceAccountKey.client_email, + client_id: credential.delegatedTo.serviceAccountKey.client_id, + private_key: credential.delegatedTo.serviceAccountKey.private_key, + }, + } + : null, + }; + } +} diff --git a/packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts b/packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts new file mode 100644 index 00000000000000..b115540e09aaf6 --- /dev/null +++ b/packages/features/calendar-subscription/lib/__mocks__/delegationCredential.ts @@ -0,0 +1,12 @@ +import { vi } from "vitest"; + +export const getCredentialForCalendarCache = vi.fn().mockResolvedValue({ + id: 1, + key: { access_token: "test-token" }, + user: { email: "test@example.com" }, + delegatedTo: null, +}); + +vi.doMock("@calcom/lib/delegationCredential/server", () => ({ + getCredentialForCalendarCache, +})); diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts new file mode 100644 index 00000000000000..539ca71d23e5d0 --- /dev/null +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -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; + unsubscribe: ReturnType; + validate: ReturnType; + extractChannelId: ReturnType; + fetchEvents: ReturnType; + }; + + beforeEach(async () => { + mockAdapter = { + subscribe: vi.fn().mockResolvedValue(mockSubscriptionResult), + unsubscribe: vi.fn().mockResolvedValue(undefined), + validate: vi.fn().mockResolvedValue(true), + extractChannelId: vi.fn().mockResolvedValue("test-channel-id"), + fetchEvents: vi.fn().mockResolvedValue(mockEvents), + }; + + mockAdapterFactory = { + get: vi.fn().mockReturnValue(mockAdapter), + getProviders: vi.fn().mockReturnValue(["google_calendar", "office365_calendar"]), + }; + + mockSelectedCalendarRepository = { + 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" + ); + }); + }); +}); diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts new file mode 100644 index 00000000000000..3ba7b760dc0b75 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts @@ -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[]): Promise; + + /** + * Deletes many events + * @param events the list of events to delete + */ + deleteMany(events: Pick[]): Promise; + + /** + * Deletes all events for a selected calendar + * @param selectedCalendarId the id of the calendar + */ + deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise; + + /** + * Deletes all stale events + */ + deleteStale(): Promise; + + /** + * + * @param selectedCalendarId + * @param start + * @param end + */ + findAllBySelectedCalendarIdsBetween( + selectedCalendarId: string[], + start: Date, + end: Date + ): Promise[]>; +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts new file mode 100644 index 00000000000000..bece5e1420762f --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts @@ -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[]> { + 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[]) { + // 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(), + }, + }, + }); + } +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts new file mode 100644 index 00000000000000..906103c58758fb --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -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 { + log.debug("handleEvents", { count: calendarSubscriptionEvents.length }); + const toUpsert: Partial[] = []; + const toDelete: Pick[] = []; + + 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 { + log.debug("cleanupCache", { selectedCalendarId: selectedCalendar.id }); + await this.deps.calendarCacheEventRepository.deleteAllBySelectedCalendarId(selectedCalendar.id); + } + + /** + * Removes stale events from the cache + */ + async cleanupStaleCache(): Promise { + log.debug("cleanupStaleCache"); + await this.deps.calendarCacheEventRepository.deleteStale(); + } + + /** + * Checks if the app is supported + * + * @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); + } +} diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts new file mode 100644 index 00000000000000..be7ea8dcf2ed39 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheWrapper.ts @@ -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 { + return this.deps.originalCalendar.createEvent(event, credentialId, externalCalendarId); + } + + updateEvent( + uid: string, + event: CalendarServiceEvent, + externalCalendarId?: string | null + ): Promise { + return this.deps.originalCalendar.updateEvent(uid, event, externalCalendarId); + } + + deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise { + return this.deps.originalCalendar.deleteEvent(uid, event, externalCalendarId); + } + + /** + * Override this method to use cache + * + * @param dateFrom + * @param dateTo + * @param selectedCalendars + * @param shouldServeCache + */ + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[], + shouldServeCache?: boolean + // _fallbackToPrimary?: boolean + ): Promise { + if (!shouldServeCache) { + return this.deps.originalCalendar.getAvailability(dateFrom, dateTo, selectedCalendars); + } + + log.debug("getAvailability from cache", { dateFrom, dateTo, selectedCalendars }); + const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id)); + if (!selectedCalendarIds.length) { + 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 { + return this.deps.originalCalendar.fetchAvailabilityAndSetCache?.(selectedCalendars) || Promise.resolve(); + } + + listCalendars(event?: CalendarEvent): Promise { + return this.deps.originalCalendar.listCalendars(event); + } + + testDelegationCredentialSetup?(): Promise { + return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false); + } + + watchCalendar?(options: { + calendarId: string; + eventTypeIds: SelectedCalendarEventTypeIds; + }): Promise { + return this.deps.originalCalendar.watchCalendar?.(options) || Promise.resolve(); + } + + unwatchCalendar?(options: { + calendarId: string; + eventTypeIds: SelectedCalendarEventTypeIds; + }): Promise { + return this.deps.originalCalendar.unwatchCalendar?.(options) || Promise.resolve(); + } +} diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts new file mode 100644 index 00000000000000..88f1450dbec7bf --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventRepository.test.ts @@ -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), + }, + }, + }); + }); + }); +}); diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts new file mode 100644 index 00000000000000..88b93174c259f1 --- /dev/null +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -0,0 +1,354 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; +import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface"; +import type { SelectedCalendar } from "@calcom/prisma/client"; + +import { CalendarCacheEventService } from "../CalendarCacheEventService"; + +const mockSelectedCalendar: SelectedCalendar = { + id: "test-calendar-id", + userId: 1, + credentialId: 1, + integration: "google_calendar", + externalId: "test@example.com", + eventTypeId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + error: null, + lastErrorAt: null, + watchAttempts: 0, + maxAttempts: 3, + unwatchAttempts: 0, + createdAt: new Date(), + updatedAt: new Date(), + channelId: "test-channel-id", + channelKind: "web_hook", + channelResourceId: "test-resource-id", + channelResourceUri: "test-resource-uri", + channelExpiration: new Date(Date.now() + 86400000), + syncSubscribedAt: new Date(), + syncToken: "test-sync-token", + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, +}; + +describe("CalendarCacheEventService", () => { + let service: CalendarCacheEventService; + let mockRepository: ICalendarCacheEventRepository; + + beforeEach(() => { + mockRepository = { + upsertMany: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + deleteAllBySelectedCalendarId: vi.fn().mockResolvedValue(undefined), + deleteStale: vi.fn().mockResolvedValue(undefined), + 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); + }); + }); +}); diff --git a/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts new file mode 100644 index 00000000000000..0f4e425e993429 --- /dev/null +++ b/packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts @@ -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 + } +} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index eea362f9cfef4f..eb068d26361031 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -26,6 +26,8 @@ export type AppFlags = { "team-booking-page-cache": boolean; "cal-ai-voice-agents": boolean; "tiered-support-chat": boolean; + "calendar-subscription-cache": boolean; + "calendar-subscription-sync": boolean; }; export type TeamFeatures = Record; diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 2f1a1a6e514e9c..3781766f6558bd 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -2,6 +2,7 @@ import { captureException } from "@sentry/nextjs"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; + import type { AppFlags, TeamFeatures } from "./config"; import type { IFeaturesRepository } from "./features.repository.interface"; @@ -15,6 +16,7 @@ interface CacheOptions { * for users, teams, and global application features. */ export class FeaturesRepository implements IFeaturesRepository { + // eslint-disable-next-line @typescript-eslint/no-explicit-any private static featuresCache: { data: any[]; expiry: number } | null = null; constructor(private prismaClient: PrismaClient) {} diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 60dbb64026179a..a1789bfd823be2 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -25,6 +25,8 @@ const initialData: AppFlags = { "team-booking-page-cache": false, "cal-ai-voice-agents": false, "tiered-support-chat": false, + "calendar-subscription-cache": false, + "calendar-subscription-sync": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { diff --git a/packages/lib/server/repository/SelectedCalendarRepository.interface.ts b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts new file mode 100644 index 00000000000000..45939e99be5dc3 --- /dev/null +++ b/packages/lib/server/repository/SelectedCalendarRepository.interface.ts @@ -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; + + /** + * Find selected calendar by channel id + * + * @param channelId + */ + findByChannelId(channelId: string): Promise; + + /** + * Find next batch of selected calendars + * Will check if syncSubscribedAt is null or channelExpiration is greater than current date + * + * @param take the number of calendars to take + * @param integrations the list of integrations + */ + findNextSubscriptionBatch({ + take, + integrations, + }: { + take: number; + integrations?: string[]; + }): Promise; + + /** + * Update status of sync for selected calendar + * + * @param id + * @param data + */ + updateSyncStatus( + id: string, + data: Pick< + Prisma.SelectedCalendarUpdateInput, + "syncToken" | "syncedAt" | "syncErrorAt" | "syncErrorCount" + > + ): Promise; + + /** + * Update subscription status for selected calendar + */ + updateSubscription( + id: string, + data: Pick< + Prisma.SelectedCalendarUpdateInput, + | "channelId" + | "channelResourceId" + | "channelResourceUri" + | "channelKind" + | "channelExpiration" + | "syncSubscribedAt" + > + ): Promise; +} diff --git a/packages/lib/server/repository/SelectedCalendarRepository.ts b/packages/lib/server/repository/SelectedCalendarRepository.ts new file mode 100644 index 00000000000000..5eb6c585b19d49 --- /dev/null +++ b/packages/lib/server/repository/SelectedCalendarRepository.ts @@ -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, + }); + } +} diff --git a/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts new file mode 100644 index 00000000000000..032454a7f189d7 --- /dev/null +++ b/packages/lib/server/repository/__tests__/SelectedCalendarRepository.test.ts @@ -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); + }); + }); +}); diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 67adbf554c9e12..67e18e8d367823 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -387,6 +387,17 @@ export class BookingRepository { }); } + async findBookingByUidWithEventType({ bookingUid }: { bookingUid: string }) { + return await this.prismaClient.booking.findUnique({ + where: { + uid: bookingUid, + }, + include: { + eventType: true, + }, + }); + } + async findByIdIncludeUserAndAttendees(bookingId: number) { return await this.prismaClient.booking.findUnique({ where: { @@ -725,13 +736,9 @@ export class BookingRepository { }, }, }, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true destinationCalendar: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true payment: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true references: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true workflowReminders: true, }, }); @@ -873,13 +880,9 @@ export class BookingRepository { status: filterForUnconfirmed ? BookingStatus.PENDING : BookingStatus.ACCEPTED, }, include: { - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true attendees: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true references: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true user: true, - // eslint-disable-next-line @calcom/eslint/no-prisma-include-true payment: true, }, }); diff --git a/packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql b/packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql new file mode 100644 index 00000000000000..5b20badb070520 --- /dev/null +++ b/packages/prisma/migrations/20250101000000_add_calendar_cache_and_sync_fields/migration.sql @@ -0,0 +1,58 @@ +/* + Warnings: + + - Added calendar cache and sync fields to SelectedCalendar table + - Created CalendarCacheEvent table with CalendarCacheEventStatus enum + +*/ + +CREATE TYPE "CalendarCacheEventStatus" AS ENUM ('confirmed', 'tentative', 'cancelled'); + +-- AlterTable +ALTER TABLE "SelectedCalendar" ADD COLUMN "channelId" TEXT, +ADD COLUMN "channelKind" TEXT, +ADD COLUMN "channelResourceId" TEXT, +ADD COLUMN "channelResourceUri" TEXT, +ADD COLUMN "channelExpiration" TIMESTAMP(3), +ADD COLUMN "syncSubscribedAt" TIMESTAMP(3), +ADD COLUMN "syncToken" TEXT, +ADD COLUMN "syncedAt" TIMESTAMP(3), +ADD COLUMN "syncErrorAt" TIMESTAMP(3), +ADD COLUMN "syncErrorCount" INTEGER DEFAULT 0; + +CREATE TABLE "CalendarCacheEvent" ( + "id" TEXT NOT NULL, + "selectedCalendarId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "externalEtag" TEXT NOT NULL, + "iCalUID" TEXT, + "iCalSequence" INTEGER NOT NULL DEFAULT 0, + "summary" TEXT, + "description" TEXT, + "location" TEXT, + "start" TIMESTAMP(3) NOT NULL, + "end" TIMESTAMP(3) NOT NULL, + "isAllDay" BOOLEAN NOT NULL DEFAULT false, + "timeZone" TEXT, + "status" "CalendarCacheEventStatus" NOT NULL DEFAULT 'confirmed', + "recurringEventId" TEXT, + "originalStartTime" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "externalCreatedAt" TIMESTAMP(3), + "externalUpdatedAt" TIMESTAMP(3), + + CONSTRAINT "CalendarCacheEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CalendarCacheEvent_selectedCalendarId_externalId_key" ON "CalendarCacheEvent"("selectedCalendarId", "externalId"); + +-- CreateIndex +CREATE INDEX "CalendarCacheEvent_start_end_status_idx" ON "CalendarCacheEvent"("start", "end", "status"); + +-- CreateIndex +CREATE INDEX "CalendarCacheEvent_selectedCalendarId_iCalUID_idx" ON "CalendarCacheEvent"("selectedCalendarId", "iCalUID"); + +-- AddForeignKey +ALTER TABLE "CalendarCacheEvent" ADD CONSTRAINT "CalendarCacheEvent_selectedCalendarId_fkey" FOREIGN KEY ("selectedCalendarId") REFERENCES "SelectedCalendar"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql b/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql new file mode 100644 index 00000000000000..1b9f330e71ad2e --- /dev/null +++ b/packages/prisma/migrations/20250924205500_calendar_subscription_features/migration.sql @@ -0,0 +1,8 @@ +-- FeatureFlags +INSERT INTO "Feature" (slug, enabled, description, "type") +VALUES ('calendar-subscription-sync', false, 'Enable calendar subscription 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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 52d99614f09e57..0362019f6e9ddf 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) +} diff --git a/turbo.json b/turbo.json index 8d89b14c0a4842..33ad46ed11f61e 100644 --- a/turbo.json +++ b/turbo.json @@ -81,6 +81,7 @@ "GOOGLE_API_CREDENTIALS", "GOOGLE_LOGIN_ENABLED", "GOOGLE_WEBHOOK_TOKEN", + "GOOGLE_WEBHOOK_URL", "HEROKU_APP_NAME", "HUBSPOT_CLIENT_ID", "HUBSPOT_CLIENT_SECRET", @@ -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"