Files
calendar/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts
Volnei MunhozGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Alex van AndelKeith Williams
e6b2116a2b feat: Calendar Cache and Sync (#23876)
* feat: calendar cache and sync - wip

* Add env.example

* refactor on CalendarCacheEventService

* remove test console.log

* Fix type checks errors

* chore: remove pt comment

* add route.ts

* chore: fix tests

* Improve cache impl

* chore: update recurring event id

* chore: small improvements

* calendar cache improvements

* Fix remove dynamic imports

* Add cleanup stale cache

* Fix tests

* add event update

* type fixes

* feat: add comprehensive tests for new calendar subscription API routes

- Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests)
- Add tests for /api/cron/calendar-subscriptions route (10 tests)
- Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests)
- Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync
- All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation

Tests cover:
- Authentication scenarios (API key validation, Bearer tokens, query parameters)
- Feature flag combinations (cache/sync enabled/disabled states)
- Success and error handling (including non-Error exceptions)
- Service instantiation with proper dependency injection
- Provider validation for webhook endpoints

Co-Authored-By: Volnei Munhoz <[email protected]>

* feat: add comprehensive tests for calendar subscription services, repositories, and adapters

- Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing
- Add unit tests for CalendarCacheEventService with cache operations and cleanup
- Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations
- Add unit tests for CalendarCacheEventRepository with CRUD operations
- Add unit tests for SelectedCalendarRepository with calendar selection management
- Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching
- Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation
- Add unit tests for AdaptersFactory with provider management and adapter creation
- Fix lint issues by removing explicit 'any' type casting and unused variables
- All tests follow Cal.com conventions using Vitest framework with proper mocking

Co-Authored-By: Volnei Munhoz <[email protected]>

* fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks

- Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry
- Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks
- Ensure consistent test performance across different environments

Co-Authored-By: Volnei Munhoz <[email protected]>

* Fix tests

* Fix tests

* type fix

* Fix coderabbit comments

* Fix types

* Fix test

* Update apps/web/app/api/cron/calendar-subscriptions/route.ts

Co-authored-by: Alex van Andel <[email protected]>

* Fixes by first review

* feat: add database migrations for calendar cache and sync fields

- Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values
- Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount
- Create CalendarCacheEvent table with foreign key to SelectedCalendar
- Add necessary indexes and constraints for performance and data integrity

Fixes database schema issues causing e2e test failures with 'column does not exist' errors.

Co-Authored-By: Volnei Munhoz <[email protected]>

* only google-calendar for now

* docs: add Calendar Cache and Sync feature documentation

- Add comprehensive feature overview and motivation
- Document feature flags with SQL examples
- Include SQL examples for enabling features for users and teams
- Reference technical documentation files

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <[email protected]>

* docs: update calendar subscription README with comprehensive documentation

- Undo incorrect changes to main README.md
- Update packages/features/calendar-subscription/README.md with:
  - Feature overview and motivation
  - Environment variables section
  - Complete feature flags documentation with SQL examples
  - SQL examples for enabling features for users and teams
  - Detailed architecture documentation

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <[email protected]>

* fix docs

* Fix test to available calendars

* Fix test to available calendars

* add migration and sync boilerplate

* fix typo

* remove double log

* sync boilerplate

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <[email protected]>
Co-authored-by: Keith Williams <[email protected]>
2025-09-29 14:26:14 +00:00

263 lines
8.5 KiB
TypeScript

import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type {
CalendarSubscriptionEvent,
ICalendarSubscriptionPort,
CalendarSubscriptionResult,
CalendarCredential,
CalendarSubscriptionEventItem,
} from "../lib/CalendarSubscriptionPort.interface";
const log = logger.getSubLogger({ prefix: ["MicrosoftCalendarSubscriptionAdapter"] });
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type GraphClient = { accessToken: string };
interface MicrosoftGraphEvent {
id: string;
iCalUId?: string;
subject?: string;
bodyPreview?: string;
location?: { displayName?: string };
start?: { dateTime: string; timeZone: string };
end?: { dateTime: string; timeZone: string };
showAs?: "free" | "tentative" | "busy" | "oof" | "workingElsewhere" | "unknown";
isAllDay?: boolean;
isCancelled?: boolean;
type?: string;
}
interface MicrosoftGraphEventsResponse {
"@odata.nextLink"?: string;
"@odata.deltaLink"?: string;
value: MicrosoftGraphEvent[];
}
interface MicrosoftGraphSubscriptionReq {
resource: string;
changeType: string;
notificationUrl: string;
expirationDateTime: string;
clientState?: string;
}
interface MicrosoftGraphSubscriptionRes {
id: string;
resource: string;
expirationDateTime: string;
}
type AdapterConfig = {
baseUrl?: string;
webhookToken?: string | null;
webhookUrl?: string | null;
subscriptionTtlMs?: number;
};
/**
* Office365 Calendar Subscription Adapter
*
* This adapter uses the Microsoft Graph API to create and manage calendar subscriptions
* @see https://docs.microsoft.com/en-us/graph/api/resources/subscription
*/
export class Office365CalendarSubscriptionAdapter implements ICalendarSubscriptionPort {
private readonly baseUrl: string;
private readonly webhookToken?: string | null;
private readonly webhookUrl?: string | null;
private readonly subscriptionTtlMs: number;
constructor(cfg: AdapterConfig = {}) {
this.baseUrl = cfg.baseUrl ?? "https://graph.microsoft.com/v1.0";
this.webhookToken = cfg.webhookToken ?? process.env.MICROSOFT_WEBHOOK_TOKEN ?? null;
this.webhookUrl = cfg.webhookUrl ?? process.env.MICROSOFT_WEBHOOK_URL ?? null;
this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000;
}
async validate(request: Request): Promise<boolean> {
// validate handshake
let validationToken: string | null = null;
if (request?.url) {
try {
const urlObj = new URL(request.url);
validationToken = urlObj.searchParams.get("validationToken");
} catch (e) {
log.warn("Invalid request URL", { url: request.url });
}
}
if (validationToken) return true;
// validate notifications
const clientState =
request?.headers?.get("clientState") ??
(typeof request?.body === "object" && request.body !== null && "clientState" in request.body
? (request.body as { clientState?: string }).clientState
: undefined);
if (!this.webhookToken) {
log.warn("MICROSOFT_WEBHOOK_TOKEN missing");
return false;
}
if (clientState !== this.webhookToken) {
log.warn("Invalid clientState");
return false;
}
return true;
}
async extractChannelId(request: Request): Promise<string | null> {
let id: string | null = null;
if (request?.body && typeof request.body === "object" && "subscriptionId" in request.body) {
id = (request.body as { subscriptionId?: string }).subscriptionId ?? null;
} else if (request?.headers?.get("subscriptionId")) {
id = request.headers.get("subscriptionId");
}
if (!id) {
log.warn("subscriptionId missing in webhook");
}
return id;
}
async subscribe(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionResult> {
if (!this.webhookUrl || !this.webhookToken) {
throw new Error("Webhook config missing (MICROSOFT_WEBHOOK_URL/TOKEN)");
}
const expirationDateTime = new Date(Date.now() + this.subscriptionTtlMs).toISOString();
const body: MicrosoftGraphSubscriptionReq = {
resource: `me/calendars/${selectedCalendar.externalId}/events`,
changeType: "created,updated,deleted",
notificationUrl: this.webhookUrl,
expirationDateTime,
clientState: this.webhookToken,
};
const client = await this.getGraphClient(credential);
const res = await this.request<MicrosoftGraphSubscriptionRes>(client, "POST", "/subscriptions", body);
return {
provider: "office365_calendar",
id: res.id,
resourceId: res.resource,
resourceUri: `${this.baseUrl}/${res.resource}`,
expiration: new Date(res.expirationDateTime),
};
}
async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise<void> {
const subId = selectedCalendar.channelResourceId;
if (!subId) return;
const client = await this.getGraphClient(credential);
await this.request(client, "DELETE", `/subscriptions/${subId}`);
}
async fetchEvents(
selectedCalendar: SelectedCalendar,
credential: CalendarCredential
): Promise<CalendarSubscriptionEvent> {
const client = await this.getGraphClient(credential);
let deltaLink = selectedCalendar.syncToken ?? null;
const items: MicrosoftGraphEvent[] = [];
if (deltaLink) {
const path = this.stripBase(deltaLink);
const r = await this.request<MicrosoftGraphEventsResponse>(client, "GET", path);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
} else {
let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`;
while (next) {
const r: MicrosoftGraphEventsResponse = await this.request<MicrosoftGraphEventsResponse>(
client,
"GET",
next
);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null;
}
}
return {
provider: "office365_calendar",
syncToken: deltaLink,
items: this.parseEvents(items),
};
}
private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] {
return events
.map((e) => {
const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof";
const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date();
const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date();
return {
id: e.id,
iCalUID: e.iCalUId ?? null,
start,
end,
busy,
etag: null,
summary: e.subject ?? null,
description: e.bodyPreview ?? null,
location: e.location?.displayName ?? null,
kind: e.type ?? "microsoftgraph#event",
status: e.isCancelled ? "cancelled" : "confirmed",
isAllDay: e.isAllDay ?? false,
timeZone: e.start?.timeZone ?? null,
recurringEventId: null,
originalStartDate: null,
createdAt: null,
updatedAt: null,
};
})
.filter(({ id }) => !!id);
}
private async getGraphClient(credential: CalendarCredential): Promise<GraphClient> {
const accessToken = credential.delegatedTo?.serviceAccountKey?.private_key ?? (credential.key as string);
if (!accessToken) throw new Error("Missing Microsoft access token");
return { accessToken };
}
private stripBase(urlOrPath: string): string {
return urlOrPath.startsWith("http") ? urlOrPath.replace(this.baseUrl, "") : urlOrPath;
}
private async request<T = unknown>(
client: GraphClient,
method: HttpMethod,
endpoint: string,
data?: unknown
): Promise<T> {
const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${client.accessToken}`,
"Content-Type": "application/json",
};
const init: RequestInit = { method, headers };
if (data && (method === "POST" || method === "PUT" || method === "PATCH")) {
init.body = JSON.stringify(data);
}
const res = await fetch(url, init);
if (!res.ok) {
const text = await res.text().catch(() => "");
log.error("Graph API error", { method, endpoint: this.stripBase(url), status: res.status, text });
throw new Error(`Graph ${res.status} ${res.statusText}`);
}
if (method === "DELETE" || res.status === 204) return {} as T;
return (await res.json()) as T;
}
}