mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* 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]>
263 lines
8.5 KiB
TypeScript
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;
|
|
}
|
|
}
|