-
Notifications
You must be signed in to change notification settings - Fork 10.2k
feat: outlook cache #21072
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
base: main
Are you sure you want to change the base?
feat: outlook cache #21072
Conversation
@vijayraghav-io is attempting to deploy a commit to the cal Team on Vercel. A member of the Team first needs to authorize it. |
Graphite Automations"Add consumer team as reviewer" took an action on this PR • (05/02/25)1 reviewer was added to this PR based on Keith Williams's automation. "Add community label" took an action on this PR • (05/02/25)1 label was added to this PR based on Keith Williams's automation. "Add foundation team as reviewer" took an action on this PR • (05/16/25)1 reviewer was added to this PR based on Keith Williams's automation. |
if (!isDelegated) { | ||
const user = await this.fetcher("/me"); | ||
const userResponseBody = await handleErrorsJson<User>(user); | ||
this.azureUserId = userResponseBody.userPrincipalName ?? undefined; | ||
if (!this.azureUserId) { | ||
throw new Error("UserPrincipalName is missing for non-delegated user"); | ||
} | ||
return this.azureUserId; | ||
} |
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.
Thought of handling non-delegatedTo scenarios.
} | ||
this.azureUserId = parsedBody.value[0].userPrincipalName ?? undefined; |
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.
Using UPN instead of GUID
The Microsoft Graph documentation (Outlook change notifications overview) specifies that the {id} in /users/{id}/events can be either:
- A user principal name (UPN, e.g., user@domain.com).
- A user’s Azure AD ID (GUID).
Reason for using UPN :
- It’s more readable and aligns with Cal’s use of email-based identifiers.
- It’s supported for all Graph API operations, including subscriptions.
- It avoids the need for additional queries to map GUIDs to users in delegated scenarios.
@@ -52,6 +57,9 @@ interface BodyValue { | |||
start: { dateTime: string }; | |||
} | |||
|
|||
const MICROSOFT_WEBHOOK_URL = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/office365calendar/webhook`; | |||
const MICROSOFT_SUBSCRIPTION_TTL = 3 * 24 * 60 * 60 * 1000; // 3 days in milliseconds |
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.
recommended TTL for calendar event subscriptions is 4320 mins (~ 3 Days)
Ref - https://learn.microsoft.com/en-us/answers/questions/2184864/wrong-max-subscription-expiration-on-meetingcallev
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.
That was quick! Can you add some unit tests please?
Sure will add 🙏 |
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.
Actionable comments posted: 2
🔭 Outside diff range comments (1)
packages/app-store/office365calendar/lib/CalendarService.ts (1)
348-390
: Add input validation for date range and calendar IDs.The method should validate inputs before processing to prevent unnecessary API calls and potential errors.
Add validation at the beginning of the method:
async fetchAvailability( dateFrom: string, dateTo: string, calendarIds: string[], getCreatedDateTime = false ): Promise<EventBusyDate[]> { + if (!calendarIds || calendarIds.length === 0) { + return []; + } + const dateFromParsed = new Date(dateFrom); const dateToParsed = new Date(dateTo); + + if (isNaN(dateFromParsed.getTime()) || isNaN(dateToParsed.getTime())) { + throw new Error("Invalid date format provided"); + } + + if (dateFromParsed >= dateToParsed) { + throw new Error("dateFrom must be before dateTo"); + }
♻️ Duplicate comments (2)
packages/app-store/office365calendar/lib/CalendarService.ts (2)
697-699
: LGTM! Well-documented subscription scoping.The implementation correctly scopes subscriptions to specific calendars using
/calendars/{calendar_id}/events
to reduce notification volume, as documented in the Microsoft Graph API guidelines.
700-700
: LGTM! Using recommended TTL for calendar subscriptions.The 3-day TTL aligns with Microsoft's recommended duration for calendar event subscriptions as per the documentation.
🧹 Nitpick comments (3)
packages/app-store/office365calendar/lib/CalendarService.ts (3)
405-436
: Consider implementing cache warming strategy.The cache miss path fetches data but doesn't automatically populate the cache. Consider warming the cache after a miss to benefit subsequent requests.
Consider updating the cache after a miss:
this.log.debug("[Cache Miss] Fetching availability", { dateFrom, dateTo, calendarIds }); const data = await this.fetchAvailability(dateFrom, dateTo, calendarIds); + +// Optionally warm the cache for future requests +await this.setAvailabilityInCache(cacheArgs, data).catch((err) => { + this.log.warn("Failed to warm cache after miss", err); +}); + return data;
712-726
: Add error handling for failed subscription deletions.While the method logs warnings for failed deletions, consider tracking which deletions failed and potentially retrying or escalating critical failures.
Consider tracking failed deletions:
private async stopWatchingCalendarsInMicrosoft(subscriptions: { subscriptionId: string | null }[]) { const uniqueSubscriptions = subscriptions.filter( (s, i, arr) => s.subscriptionId && arr.findIndex((x) => x.subscriptionId === s.subscriptionId) === i ); - await Promise.allSettled( + const results = await Promise.allSettled( uniqueSubscriptions.map(({ subscriptionId }) => subscriptionId ? this.fetcher(`/subscriptions/${subscriptionId}`, { method: "DELETE" }).catch((err) => { this.log.warn(`Failed to delete subscription ${subscriptionId}`, err); + throw err; // Re-throw to capture in allSettled }) : Promise.resolve() ) ); + + const failedDeletions = results.filter(r => r.status === 'rejected'); + if (failedDeletions.length > 0) { + this.log.error(`Failed to delete ${failedDeletions.length} subscriptions`); + } }
728-756
: Consider batching cache operations for performance.The method processes calendars per event type sequentially. For better performance with many event types, consider batching the cache operations.
Consider parallel processing with batching:
-for (const [_eventTypeId, selectedCalendars] of Array.from(selectedCalendarsPerEventType.entries())) { - const parsedArgs = { - timeMin: getTimeMin(), - timeMax: getTimeMax(), - items: selectedCalendars.map((sc) => ({ id: sc.externalId })), - }; - const data = await this.fetchAvailability( - parsedArgs.timeMin, - parsedArgs.timeMax, - parsedArgs.items.map((i) => i.id) - ); - await this.setAvailabilityInCache(parsedArgs, data); -} +const cacheOperations = Array.from(selectedCalendarsPerEventType.entries()).map( + async ([_eventTypeId, selectedCalendars]) => { + const parsedArgs = { + timeMin: getTimeMin(), + timeMax: getTimeMax(), + items: selectedCalendars.map((sc) => ({ id: sc.externalId })), + }; + const data = await this.fetchAvailability( + parsedArgs.timeMin, + parsedArgs.timeMax, + parsedArgs.items.map((i) => i.id) + ); + return this.setAvailabilityInCache(parsedArgs, data); + } +); + +await Promise.all(cacheOperations);
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
packages/app-store/office365calendar/lib/CalendarService.ts
(9 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*Service.ts
📄 CodeRabbit Inference Engine (.cursor/rules/review.mdc)
Service files must include
Service
suffix, use PascalCase matching exported class, and avoid generic names (e.g.,MembershipService.ts
)
Files:
packages/app-store/office365calendar/lib/CalendarService.ts
**/*.ts
📄 CodeRabbit Inference Engine (.cursor/rules/review.mdc)
**/*.ts
: For Prisma queries, only select data you need; never useinclude
, always useselect
Ensure thecredential.key
field is never returned from tRPC endpoints or APIs
Files:
packages/app-store/office365calendar/lib/CalendarService.ts
**/*.{ts,tsx}
📄 CodeRabbit Inference Engine (.cursor/rules/review.mdc)
Flag excessive Day.js use in performance-critical code; prefer native Date or Day.js
.utc()
in hot paths like loops
Files:
packages/app-store/office365calendar/lib/CalendarService.ts
🧠 Learnings (5)
📓 Common learnings
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/app-store/office365calendar/api/webhook.ts:120-123
Timestamp: 2025-07-18T17:57:16.395Z
Learning: The office365calendar webhook handler in packages/app-store/office365calendar/api/webhook.ts is specifically designed for Office365 calendar integration, not as a generic webhook handler. Therefore, it's safe to assume that fetchAvailabilityAndSetCache method will be implemented in the Office365CalendarService, making explicit validation checks unnecessary.
📚 Learning: 2025-08-05T12:04:29.037Z
Learnt from: din-prajapati
PR: calcom/cal.com#21854
File: packages/app-store/office365calendar/__tests__/unit_tests/SubscriptionManager.test.ts:0-0
Timestamp: 2025-08-05T12:04:29.037Z
Learning: In packages/app-store/office365calendar/lib/CalendarService.ts, the fetcher method in Office365CalendarService class is public, not private. It was specifically changed from private to public in this PR to support proper testing and external access patterns.
Applied to files:
packages/app-store/office365calendar/lib/CalendarService.ts
📚 Learning: 2025-07-18T17:57:16.395Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/app-store/office365calendar/api/webhook.ts:120-123
Timestamp: 2025-07-18T17:57:16.395Z
Learning: The office365calendar webhook handler in packages/app-store/office365calendar/api/webhook.ts is specifically designed for Office365 calendar integration, not as a generic webhook handler. Therefore, it's safe to assume that fetchAvailabilityAndSetCache method will be implemented in the Office365CalendarService, making explicit validation checks unnecessary.
Applied to files:
packages/app-store/office365calendar/lib/CalendarService.ts
📚 Learning: 2025-07-18T08:47:01.264Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/prisma/schema.prisma:891-891
Timestamp: 2025-07-18T08:47:01.264Z
Learning: The Outlook Calendar integration in Cal.com intentionally reuses subscription IDs across multiple event types for efficiency. The `upsertSelectedCalendarsForEventTypeIds` method creates separate SelectedCalendar records for each eventTypeId, all sharing the same outlookSubscriptionId. This subscription sharing pattern means that unique constraints like `@unique([outlookSubscriptionId, eventTypeId])` should not be applied as they would prevent this intended functionality.
Applied to files:
packages/app-store/office365calendar/lib/CalendarService.ts
📚 Learning: 2025-07-18T08:47:01.264Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/prisma/schema.prisma:891-891
Timestamp: 2025-07-18T08:47:01.264Z
Learning: In Cal.com's calendar integration, both Google Calendar and Outlook Calendar are designed to allow multiple eventTypeIds to share the same subscription ID (googleChannelId or outlookSubscriptionId). This is an intentional design pattern to reuse existing subscriptions for efficiency rather than creating separate subscriptions for each event type. Therefore, unique constraints like `@unique([outlookSubscriptionId, eventTypeId])` should not be added as they would break this subscription sharing functionality.
Applied to files:
packages/app-store/office365calendar/lib/CalendarService.ts
🧬 Code Graph Analysis (1)
packages/app-store/office365calendar/lib/CalendarService.ts (4)
packages/types/Calendar.d.ts (3)
EventBusyDate
(56-62)IntegrationCalendar
(247-256)SelectedCalendarEventTypeIds
(261-261)packages/features/calendar-cache/calendar-cache.ts (1)
CalendarCache
(15-31)packages/features/calendar-cache/lib/datesForCache.ts (2)
getTimeMin
(2-8)getTimeMax
(21-59)packages/lib/server/repository/selectedCalendar.ts (1)
SelectedCalendarRepository
(48-502)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Install dependencies / Yarn install & cache
🔇 Additional comments (3)
packages/app-store/office365calendar/lib/CalendarService.ts (3)
177-185
: LGTM! Proper handling of non-delegated scenarios.The implementation correctly fetches the UserPrincipalName (UPN) for non-delegated users using the
/me
endpoint and includes appropriate error handling when the UPN is missing.
208-230
: LGTM! Well-documented implementation using UPN.The implementation correctly uses UserPrincipalName (UPN) instead of GUID, which aligns with Microsoft Graph documentation and improves readability. The regex pattern for removing OAuth client ID aliases is appropriate.
1003-1021
: LGTM! Clean implementation of busy time processing.The method correctly filters out free/workingElsewhere events and properly enriches the response with optional
id
andcreatedDateTime
fields when available.
const diff = Math.floor((fromDate.getTime() - toDate.getTime()) / oneDayMs); | ||
|
||
if (diff <= 90) { | ||
return await this.getCacheOrFetchAvailability(dateFrom, dateTo, calendarIds, shouldServeCache); |
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.
Fix the date difference calculation logic.
The date difference calculation is inverted - it should be toDate - fromDate
, not fromDate - toDate
. This results in a negative value, causing the condition to always be true for valid date ranges.
Fix the calculation:
-const diff = Math.floor((fromDate.getTime() - toDate.getTime()) / oneDayMs);
+const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / oneDayMs);
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
const diff = Math.floor((fromDate.getTime() - toDate.getTime()) / oneDayMs); | |
if (diff <= 90) { | |
return await this.getCacheOrFetchAvailability(dateFrom, dateTo, calendarIds, shouldServeCache); | |
const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / oneDayMs); | |
if (diff <= 90) { | |
return await this.getCacheOrFetchAvailability(dateFrom, dateTo, calendarIds, shouldServeCache); |
🤖 Prompt for AI Agents
In packages/app-store/office365calendar/lib/CalendarService.ts around lines 473
to 476, the date-difference calculation is inverted (fromDate - toDate) causing
negative diffs and the condition to always be true; change the calculation to
use (toDate.getTime() - fromDate.getTime()) / oneDayMs (still using Math.floor)
so diff reflects the number of days from fromDate to toDate and the <= 90 check
behaves correctly.
if (!process.env.MICROSOFT_WEBHOOK_TOKEN) { | ||
this.log.warn("MICROSOFT_WEBHOOK_TOKEN is not set, skipping watching calendar"); | ||
return; | ||
} |
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.
🛠️ Refactor suggestion
Ensure consistent behavior when webhook token is missing.
When MICROSOFT_WEBHOOK_TOKEN
is not set, the method logs a warning but returns undefined
instead of properly handling the error flow. Consider either throwing an error or returning a structured response indicating the failure reason.
Consider returning a structured error response:
if (!process.env.MICROSOFT_WEBHOOK_TOKEN) {
this.log.warn("MICROSOFT_WEBHOOK_TOKEN is not set, skipping watching calendar");
- return;
+ return { error: "MICROSOFT_WEBHOOK_TOKEN is not configured" };
}
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In packages/app-store/office365calendar/lib/CalendarService.ts around lines 566
to 569, the code currently logs a warning and returns undefined when
MICROSOFT_WEBHOOK_TOKEN is missing; update it to return a consistent, structured
error response instead of undefined. Change the early-exit to keep the log.warn
but then return an object (or type used across this service, e.g., { success:
false, error: 'MICROSOFT_WEBHOOK_TOKEN not set' }) that clearly signals failure
and reason; ensure the method signature/return type is updated accordingly so
callers can handle the structured error consistently.
What does this PR do?
/claim Outlook Cache – Bounty-to-Hire #21050
This PR implements the functionality to -
fetchAvailabilityAndSetCache()
is called for above outlook calendars to update the calendar-cache.watch
andunwatch
functionality is implemented through Microsoft Graph APIs /subscribe , so that the existing cron job can start to subscribe or unsubscribe based on conditions (to avoid duplicate subscriptions).Key points:
Visit the team public page with
cal.cache
query parameter set to true, to see the Cache effect or see [Cache Hit] in logs for office365_calendar.Example : http://localhost:3000/team/devs/teamevent?overlayCalendar=true&layout=month_view&cal.cache=true
In this PR, the

startWatchingCalendarInMicrosoft()
calls /subscription endpoint targeting specific calendars. This avoids subscribing to overly broad resources (e.g., me/events for all calendar events). Instead, target specific calendars or folders (e.g., me/calendars/{calendar_id}/events) to reduce notification volume.Next Steps or Improvements for scaling
Given the volume of usage, if we have very high number of teams, team members, team events, simultaneously updating events in outlook - the volume of subscriptions received by the web-hook can be overwhelming even with load balancers.
We can consider Asynchronous Processing: Process notifications asynchronously to avoid blocking the webhook endpoint. For example, queue incoming notifications (using a cloud message queuing system like AWS SQS or RabbitMQ or Azure Queue Storage) and process them in the background with worker nodes. This ensures the endpoint remains responsive under high load.
Also we can consider using Azure Event Hubs or Azure Event Grid as alternative delivery channels. These services are designed for high-throughput event streaming and can buffer notifications, reducing the load on our application.
Video Demo
This demo shows - on creation of an event in outlook calendar the CalendarCache is updated through the webhook
Cron job was triggered manually through api - http://localhost:3000/api/calendar-cache/cron?apiKey=
https://www.loom.com/share/468bf851a93d49849b2dab27fe751efd?sid=458f9244-e4fe-4632-897d-b93835535ff6
The second demo shows the effect of caching on public team event page-
https://www.loom.com/share/567c3d5af03e478e97a44299e9bcc8da?sid=9254a7e4-a28b-4a49-b17e-2890489fcf84
Mandatory Tasks (DO NOT REMOVE)
How should this be tested?
MICROSOFT_WEBHOOK_TOKEN
- This token is sent with subscription requests through /subscriptions Graph API.The notifications received by webhook are verified with this token
Enable
calendar-cache
feature flag for the team.Create a team event with hosts having their outlook calendars connected
At any given point of time Calendar-Cache in cal.com has the latest busy slots in sync with changes in outlook calendar busy times or events.
Update or create or delete event in Microsoft outlook calendar , there should be instant update in cal.com
calendar-cache
On visiting the team event public page with
&cal.cahce=true
, latest available slots are displayed with low latencyTo test locally use port forwarding providers
Summary by mrge
Added caching and webhook support for Outlook (Office365) calendar availability to improve performance and enable real-time updates.