-
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
+4,723
β42
Merged
feat: Calendar Cache #23876
Changes from 5 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
Some comments aren't visible on the classic Files Changed page.
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
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,40 @@ | ||
| # Calendar Subscription | ||
|
|
||
| The **Calendar Subscription** feature keeps your calendars synchronized while ensuring efficient caching. | ||
|
|
||
| When enabled, it uses **webhooks** to automatically listen for updates and apply changes in real time. | ||
| This means new events, modifications, or deletions are instantly captured and reflected across the system β no manual refresh or constant polling required. | ||
|
|
||
| ## 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. | ||
|
|
||
| By subscribing to calendars via webhooks, you gain a smarter, faster, and more resource-friendly way to keep your data in sync. | ||
|
|
||
| ## Feature Flags | ||
|
|
||
| This feature can be enabled using two feature flags: | ||
| 1. `calendar-subscription-cache` that will allow calendar cache to be recorded and used thru calendars, this flag is for globally enable this feature that should be managed individually by teams through team_Features | ||
|
|
||
| ### Enabling cache feature | ||
| `insert into Feature` | ||
| INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") VALUES | ||
| ('calendar-cache-serve', 'f', 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', 'f', NULL, '2025-09-16 17:02:09.292', '2025-09-16 17:02:09.292', NULL); | ||
|
|
||
volnei marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| `insert into TeamFeatures | ||
| INSERT INTO "public"."TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") VALUES | ||
| (1, DEFAULT, DEFAULT, DEFAULT, DEFAULT); | ||
|
|
||
volnei marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ### Enabling sync feature | ||
| 2. `calendar-subscription-sync` this flag will enable canlendar sync for all calendars, diferently from cache it will be enabled globally for all users regardinless it is a team, individual or org. | ||
| INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt", "updatedBy") VALUES | ||
| ('calendar-cache-serve', 'f', 'Whether to serve calendar cache by default or not on a team/user basis.', 'OPERATIONAL', 'f', NULL, '2025-09-16 17:02:09.292', '2025-09-16 17:02:09.292', NULL); | ||
|
|
||
volnei marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ## Team Feature Flags | ||
|
|
||
|
|
||
| ## Architecture | ||
|
|
||
27 changes: 27 additions & 0 deletions
27
packages/features/calendar-subscription/adapters/AdaptersFactory.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,27 @@ | ||
| 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; | ||
| } | ||
|
|
||
| /** | ||
| * Default adapter factory | ||
| */ | ||
| export class DefaultAdapterFactory implements AdapterFactory { | ||
| private singletons = { | ||
| google_calendar: new GoogleCalendarSubscriptionAdapter(), | ||
| office365_calendar: new Office365CalendarSubscriptionAdapter(), | ||
| } as const; | ||
volnei marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| get(provider: CalendarSubscriptionProvider): ICalendarSubscriptionPort { | ||
| const adapter = this.singletons[provider]; | ||
| if (!adapter) { | ||
| throw new Error(`No adapter found for provider ${provider}`); | ||
| } | ||
| return adapter; | ||
| } | ||
| } | ||
176 changes: 176 additions & 0 deletions
176
packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.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,176 @@ | ||
| import type { calendar_v3 } from "@googleapis/calendar"; | ||
| import { v4 as uuid } from "uuid"; | ||
|
|
||
| import { CalendarAuth } from "@calcom/app-store/googlecalendar/lib/CalendarAuth"; | ||
| import logger from "@calcom/lib/logger"; | ||
| import type { SelectedCalendar } from "@calcom/prisma/client"; | ||
|
|
||
| import type { | ||
| ICalendarSubscriptionPort, | ||
| CalendarSubscriptionResult, | ||
| CalendarSubscriptionEvent, | ||
| CalendarSubscriptionEventItem, | ||
| CalendarCredential, | ||
| CalendarSubscriptionWebhookContext, | ||
| } 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; | ||
|
|
||
| async validate(context: CalendarSubscriptionWebhookContext): Promise<boolean> { | ||
| const token = context?.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", { token }); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| async extractChannelId(context: CalendarSubscriptionWebhookContext): Promise<string | null> { | ||
| const channelId = context?.headers?.get("X-Goog-Channel-ID"); | ||
| if (!channelId) { | ||
| log.warn("Missing channel ID in webhook"); | ||
| return null; | ||
| } | ||
| return channelId; | ||
| } | ||
|
|
||
| async subscribe( | ||
| selectedCalendar: SelectedCalendar, | ||
| credential: CalendarCredential | ||
| ): Promise<CalendarSubscriptionResult> { | ||
| log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId }); | ||
| selectedCalendar; | ||
|
|
||
| const MONTH_IN_SECONDS = 60 * 60 * 24 * 30; | ||
|
|
||
| const client = await this.getClient(credential); | ||
| const result = await client.events.watch({ | ||
| calendarId: selectedCalendar.externalId, | ||
| requestBody: { | ||
| id: uuid(), | ||
| type: "web_hook", | ||
| address: this.GOOGLE_WEBHOOK_URL, | ||
| token: this.GOOGLE_WEBHOOK_TOKEN, | ||
| params: { | ||
| ttl: MONTH_IN_SECONDS.toFixed(0), | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const e = result.data?.expiration; | ||
| const expiration = e ? new Date(/^\d+$/.test(e) ? +e : e) : null; | ||
|
|
||
| return { | ||
| provider: "google_calendar", | ||
| id: result.data.id, | ||
| resourceId: result.data.resourceId, | ||
| resourceUri: result.data.resourceUri, | ||
| expiration, | ||
| }; | ||
| } | ||
| async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise<void> { | ||
| log.debug("Attempt to unsubscribe from Google Calendar", { externalId: selectedCalendar.externalId }); | ||
|
|
||
| const client = await this.getClient(credential); | ||
| await client.channels | ||
| .stop({ | ||
| requestBody: { | ||
| resourceId: selectedCalendar.channelResourceId, | ||
| }, | ||
| }) | ||
| .catch((err) => { | ||
| log.error("Error unsubscribing from Google Calendar", err); | ||
| throw err; | ||
| }); | ||
| } | ||
volnei marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| async fetchEvents( | ||
| selectedCalendar: SelectedCalendar, | ||
| credential: CalendarCredential | ||
| ): Promise<CalendarSubscriptionEvent> { | ||
| const client = await this.getClient(credential); | ||
|
|
||
| let syncToken = selectedCalendar.syncToken || undefined; | ||
| let pageToken; | ||
|
|
||
| const events: calendar_v3.Schema$Event[] = []; | ||
| do { | ||
| const { data }: { data: calendar_v3.Schema$Events } = await client.events.list({ | ||
| calendarId: selectedCalendar.externalId, | ||
| syncToken, | ||
| pageToken, | ||
| singleEvents: true, | ||
| }); | ||
|
|
||
| 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[] { | ||
| const now = new Date(); | ||
| return events | ||
| .map((event) => { | ||
| const busy = event.transparency === "opaque"; // opaque = busy, transparent = free | ||
|
|
||
| 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, | ||
| iCalUID: event.iCalUID, | ||
| start, | ||
| end, | ||
| busy, | ||
| summary: event.summary, | ||
| description: event.description, | ||
| location: event.location, | ||
| kind: event.kind, | ||
| status: event.status, | ||
| isAllDay: !!event.start?.date && !event.start?.dateTime, | ||
| timeZone: event.start?.timeZone || null, | ||
| originalStartTime: event.originalStartTime?.dateTime, | ||
| createdAt: event.created ? new Date(event.created) : null, | ||
| updatedAt: event.updated ? new Date(event.updated) : null, | ||
| }; | ||
| }) | ||
| .filter((e) => !!e.id) // safely remove events with no ID | ||
| .filter((e) => e.start < now); // remove old events | ||
| } | ||
|
|
||
| private async getClient(credential: CalendarCredential) { | ||
| const auth = new CalendarAuth(credential); | ||
| return await auth.getClient(); | ||
| } | ||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.