-
Notifications
You must be signed in to change notification settings - Fork 11.1k
feat: Calendar Cache #23876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: Calendar Cache #23876
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 6b6d00d
Add env.example
volnei 79de50b
refactor on CalendarCacheEventService
volnei 11d1bd2
remove test console.log
volnei fc5b3f9
Fix type checks errors
volnei 4daf6d6
chore: remove pt comment
volnei f027bfc
add route.ts
volnei 118a88a
chore: fix tests
volnei 47a231d
Improve cache impl
volnei da76f39
chore: update recurring event id
volnei c6b45ea
chore: small improvements
volnei 55d484b
calendar cache improvements
volnei 36eadd9
Merge branch 'main' into feat/calendar_sync_cache
volnei 1d2e0f1
Fix remove dynamic imports
volnei c5f8fab
Merge branch 'feat/calendar_sync_cache' of https://github.com/calcom/β¦
volnei 739e3bc
Add cleanup stale cache
volnei d9891c1
Fix tests
volnei e325552
add event update
volnei 72f30ae
type fixes
volnei 088aa3f
feat: add comprehensive tests for new calendar subscription API routes
devin-ai-integration[bot] 10b8607
feat: add comprehensive tests for calendar subscription services, repβ¦
devin-ai-integration[bot] 14a8c6a
fix: improve calendar-subscriptions-cleanup test performance by addinβ¦
devin-ai-integration[bot] b6dc0bf
Fix tests
volnei 30b2ad2
Fix tests
volnei a683faa
type fix
volnei b486347
Fix coderabbit comments
volnei 075e055
Fix types
volnei b1ef7b6
Fix test
volnei 0b8d5d6
Merge branch 'main' into feat/calendar_sync_cache
volnei 7c267ad
Update apps/web/app/api/cron/calendar-subscriptions/route.ts
volnei 5e8ed4f
Fixes by first review
volnei 2605eee
merge conflict
volnei 68bde1e
feat: add database migrations for calendar cache and sync fields
devin-ai-integration[bot] c556e66
only google-calendar for now
volnei 6066bf0
Merge branch 'main' into feat/calendar_sync_cache
volnei d34b878
docs: add Calendar Cache and Sync feature documentation
devin-ai-integration[bot] f147197
docs: update calendar subscription README with comprehensive documentβ¦
devin-ai-integration[bot] a967b42
fix docs
volnei 95bf75f
Merge branch 'main' into feat/calendar_sync_cache
keithwillcode fdbf170
Fix test to available calendars
volnei 43e892d
Fix test to available calendars
volnei 35b4f6b
Merge branch 'main' into feat/calendar_sync_cache
volnei 5238b0d
add migration and sync boilerplate
volnei 153a85e
fix typo
volnei f707d1e
remove double log
volnei a251399
sync boilerplate
volnei 57c1133
Merge branch 'main' into feat/calendar_sync_cache
volnei 87d4e0d
remove console.log
volnei cf10b33
only subscribe for google calendar
volnei 81ffd24
adjust for 3 months fetch
volnei 9d514ab
Merge branch 'main' into feat/calendar_sync_cache
volnei 0b887e1
only subscribe for teams that have feature enabled
volnei 893d1e5
adjust tests
volnei 1e430fe
chore: safe increment error count
volnei 3928bbc
Merge branch 'main' into feat/calendar_sync_cache
volnei 9d564e4
Fix test
volnei 0a5b3f6
Merge branch 'feat/calendar_sync_cache' of https://github.com/calcom/β¦
volnei 9dbf30a
Rename findAllBySelectedCalendarIds to findAllBySelectedCalendarIdsbeβ¦
emrysal a05a3c6
Fix tests
volnei 6184978
Add more declaritive code for increased clarity
emrysal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
213 changes: 213 additions & 0 deletions
213
apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
38
apps/web/app/api/cron/calendar-subscriptions-cleanup/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
|
|
||
| if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { | ||
| return NextResponse.json({ message: "Forbiden" }, { status: 403 }); | ||
volnei marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
volnei marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // instantiate dependencies | ||
| const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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