feat: Calendar Cache and Sync (#23876)
* feat: calendar cache and sync - wip * Add env.example * refactor on CalendarCacheEventService * remove test console.log * Fix type checks errors * chore: remove pt comment * add route.ts * chore: fix tests * Improve cache impl * chore: update recurring event id * chore: small improvements * calendar cache improvements * Fix remove dynamic imports * Add cleanup stale cache * Fix tests * add event update * type fixes * feat: add comprehensive tests for new calendar subscription API routes - Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests) - Add tests for /api/cron/calendar-subscriptions route (10 tests) - Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests) - Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync - All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation Tests cover: - Authentication scenarios (API key validation, Bearer tokens, query parameters) - Feature flag combinations (cache/sync enabled/disabled states) - Success and error handling (including non-Error exceptions) - Service instantiation with proper dependency injection - Provider validation for webhook endpoints Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * feat: add comprehensive tests for calendar subscription services, repositories, and adapters - Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing - Add unit tests for CalendarCacheEventService with cache operations and cleanup - Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations - Add unit tests for CalendarCacheEventRepository with CRUD operations - Add unit tests for SelectedCalendarRepository with calendar selection management - Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching - Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation - Add unit tests for AdaptersFactory with provider management and adapter creation - Fix lint issues by removing explicit 'any' type casting and unused variables - All tests follow Cal.com conventions using Vitest framework with proper mocking Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks - Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry - Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks - Ensure consistent test performance across different environments Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Fix tests * Fix tests * type fix * Fix coderabbit comments * Fix types * Fix test * Update apps/web/app/api/cron/calendar-subscriptions/route.ts Co-authored-by: Alex van Andel <me@alexvanandel.com> * Fixes by first review * feat: add database migrations for calendar cache and sync fields - Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values - Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount - Create CalendarCacheEvent table with foreign key to SelectedCalendar - Add necessary indexes and constraints for performance and data integrity Fixes database schema issues causing e2e test failures with 'column does not exist' errors. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * only google-calendar for now * docs: add Calendar Cache and Sync feature documentation - Add comprehensive feature overview and motivation - Document feature flags with SQL examples - Include SQL examples for enabling features for users and teams - Reference technical documentation files Addresses PR #23876 documentation requirements Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * docs: update calendar subscription README with comprehensive documentation - Undo incorrect changes to main README.md - Update packages/features/calendar-subscription/README.md with: - Feature overview and motivation - Environment variables section - Complete feature flags documentation with SQL examples - SQL examples for enabling features for users and teams - Detailed architecture documentation Addresses PR #23876 documentation requirements Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix docs * Fix test to available calendars * Fix test to available calendars * add migration and sync boilerplate * fix typo * remove double log * sync boilerplate --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
42 files changed
+4723
-42
No files matched your search
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import handler from "../../../pages/api/selected-calendars/_post";
|
||||
|
||||
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
|
||||
|
||||
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||
id: 444444,
|
||||
} as any);
|
||||
} as User);
|
||||
|
||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||
credentialId: 1,
|
||||
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "GET";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository");
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/perfObserver", () => ({
|
||||
performance: {
|
||||
mark: vi.fn(),
|
||||
measure: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
wrapApiHandlerWithSentry: vi.fn((handler) => handler),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({
|
||||
getServerErrorFromUnknown: vi.fn((error) => ({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
statusCode: 500,
|
||||
url: "test-url",
|
||||
method: "GET",
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir: vi.fn((handler) => handler),
|
||||
}));
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService);
|
||||
|
||||
describe("/api/cron/calendar-subscriptions-cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("CRON_API_KEY", "test-cron-key");
|
||||
vi.stubEnv("CRON_SECRET", "test-cron-secret");
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
test("should return 403 when no API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("should return 403 when invalid API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "invalid-key");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("should accept CRON_API_KEY in authorization header", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept CRON_SECRET as Bearer token", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "Bearer test-cron-secret");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept API key as query parameter", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key"
|
||||
);
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup functionality", () => {
|
||||
test("should successfully cleanup stale cache", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle cleanup errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockError = new Error("Database connection failed");
|
||||
const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error");
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate CalendarCacheEventService with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(mockCalendarCacheEventService).toHaveBeenCalledWith({
|
||||
calendarCacheEventRepository: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
/**
|
||||
* Cron webhook
|
||||
* Cleanup stale calendar cache
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
async function getHandler(request: NextRequest) {
|
||||
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// instantiate dependencies
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
try {
|
||||
await calendarCacheEventService.cleanupStaleCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
console.error(`[calendar-subscriptions-cleanup] ${message}:`, e);
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "GET";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
|
||||
|
||||
describe("/api/cron/calendar-subscriptions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.stubEnv("CRON_API_KEY", "test-cron-key");
|
||||
vi.stubEnv("CRON_SECRET", "test-cron-secret");
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
test("should return 403 when no API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbiden");
|
||||
}, 10000);
|
||||
|
||||
test("should return 403 when invalid API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "invalid-key");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbiden");
|
||||
});
|
||||
|
||||
test("should accept valid API key", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature flag checks", () => {
|
||||
test("should return early when cache AND sync are disabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockCheckForNewSubscriptions = vi.fn();
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should proceed when both cache and sync are enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subscription checking functionality", () => {
|
||||
test("should successfully check for new subscriptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle subscription checking errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockError = new Error("Subscription service unavailable");
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Subscription service unavailable");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue("String error");
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate all services with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
|
||||
adapterFactory: expect.any(Object),
|
||||
selectedCalendarRepository: expect.any(Object),
|
||||
featuresRepository: expect.any(Object),
|
||||
calendarSyncService: expect.any(Object),
|
||||
calendarCacheEventService: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
/**
|
||||
* Cron webhook
|
||||
* Checks for new calendar subscriptions (rollouts)
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
async function getHandler(request: NextRequest) {
|
||||
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
return NextResponse.json({ message: "Forbiden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// instantiate dependencies
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const calendarSyncService = new CalendarSyncService({
|
||||
bookingRepository,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
// are features globally enabled
|
||||
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
|
||||
calendarSubscriptionService.isCacheEnabled(),
|
||||
calendarSubscriptionService.isSyncEnabled(),
|
||||
]);
|
||||
|
||||
if (!isCacheEnabled && !isSyncEnabled) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await calendarSubscriptionService.checkForNewSubscriptions();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -0,0 +1,315 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "POST";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
|
||||
|
||||
describe("/api/webhooks/calendar-subscription/[provider]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Provider validation", () => {
|
||||
test("should accept google_calendar provider", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
}, 10000);
|
||||
|
||||
test("should accept office365_calendar provider", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/webhooks/calendar-subscription/office365_calendar",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "office365_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("office365_calendar", request);
|
||||
});
|
||||
|
||||
test("should reject unsupported provider", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/webhooks/calendar-subscription/unsupported_calendar",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "unsupported_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unsupported provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature flag handling", () => {
|
||||
test("should return 200 when neither cache nor sync is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn();
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("No cache or sync enabled");
|
||||
expect(mockProcessWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should process webhook when cache is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
|
||||
test("should process webhook when sync is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
|
||||
test("should process webhook when both cache and sync are enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("should handle webhook processing errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockError = new Error("Webhook validation failed");
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook validation failed");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockRejectedValue("String error");
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
|
||||
test("should handle feature flag check errors", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockError = new Error("Feature flag service unavailable");
|
||||
const mockIsCacheEnabled = vi.fn().mockRejectedValue(mockError);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Feature flag service unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate all services with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
|
||||
adapterFactory: expect.any(Object),
|
||||
selectedCalendarRepository: expect.any(Object),
|
||||
featuresRepository: expect.any(Object),
|
||||
calendarSyncService: expect.any(Object),
|
||||
calendarCacheEventService: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Params } from "app/_types";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["calendar-webhook"] });
|
||||
|
||||
function extractAndValidateProviderFromParams(params: Params): CalendarSubscriptionProvider | null {
|
||||
if (!("provider" in params)) {
|
||||
return null;
|
||||
}
|
||||
const { provider } = params;
|
||||
if (provider === "google_calendar" || provider === "office365_calendar") {
|
||||
return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming POST requests for calendar webhooks.
|
||||
* It processes the webhook based on the calendar provider specified in the URL.
|
||||
* If the provider is unsupported, it returns a 400 response.
|
||||
* If the webhook is processed successfully, it returns a 200 response.
|
||||
* In case of errors during processing, it returns a 500 response with the error message.
|
||||
* @param {NextRequest} request - The incoming request object.
|
||||
* @param {Object} context - The context object containing route parameters.
|
||||
* @param {Promise<Params>} context.params - A promise that resolves to the route parameters.
|
||||
* @returns {Promise<NextResponse>} - A promise that resolves to the response object.
|
||||
*/
|
||||
async function postHandler(request: NextRequest, ctx: { params: Promise<Params> }) {
|
||||
const providerFromParams = extractAndValidateProviderFromParams(await ctx.params);
|
||||
if (!providerFromParams) {
|
||||
return NextResponse.json({ message: "Unsupported provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// instantiate dependencies
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const calendarSyncService = new CalendarSyncService({
|
||||
bookingRepository,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
// are features globally enabled
|
||||
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
|
||||
calendarSubscriptionService.isCacheEnabled(),
|
||||
calendarSubscriptionService.isSyncEnabled(),
|
||||
]);
|
||||
|
||||
if (!isCacheEnabled && !isSyncEnabled) {
|
||||
log.debug("No cache or sync enabled");
|
||||
return NextResponse.json({ message: "No cache or sync enabled" }, { status: 200 });
|
||||
}
|
||||
|
||||
await calendarSubscriptionService.processWebhook(providerFromParams, request);
|
||||
return NextResponse.json({ message: "Webhook processed" }, { status: 200 });
|
||||
} catch (error) {
|
||||
log.error("Error processing webhook", { error });
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(postHandler);
|
||||
Regular → Executable
+2
-2
@@ -23,9 +23,9 @@ try {
|
||||
"*/5 * * * * *",
|
||||
async function () {
|
||||
await Promise.allSettled([
|
||||
fetchCron("/calendar-cache/cron"),
|
||||
fetchCron("/cron/calendar-subscriptions"),
|
||||
// fetchCron("/calendar-cache/cron"),
|
||||
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
|
||||
//
|
||||
fetchCron("/tasks/cron"),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"path": "/api/cron/calendar-cache-cleanup",
|
||||
"schedule": "0 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/calendar-subscriptions",
|
||||
"schedule": "*/5 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/calendar-subscriptions-cleanup",
|
||||
"schedule": "0 3 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/queuedFormResponseCleanup",
|
||||
"schedule": "0 */12 * * *"
|
||||
|
||||
Reference in new issue
Block a user