Skip to content
Merged
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
78ba17e
feat: calendar cache and sync - wip
volnei Sep 16, 2025
6b6d00d
Add env.example
volnei Sep 16, 2025
79de50b
refactor on CalendarCacheEventService
volnei Sep 16, 2025
11d1bd2
remove test console.log
volnei Sep 16, 2025
fc5b3f9
Fix type checks errors
volnei Sep 16, 2025
4daf6d6
chore: remove pt comment
volnei Sep 16, 2025
f027bfc
add route.ts
volnei Sep 16, 2025
118a88a
chore: fix tests
volnei Sep 16, 2025
47a231d
Improve cache impl
volnei Sep 17, 2025
da76f39
chore: update recurring event id
volnei Sep 17, 2025
c6b45ea
chore: small improvements
volnei Sep 17, 2025
55d484b
calendar cache improvements
volnei Sep 18, 2025
36eadd9
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 18, 2025
1d2e0f1
Fix remove dynamic imports
volnei Sep 18, 2025
c5f8fab
Merge branch 'feat/calendar_sync_cache' of https://github.com/calcom/…
volnei Sep 18, 2025
739e3bc
Add cleanup stale cache
volnei Sep 18, 2025
d9891c1
Fix tests
volnei Sep 18, 2025
e325552
add event update
volnei Sep 18, 2025
72f30ae
type fixes
volnei Sep 18, 2025
088aa3f
feat: add comprehensive tests for new calendar subscription API routes
devin-ai-integration[bot] Sep 19, 2025
10b8607
feat: add comprehensive tests for calendar subscription services, rep…
devin-ai-integration[bot] Sep 19, 2025
14a8c6a
fix: improve calendar-subscriptions-cleanup test performance by addin…
devin-ai-integration[bot] Sep 19, 2025
b6dc0bf
Fix tests
volnei Sep 19, 2025
30b2ad2
Fix tests
volnei Sep 19, 2025
a683faa
type fix
volnei Sep 19, 2025
b486347
Fix coderabbit comments
volnei Sep 19, 2025
075e055
Fix types
volnei Sep 19, 2025
b1ef7b6
Fix test
volnei Sep 19, 2025
0b8d5d6
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 20, 2025
7c267ad
Update apps/web/app/api/cron/calendar-subscriptions/route.ts
volnei Sep 20, 2025
5e8ed4f
Fixes by first review
volnei Sep 20, 2025
2605eee
merge conflict
volnei Sep 20, 2025
68bde1e
feat: add database migrations for calendar cache and sync fields
devin-ai-integration[bot] Sep 22, 2025
c556e66
only google-calendar for now
volnei Sep 23, 2025
6066bf0
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 23, 2025
d34b878
docs: add Calendar Cache and Sync feature documentation
devin-ai-integration[bot] Sep 23, 2025
f147197
docs: update calendar subscription README with comprehensive document…
devin-ai-integration[bot] Sep 23, 2025
a967b42
fix docs
volnei Sep 23, 2025
95bf75f
Merge branch 'main' into feat/calendar_sync_cache
keithwillcode Sep 23, 2025
fdbf170
Fix test to available calendars
volnei Sep 24, 2025
43e892d
Fix test to available calendars
volnei Sep 24, 2025
35b4f6b
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 24, 2025
5238b0d
add migration and sync boilerplate
volnei Sep 24, 2025
153a85e
fix typo
volnei Sep 25, 2025
f707d1e
remove double log
volnei Sep 25, 2025
a251399
sync boilerplate
volnei Sep 25, 2025
57c1133
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 25, 2025
87d4e0d
remove console.log
volnei Sep 25, 2025
cf10b33
only subscribe for google calendar
volnei Sep 25, 2025
81ffd24
adjust for 3 months fetch
volnei Sep 26, 2025
9d514ab
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 26, 2025
0b887e1
only subscribe for teams that have feature enabled
volnei Sep 26, 2025
893d1e5
adjust tests
volnei Sep 26, 2025
1e430fe
chore: safe increment error count
volnei Sep 27, 2025
3928bbc
Merge branch 'main' into feat/calendar_sync_cache
volnei Sep 27, 2025
9d564e4
Fix test
volnei Sep 27, 2025
0a5b3f6
Merge branch 'feat/calendar_sync_cache' of https://github.com/calcom/…
volnei Sep 29, 2025
9dbf30a
Rename findAllBySelectedCalendarIds to findAllBySelectedCalendarIdsbe…
emrysal Sep 29, 2025
a05a3c6
Fix tests
volnei Sep 29, 2025
6184978
Add more declaritive code for increased clarity
emrysal Sep 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
23 changes: 22 additions & 1 deletion apps/api/v1/test/lib/selected-calendars/_post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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("Forbiden");
});

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("Forbiden");
});

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),
});
});
});
});
38 changes: 38 additions & 0 deletions apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't call these Authorization headers, these are webhook secrets; I'd do the simplest possible check, then a direct match. Not even going "Not authenticated", just go full on Forbidden.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! I just kept this way to stay consistent with the rest of our webhooks and cron-tester. But changed to 403 instead of 401. Let me know what you think


if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Forbiden" }, { status: 403 });
}

// instantiate dependencies
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to go di container here? Think so, but up to you.

const calendarCacheEventService = new CalendarCacheEventService({
calendarCacheEventRepository,
});

try {
await calendarCacheEventService.cleanupStaleCache();
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);
Loading
Loading