Skip to content
Merged
Show file tree
Hide file tree
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 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
52 changes: 34 additions & 18 deletions packages/app-store/_utils/getCalendar.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { CalendarCacheWrapper } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheWrapper";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import logger from "@calcom/lib/logger";
import type { Calendar, CalendarClass } from "@calcom/types/Calendar";
import { prisma } from "@calcom/prisma";
import type { Calendar } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";

import { CalendarServiceMap } from "../calendar.services.generated";

interface CalendarApp {
lib: {
CalendarService: CalendarClass;
};
}

const log = logger.getSubLogger({ prefix: ["CalendarManager"] });

/**
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
*/
const isCalendarService = (x: unknown): x is CalendarApp =>
!!x &&
typeof x === "object" &&
"lib" in x &&
typeof x.lib === "object" &&
!!x.lib &&
"CalendarService" in x.lib;

export const getCalendar = async (
credential: CredentialForCalendarService | null
): Promise<Calendar | null> => {
Expand Down Expand Up @@ -53,5 +41,33 @@ export const getCalendar = async (
return null;
}

// check if Calendar Cache is supported and enabled
if (CalendarCacheEventService.isAppSupported(credential.appId)) {
log.debug(
`Using regular CalendarService for credential ${credential.id} (not Google or Office365 Calendar)`
);
const featuresRepository = new FeaturesRepository(prisma);
const isCalendarSubscriptionCacheEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"calendar-subscription-cache"
);

if (isCalendarSubscriptionCacheEnabled) {
log.debug(
`calendar-subscription-cache is enabled, using CalendarCacheService for credential ${credential.id}`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalCalendar = new CalendarService(credential as any);
if (originalCalendar) {
// return cacheable calendar
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
return new CalendarCacheWrapper({
originalCalendar,
calendarCacheEventRepository,
});
}
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new CalendarService(credential as any);
};
40 changes: 40 additions & 0 deletions packages/features/calendar-subscription/README.md
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);


`insert into TeamFeatures
INSERT INTO "public"."TeamFeatures" ("teamId", "featureId", "assignedAt", "assignedBy", "updatedAt") VALUES
(1, DEFAULT, DEFAULT, DEFAULT, DEFAULT);

### 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);

## Team Feature Flags


## Architecture

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;

get(provider: CalendarSubscriptionProvider): ICalendarSubscriptionPort {
const adapter = this.singletons[provider];
if (!adapter) {
throw new Error(`No adapter found for provider ${provider}`);
}
return adapter;
}
}
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;
});
}

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