From 81023f26ecfacad6e273b0863580ce3334436ef7 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 17 Oct 2025 12:43:25 -0300 Subject: [PATCH 01/14] wip Outlook calendar cache --- .../adapters/Office365CalendarSubscription.adapter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 2338ad5d0b98c4..3838e154d19150 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -70,7 +70,9 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti 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.webhookUrl = `${ + process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL + }/api/webhooks/calendar-subscription/microsoft_calendar`; this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000; } @@ -81,7 +83,7 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti try { const urlObj = new URL(request.url); validationToken = urlObj.searchParams.get("validationToken"); - } catch (e) { + } catch { log.warn("Invalid request URL", { url: request.url }); } } From 70df5e2cb336c8b3cf1f4eda75251004d4931936 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Fri, 17 Oct 2025 15:35:59 -0300 Subject: [PATCH 02/14] enable cache for office365 --- .../calendar-subscription/adapters/AdaptersFactory.ts | 2 +- .../adapters/Office365CalendarSubscription.adapter.ts | 2 +- .../adapters/__tests__/AdaptersFactory.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts index 6880234ae0ac59..7cddf1fc768853 100644 --- a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -38,7 +38,7 @@ export class DefaultAdapterFactory implements AdapterFactory { * @returns */ getProviders(): CalendarSubscriptionProvider[] { - const providers: CalendarSubscriptionProvider[] = ["google_calendar"]; + const providers: CalendarSubscriptionProvider[] = ["google_calendar", "office365_calendar"]; return providers; } } diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 3838e154d19150..24358cd9b4f58d 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -72,7 +72,7 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti this.webhookToken = cfg.webhookToken ?? process.env.MICROSOFT_WEBHOOK_TOKEN ?? null; this.webhookUrl = `${ process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL - }/api/webhooks/calendar-subscription/microsoft_calendar`; + }/api/webhooks/calendar-subscription/office365_calendar`; this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000; } diff --git a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts index ff75976946c723..0d37211ed7705c 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts @@ -42,13 +42,13 @@ describe("DefaultAdapterFactory", () => { test("should return all available providers", () => { const providers = factory.getProviders(); - expect(providers).toEqual(["google_calendar"]); + expect(providers).toEqual(["google_calendar", "office365_calendar"]); }); test("should return array with correct length", () => { const providers = factory.getProviders(); - expect(providers).toHaveLength(1); + expect(providers).toHaveLength(2); }); }); }); From d0718f8950443b5bd7a73208c9bd86099c274679 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sun, 19 Oct 2025 08:20:26 -0300 Subject: [PATCH 03/14] Finish office365 implementation --- .../api/cron/calendar-subscriptions/route.ts | 2 +- .../calendar-subscription/[provider]/route.ts | 11 +- .../Office365CalendarSubscription.adapter.ts | 305 ++++++++++-------- ...fice365CalendarSubscriptionAdapter.test.ts | 227 ++++++++++++- .../lib/CalendarSubscriptionService.ts | 10 +- .../lib/cache/CalendarCacheEventService.ts | 2 + 6 files changed, 408 insertions(+), 149 deletions(-) diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index bd3e07fda64345..61036eb7b46e1f 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; @@ -8,7 +9,6 @@ import { CalendarCacheEventService } from "@calcom/features/calendar-subscriptio import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; -import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index 60abe54140ac73..da9b99de3f672a 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -2,6 +2,7 @@ import type { Params } from "app/_types"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; @@ -11,7 +12,6 @@ import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/ import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository"; -import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; @@ -64,6 +64,13 @@ async function postHandler(request: NextRequest, ctx: { params: Promise calendarCacheEventService, }); + // only for office365 handshake validation + const url = new URL(request.url); + const validationToken = url.searchParams.get("validationToken"); + if (validationToken) { + return NextResponse.json({ validationToken }, { status: 200 }); + } + // are features globally enabled const [isCacheEnabled, isSyncEnabled] = await Promise.all([ calendarSubscriptionService.isCacheEnabled(), @@ -76,7 +83,7 @@ async function postHandler(request: NextRequest, ctx: { params: Promise } await calendarSubscriptionService.processWebhook(providerFromParams, request); - return NextResponse.json({ message: "Webhook processed" }, { status: 200 }); + return NextResponse.json({}, { status: 200 }); } catch (error) { log.error("Error processing webhook", { error }); const message = error instanceof Error ? error.message : "Unknown error"; diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 24358cd9b4f58d..30501e4c0b0c8a 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -1,7 +1,7 @@ import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; -import type { +import { CalendarSubscriptionEvent, ICalendarSubscriptionPort, CalendarSubscriptionResult, @@ -14,108 +14,79 @@ const log = logger.getSubLogger({ prefix: ["MicrosoftCalendarSubscriptionAdapter type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; type GraphClient = { accessToken: string }; -interface MicrosoftGraphEvent { +export type MicrosoftGraphEvent = { id: string; iCalUId?: string; subject?: string; bodyPreview?: string; location?: { displayName?: string }; - start?: { dateTime: string; timeZone: string }; - end?: { dateTime: string; timeZone: string }; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string; timeZone?: string }; showAs?: "free" | "tentative" | "busy" | "oof" | "workingElsewhere" | "unknown"; isAllDay?: boolean; isCancelled?: boolean; type?: string; -} + recurringEventId?: string; + "@odata.etag"?: string; + createdDateTime?: string; + lastModifiedDateTime?: string; +}; -interface MicrosoftGraphEventsResponse { +type 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 - */ +const BASE_URL = "https://graph.microsoft.com/v1.0"; +const SUBSCRIPTION_TTL_MS = 6 * 24 * 60 * 60 * 1000; // 7 days (max allowed for MS Graph) +const BUSY_STATES = ["busy", "tentative", "oof"]; + 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 = `${ - process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL - }/api/webhooks/calendar-subscription/office365_calendar`; - this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000; - } + private readonly baseUrl = BASE_URL; + private readonly subscriptionTtlMs = SUBSCRIPTION_TTL_MS; + private readonly webhookToken = process.env.MICROSOFT_WEBHOOK_TOKEN ?? null; + + private readonly webhookUrl = `${ + process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL + }/api/webhooks/calendar-subscription/office365_calendar`; + private tokenCache = new Map(); async validate(request: Request): Promise { - // validate handshake - let validationToken: string | null = null; - if (request?.url) { - try { - const urlObj = new URL(request.url); - validationToken = urlObj.searchParams.get("validationToken"); - } catch { - log.warn("Invalid request URL", { url: request.url }); + try { + const body = await request + .clone() + .json() + .catch(() => ({})); + const clientState = + request.headers.get("clientState") ?? body?.value?.[0]?.clientState ?? body?.clientState; + + if (!this.webhookToken) { + log.warn("MICROSOFT_WEBHOOK_TOKEN missing"); + return false; } - } - 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"); + + if (clientState !== this.webhookToken) { + log.warn("Invalid clientState", { received: clientState, expected: this.webhookToken }); + return false; + } + + return true; + } catch (err) { + log.error("Error validating Microsoft webhook", err); return false; } - return true; } async extractChannelId(request: Request): Promise { - 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"); - } + const body = await request + .clone() + .json() + .catch(() => ({})); + const id = + body?.value?.[0]?.subscriptionId ?? body?.subscriptionId ?? request.headers.get("subscriptionId"); + + if (!id) log.warn("subscriptionId missing in webhook"); return id; } @@ -127,18 +98,19 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti 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(client, "POST", "/subscriptions", body); + const res = await this.request<{ id: string; resource: string; expirationDateTime: string }>( + client, + "POST", + "/subscriptions", + { + resource: `me/calendars/${selectedCalendar.externalId}/events`, + changeType: "created,updated,deleted", + notificationUrl: this.webhookUrl, + expirationDateTime: new Date(Date.now() + this.subscriptionTtlMs).toISOString(), + clientState: this.webhookToken, + } + ); return { provider: "office365_calendar", @@ -162,26 +134,27 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti credential: CalendarCredential ): Promise { const client = await this.getGraphClient(credential); - - let deltaLink = selectedCalendar.syncToken ?? null; const items: MicrosoftGraphEvent[] = []; + let deltaLink = selectedCalendar.syncToken ?? null; if (deltaLink) { - const path = this.stripBase(deltaLink); - const r = await this.request(client, "GET", path); - items.push(...r.value); - deltaLink = r["@odata.deltaLink"] ?? deltaLink; + log.info("Fetching with deltaLink", { url: deltaLink }); + const response = await this.request(client, "GET", deltaLink); + items.push(...response.value); + deltaLink = response["@odata.deltaLink"] ?? deltaLink; } else { let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`; + log.info("Starting fresh delta sync", { url: next }); + while (next) { - const r: MicrosoftGraphEventsResponse = await this.request( + const response: MicrosoftGraphEventsResponse = await this.request( client, "GET", next ); - items.push(...r.value); - deltaLink = r["@odata.deltaLink"] ?? deltaLink; - next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null; + items.push(...response.value); + deltaLink = response["@odata.deltaLink"] ?? deltaLink; + next = response["@odata.nextLink"] ?? null; } } @@ -194,42 +167,92 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti 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); + .filter((e) => e.id) + .map((e) => ({ + id: e.id, + iCalUID: e.iCalUId ?? null, + start: new Date(e.start?.dateTime ?? e.start?.date ?? Date.now()), + end: new Date(e.end?.dateTime ?? e.end?.date ?? Date.now()), + busy: e.showAs ? BUSY_STATES.includes(e.showAs) : true, + etag: e["@odata.etag"] ?? null, + summary: e.subject ?? null, + description: e.bodyPreview ?? null, + location: e.location?.displayName ?? null, + kind: e.type ?? "microsoft.graph.event", + status: e.isCancelled ? "cancelled" : "confirmed", + isAllDay: !!e.isAllDay, + timeZone: e.start?.timeZone ?? e.end?.timeZone ?? "UTC", + recurringEventId: e.recurringEventId ?? null, + originalStartDate: null, + createdAt: new Date(e.createdDateTime ?? Date.now()), + updatedAt: new Date(e.lastModifiedDateTime ?? Date.now()), + })); } private async getGraphClient(credential: CalendarCredential): Promise { - const accessToken = credential.delegatedTo?.serviceAccountKey?.private_key ?? (credential.key as string); - if (!accessToken) throw new Error("Missing Microsoft access token"); - return { accessToken }; + const key = credential.key as { + access_token?: string; + refresh_token?: string; + expiry_date?: number; + }; + + if (credential.delegatedTo?.serviceAccountKey?.private_key) { + return { accessToken: credential.delegatedTo.serviceAccountKey.private_key }; + } + + const credentialId = String(credential.id); + const cached = this.tokenCache.get(credentialId); + if (cached && Date.now() < cached.expiresAt) { + return { accessToken: cached.token }; + } + + const isExpired = key.expiry_date ? Date.now() >= key.expiry_date - 5 * 60 * 1000 : false; + + if (isExpired && key.refresh_token) { + log.info("Access token expired, refreshing...", { credentialId }); + const newToken = await this.refreshAccessToken(key.refresh_token); + + this.tokenCache.set(credentialId, { + token: newToken.access_token, + expiresAt: Date.now() + 55 * 60 * 1000, + }); + + return { accessToken: newToken.access_token }; + } + + if (!key.access_token) throw new Error("Missing Microsoft access token"); + return { accessToken: key.access_token }; } - private stripBase(urlOrPath: string): string { - return urlOrPath.startsWith("http") ? urlOrPath.replace(this.baseUrl, "") : urlOrPath; + private async refreshAccessToken(refreshToken: string): Promise<{ + access_token: string; + expires_in: number; + refresh_token: string; + }> { + const clientId = process.env.MS_GRAPH_CLIENT_ID; + const clientSecret = process.env.MS_GRAPH_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new Error("Missing MS_GRAPH_CLIENT_ID or MS_GRAPH_CLIENT_SECRET"); + + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }); + + const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + log.error("Failed to refresh token", { status: response.status, error }); + throw new Error("Failed to refresh Microsoft access token"); + } + + return await response.json(); } private async request( @@ -239,13 +262,15 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti data?: unknown ): Promise { const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`; - const headers: Record = { - Authorization: `Bearer ${client.accessToken}`, - "Content-Type": "application/json", + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${client.accessToken}`, + "Content-Type": "application/json", + }, }; - const init: RequestInit = { method, headers }; - if (data && (method === "POST" || method === "PUT" || method === "PATCH")) { + if (data && ["POST", "PUT", "PATCH"].includes(method)) { init.body = JSON.stringify(data); } @@ -253,12 +278,10 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti 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}`); + log.error("Graph API error", { method, url, status: res.status, text }); + throw new Error(`Graph ${res.status} ${res.statusText}: ${text}`); } - if (method === "DELETE" || res.status === 204) return {} as T; - - return (await res.json()) as T; + return method === "DELETE" || res.status === 204 ? ({} as T) : await res.json(); } } diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts index e109056a452870..2932b9fdc16364 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -1,7 +1,12 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; import type { SelectedCalendar } from "@calcom/prisma/client"; +import { + MicrosoftGraphEvent, + Office365CalendarSubscriptionAdapter, +} from "../Office365CalendarSubscription.adapter"; + const _mockSelectedCalendar: SelectedCalendar = { id: "test-calendar-id", userId: 1, @@ -37,13 +42,229 @@ const _mockSelectedCalendar: SelectedCalendar = { const _mockCredential = { id: 1, + userId: 1, + delegationCredentialId: null, key: { access_token: "test-token" }, + type: "office365_calendar", + teamId: null, + appId: null, + invalid: false, user: { email: "test@example.com" }, delegatedTo: null, }; +// prevent actual logging during tests +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: () => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }), + }, +})); + describe("Office365CalendarSubscriptionAdapter", () => { - test("should be a placeholder test", () => { - expect(true).toBe(true); + const mockWebhookUrl = "https://example.com/api/webhooks/calendar-subscription/office365_calendar"; + const mockWebhookToken = "test-webhook-token"; + + beforeEach(() => { + vi.stubEnv("MICROSOFT_WEBHOOK_URL", mockWebhookUrl); + vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", mockWebhookToken); + vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", "https://app.example.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe("validate", () => { + test("returns true with valid clientState", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + headers: { clientState: mockWebhookToken }, + }); + const res = await adapter.validate(req); + expect(res).toBe(true); + }); + + test("returns false if token does not match", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + headers: { clientState: "invalid" }, + }); + const res = await adapter.validate(req); + expect(res).toBe(false); + }); + + test("returns false if MICROSOFT_WEBHOOK_TOKEN is missing", async () => { + vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", ""); + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com"); + const res = await adapter.validate(req); + expect(res).toBe(false); + }); + }); + + describe("extractChannelId", () => { + test("extracts subscriptionId from body", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + method: "POST", + body: JSON.stringify({ subscriptionId: "abc" }), + }); + const id = await adapter.extractChannelId(req); + expect(id).toBe("abc"); + }); + + test("extracts subscriptionId from headers", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + headers: { subscriptionId: "hdr-123" }, + }); + const id = await adapter.extractChannelId(req); + expect(id).toBe("hdr-123"); + }); + + test("returns null if subscriptionId is missing", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com"); + const id = await adapter.extractChannelId(req); + expect(id).toBe(null); + }); + }); + + describe("subscribe", () => { + test("throws error if webhook config is missing", async () => { + vi.stubEnv("MICROSOFT_WEBHOOK_URL", ""); + const adapter = new Office365CalendarSubscriptionAdapter(); + await expect(adapter.subscribe(_mockSelectedCalendar, _mockCredential)).rejects.toThrow( + /Webhook config missing/ + ); + }); + + test("successfully subscribes to calendar events", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const mockRes = { + id: "sub-1", + resource: "me/calendars/test@example.com/events", + expirationDateTime: new Date().toISOString(), + }; + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRes }); + + const res = await adapter.subscribe(_mockSelectedCalendar, _mockCredential); + expect(res.id).toBe("sub-1"); + expect(global.fetch).toHaveBeenCalled(); + }); + }); + + describe("unsubscribe", () => { + test("does nothing if channelResourceId is missing", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn(); + await adapter.unsubscribe({ ..._mockSelectedCalendar, channelResourceId: null }, _mockCredential); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("sends DELETE request correctly", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + await adapter.unsubscribe(_mockSelectedCalendar, _mockCredential); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/subscriptions/"), + expect.objectContaining({ method: "DELETE" }) + ); + }); + }); + + describe("fetchEvents", () => { + test("uses deltaLink if syncToken exists", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const mockRes = { + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendars/.../events/delta?$token=abc", + value: [ + { + id: "1", + subject: "Event", + start: { dateTime: "2025-10-18T10:00:00Z" }, + end: { dateTime: "2025-10-18T11:00:00Z" }, + }, + ], + }; + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRes }); + const res = await adapter.fetchEvents(_mockSelectedCalendar, _mockCredential); + expect(res.items.length).toBe(1); + expect(res.syncToken).toContain("$token=abc"); + }); + + test("performs full sync when no syncToken is present", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const mock1 = { "@odata.nextLink": "page2", value: [{ id: "a" }] }; + const mock2 = { "@odata.deltaLink": "done", value: [{ id: "b" }] }; + global.fetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => mock1 }) + .mockResolvedValueOnce({ ok: true, json: async () => mock2 }); + + const res = await adapter.fetchEvents({ ..._mockSelectedCalendar, syncToken: null }, _mockCredential); + expect(res.items.length).toBe(2); + expect(res.syncToken).toBe("done"); + }); + }); + + describe("getGraphClient", () => { + test("returns a valid accessToken", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const client = await adapter["getGraphClient"](_mockCredential); + expect(client.accessToken).toBe("test-token"); + }); + + test("throws error if access_token is missing", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const badCred = { ..._mockCredential, key: {} }; + await expect(adapter["getGraphClient"](badCred)).rejects.toThrow(/Missing Microsoft access token/); + }); + }); + + describe("request", () => { + test("performs successful API request", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "123" }) }); + const res = await adapter["request"]({ accessToken: "tok" }, "GET", "/me/events"); + expect(res).toEqual({ id: "123" }); + }); + + test("throws error when API call fails", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "err", + }); + await expect(adapter["request"]({ accessToken: "tok" }, "GET", "/me/events")).rejects.toThrow( + /Graph 400 Bad Request/ + ); + }); + }); + + describe("parseEvents", () => { + test("parses events correctly", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events: MicrosoftGraphEvent[] = [ + { + id: "1", + subject: "Meeting", + bodyPreview: "desc", + start: { dateTime: "2025-10-18T10:00:00Z", timeZone: "UTC" }, + end: { dateTime: "2025-10-18T11:00:00Z", timeZone: "UTC" }, + showAs: "busy", + }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].summary).toBe("Meeting"); + expect(parsed[0].busy).toBe(true); + }); }); }); diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 236a718392a33d..f1fb4a1eb3d1ad 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -123,7 +123,7 @@ export class CalendarSubscriptionService { log.debug("Processing webhook", { channelId }); const selectedCalendar = await this.deps.selectedCalendarRepository.findByChannelId(channelId); // it maybe caused by an old subscription being triggered - if (!selectedCalendar) return null; + if (!selectedCalendar) return; // incremental event loading await this.processEvents(selectedCalendar); @@ -209,7 +209,13 @@ export class CalendarSubscriptionService { integrations: this.deps.adapterFactory.getProviders(), }); log.debug("checkForNewSubscriptions", { count: rows.length }); - await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); + const results = await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); + + const errors = results.filter((r) => r.status === "rejected"); + const successes = results.filter((r) => r.status === "fulfilled"); + + log.info(`Subscriptions: ${successes.length} succeeded | ${errors.length} failed`); + errors.forEach((e) => log.error(e.reason)); } /** diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index 906103c58758fb..e71d40b6fab6ef 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -29,6 +29,8 @@ export class CalendarCacheEventService { const toUpsert: Partial[] = []; const toDelete: Pick[] = []; + console.log("AMAZING", calendarSubscriptionEvents); + for (const event of calendarSubscriptionEvents) { // not storing free or cancelled events if (event.busy && event.status !== "cancelled") { From 5a5cd10715ad1ed0e273414534c85a90d9da80f7 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sun, 19 Oct 2025 08:26:26 -0300 Subject: [PATCH 04/14] Fix initial loading --- .../Office365CalendarSubscription.adapter.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 30501e4c0b0c8a..3a0eeb233a9c57 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -1,3 +1,6 @@ +import { CalendarCacheEventService } from "calendar-subscription/lib/cache/CalendarCacheEventService"; + +import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; @@ -143,8 +146,17 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti items.push(...response.value); deltaLink = response["@odata.deltaLink"] ?? deltaLink; } else { - let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`; - log.info("Starting fresh delta sync", { url: next }); + const now = dayjs().startOf("day"); + const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day"); + + const start = now.toISOString(); + const end = monthsAhead.toISOString(); + + let next: + | string + | null = `/me/calendars/${selectedCalendar.externalId}/events?$filter=start/dateTime ge '${start}' and start/dateTime le '${end}'&$orderby=start/dateTime asc`; + + log.info("Initial fetch", { url: next }); while (next) { const response: MicrosoftGraphEventsResponse = await this.request( @@ -153,9 +165,10 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti next ); items.push(...response.value); - deltaLink = response["@odata.deltaLink"] ?? deltaLink; next = response["@odata.nextLink"] ?? null; } + + deltaLink = `/me/calendars/${selectedCalendar.externalId}/events/delta`; } return { From 6e0639310cf343b6738e30c696bcd32336e84f27 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sun, 19 Oct 2025 08:29:14 -0300 Subject: [PATCH 05/14] Add missing env.example variables --- .env.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 66df73e85386d7..d20d2063e7d524 100644 --- a/.env.example +++ b/.env.example @@ -144,9 +144,12 @@ GOOGLE_WEBHOOK_TOKEN= # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. GOOGLE_WEBHOOK_URL= +# Optional Microsoft Graph Client ID +MS_GRAPH_CLIENT_ID= +# Optional Microsoft Graph Client Secret +MS_GRAPH_CLIENT_SECRET= # 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= From 9e4f99169eda96eb145f65b896e1281567f286a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Oct 2025 11:42:12 +0000 Subject: [PATCH 06/14] test: Fix and enhance Office365 calendar subscription adapter tests - Fix import path for CalendarCacheEventService (use relative path) - Fix 'throws error if webhook config is missing' test by stubbing all required env vars - Fix 'performs full sync when no syncToken is present' test to match actual adapter behavior - Add comprehensive test coverage for: - Token refresh and caching logic - Delegated credential service account key usage - parseEvents edge cases (different showAs values, cancelled events, all-day events) - refreshAccessToken error handling - Event filtering (events without id) All 49 tests now passing (6 AdaptersFactory + 17 Google + 26 Office365) Co-Authored-By: Volnei Munhoz --- .../Office365CalendarSubscription.adapter.ts | 3 +- ...fice365CalendarSubscriptionAdapter.test.ts | 133 +++++++++++++++++- 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 3a0eeb233a9c57..1fa1f6365105d2 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -1,5 +1,3 @@ -import { CalendarCacheEventService } from "calendar-subscription/lib/cache/CalendarCacheEventService"; - import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; @@ -11,6 +9,7 @@ import { CalendarCredential, CalendarSubscriptionEventItem, } from "../lib/CalendarSubscriptionPort.interface"; +import { CalendarCacheEventService } from "../lib/cache/CalendarCacheEventService"; const log = logger.getSubLogger({ prefix: ["MicrosoftCalendarSubscriptionAdapter"] }); diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts index 2932b9fdc16364..b614b355b77366 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -138,6 +138,8 @@ describe("Office365CalendarSubscriptionAdapter", () => { describe("subscribe", () => { test("throws error if webhook config is missing", async () => { vi.stubEnv("MICROSOFT_WEBHOOK_URL", ""); + vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", ""); + vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", ""); const adapter = new Office365CalendarSubscriptionAdapter(); await expect(adapter.subscribe(_mockSelectedCalendar, _mockCredential)).rejects.toThrow( /Webhook config missing/ @@ -209,7 +211,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { const res = await adapter.fetchEvents({ ..._mockSelectedCalendar, syncToken: null }, _mockCredential); expect(res.items.length).toBe(2); - expect(res.syncToken).toBe("done"); + expect(res.syncToken).toBe("/me/calendars/test@example.com/events/delta"); }); }); @@ -266,5 +268,134 @@ describe("Office365CalendarSubscriptionAdapter", () => { expect(parsed[0].summary).toBe("Meeting"); expect(parsed[0].busy).toBe(true); }); + + test("handles events with different showAs values", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events: MicrosoftGraphEvent[] = [ + { id: "1", showAs: "free" }, + { id: "2", showAs: "tentative" }, + { id: "3", showAs: "oof" }, + { id: "4", showAs: "workingElsewhere" }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].busy).toBe(false); + expect(parsed[1].busy).toBe(true); + expect(parsed[2].busy).toBe(true); + expect(parsed[3].busy).toBe(false); + }); + + test("handles cancelled events", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events: MicrosoftGraphEvent[] = [ + { id: "1", isCancelled: true }, + { id: "2", isCancelled: false }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].status).toBe("cancelled"); + expect(parsed[1].status).toBe("confirmed"); + }); + + test("handles all-day events", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events: MicrosoftGraphEvent[] = [ + { id: "1", isAllDay: true, start: { date: "2025-10-18" }, end: { date: "2025-10-19" } }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].isAllDay).toBe(true); + }); + + test("filters out events without id", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events: MicrosoftGraphEvent[] = [{ id: "" }, { id: "valid" }]; + const parsed = adapter["parseEvents"](events); + expect(parsed.length).toBe(1); + expect(parsed[0].id).toBe("valid"); + }); + }); + + describe("getGraphClient - token refresh", () => { + test("uses cached token when available and not expired", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const credential = { ..._mockCredential, id: 999 }; + + await adapter["getGraphClient"](credential); + + const client = await adapter["getGraphClient"](credential); + expect(client.accessToken).toBe("test-token"); + }); + + test("refreshes token when expired", async () => { + vi.stubEnv("MS_GRAPH_CLIENT_ID", "test-client-id"); + vi.stubEnv("MS_GRAPH_CLIENT_SECRET", "test-client-secret"); + + const adapter = new Office365CalendarSubscriptionAdapter(); + const expiredCred = { + ..._mockCredential, + key: { + access_token: "old-token", + refresh_token: "refresh-token", + expiry_date: Date.now() - 1000, // Expired + }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "new-token", + expires_in: 3600, + refresh_token: "new-refresh-token", + }), + }); + + const client = await adapter["getGraphClient"](expiredCred); + expect(client.accessToken).toBe("new-token"); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("login.microsoftonline.com"), + expect.any(Object) + ); + }); + + test("uses delegated credential service account key", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const delegatedCred = { + ..._mockCredential, + delegatedTo: { + serviceAccountKey: { + private_key: "service-account-key", + }, + }, + }; + + const client = await adapter["getGraphClient"](delegatedCred); + expect(client.accessToken).toBe("service-account-key"); + }); + }); + + describe("refreshAccessToken", () => { + test("throws error when MS_GRAPH_CLIENT_ID is missing", async () => { + vi.stubEnv("MS_GRAPH_CLIENT_ID", ""); + vi.stubEnv("MS_GRAPH_CLIENT_SECRET", "test-secret"); + + const adapter = new Office365CalendarSubscriptionAdapter(); + await expect(adapter["refreshAccessToken"]("refresh-token")).rejects.toThrow( + /Missing MS_GRAPH_CLIENT_ID/ + ); + }); + + test("throws error when refresh fails", async () => { + vi.stubEnv("MS_GRAPH_CLIENT_ID", "test-client-id"); + vi.stubEnv("MS_GRAPH_CLIENT_SECRET", "test-client-secret"); + + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => "invalid_grant", + }); + + await expect(adapter["refreshAccessToken"]("invalid-token")).rejects.toThrow( + /Failed to refresh Microsoft access token/ + ); + }); }); }); From 9d08ae0b9f06a9f2eeda3351d8af8c4f70dd6556 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sun, 19 Oct 2025 08:46:14 -0300 Subject: [PATCH 07/14] remove dirty console.log --- .../lib/cache/CalendarCacheEventService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts index 4a8fd0ea128278..220ca532973624 100644 --- a/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts +++ b/packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts @@ -30,8 +30,6 @@ export class CalendarCacheEventService { const toUpsert: Partial[] = []; const toDelete: Pick[] = []; - console.log("AMAZING", calendarSubscriptionEvents); - for (const event of calendarSubscriptionEvents) { // not storing free or cancelled events if (event.busy && event.status !== "cancelled") { From 766c8781132247b2bae0ca7f8cb58a88f876ba88 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:08:17 +0000 Subject: [PATCH 08/14] test: Fix CalendarSubscriptionService test expectation Change expectation from toBeNull() to toBeUndefined() to match actual service behavior when processWebhook returns early for old subscriptions Co-Authored-By: Volnei Munhoz --- .../lib/__tests__/CalendarSubscriptionService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts index a1081a2b1aaa3c..002d3b2f1b5cc9 100644 --- a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -255,7 +255,7 @@ describe("CalendarSubscriptionService", () => { const _result = await service.processWebhook("google_calendar", mockRequest); - expect(_result).toBeNull(); + expect(_result).toBeUndefined(); }); }); From cd99caefde8df2bd7ebba09ba92bac7bb1cbce29 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sun, 19 Oct 2025 09:09:11 -0300 Subject: [PATCH 09/14] Update packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../adapters/Office365CalendarSubscription.adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 1fa1f6365105d2..f8b9573e1228f1 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -69,7 +69,7 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti } if (clientState !== this.webhookToken) { - log.warn("Invalid clientState", { received: clientState, expected: this.webhookToken }); + log.warn("Invalid clientState"); return false; } From f6580d97a38397afd29f4cf90d9c40eaef3d4130 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Sun, 19 Oct 2025 09:09:43 -0300 Subject: [PATCH 10/14] Update apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../app/api/webhooks/calendar-subscription/[provider]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index da9b99de3f672a..b07c088e6beb52 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -68,7 +68,7 @@ async function postHandler(request: NextRequest, ctx: { params: Promise const url = new URL(request.url); const validationToken = url.searchParams.get("validationToken"); if (validationToken) { - return NextResponse.json({ validationToken }, { status: 200 }); + return new NextResponse(validationToken, { status: 200, headers: { "Content-Type": "text/plain" } }); } // are features globally enabled From 5034c145ab470bb685aa001ded1373f0e12d1e84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:21:56 +0000 Subject: [PATCH 11/14] test: Fix webhook route test expectations Update test expectations to match actual route implementation which returns empty object {} instead of {message: 'Webhook processed'} Co-Authored-By: Volnei Munhoz --- .../[provider]/__tests__/route.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts index da7a6c64d0faad..7fea2928259a78 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts @@ -160,7 +160,7 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.message).toBe("Webhook processed"); + expect(body).toEqual({}); expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); }); @@ -184,7 +184,7 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.message).toBe("Webhook processed"); + expect(body).toEqual({}); expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); }); @@ -208,7 +208,7 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.message).toBe("Webhook processed"); + expect(body).toEqual({}); expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); }); }); From 6c6bf07072083a93633cf67a3d04e5517daf2567 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Wed, 12 Nov 2025 11:36:22 -0300 Subject: [PATCH 12/14] Update packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../adapters/Office365CalendarSubscription.adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index f8b9573e1228f1..ec2d24b8cdd718 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -140,7 +140,7 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti let deltaLink = selectedCalendar.syncToken ?? null; if (deltaLink) { - log.info("Fetching with deltaLink", { url: deltaLink }); + log.info("Fetching with deltaLink"); const response = await this.request(client, "GET", deltaLink); items.push(...response.value); deltaLink = response["@odata.deltaLink"] ?? deltaLink; From 159555799db319066ded609245bf64848d4032a8 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 17 Feb 2026 09:08:59 -0300 Subject: [PATCH 13/14] Small fixes and upgrades accordingly MS api --- .../adapters/AdaptersFactory.ts | 3 ++- .../Office365CalendarSubscription.adapter.ts | 17 ++++++++--------- .../adapters/__tests__/AdaptersFactory.test.ts | 4 ++-- ...Office365CalendarSubscriptionAdapter.test.ts | 17 +++++++---------- .../CalendarSubscriptionService.test.ts | 2 +- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts index e1b92b6ebf17b1..6f1c9965d89e8a 100644 --- a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -54,7 +54,8 @@ export class DefaultAdapterFactory implements AdapterFactory { * @returns */ getProviders(): CalendarSubscriptionProvider[] { - const providers: CalendarSubscriptionProvider[] = ["google_calendar", "office365_calendar"]; + // TODO: add "office365_calendar" once the adapter is validated in production + const providers: CalendarSubscriptionProvider[] = ["google_calendar"]; return providers; } diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index ec2d24b8cdd718..0d519348ed24b8 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -2,7 +2,7 @@ import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; -import { +import type { CalendarSubscriptionEvent, ICalendarSubscriptionPort, CalendarSubscriptionResult, @@ -16,7 +16,7 @@ const log = logger.getSubLogger({ prefix: ["MicrosoftCalendarSubscriptionAdapter type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; type GraphClient = { accessToken: string }; -export type MicrosoftGraphEvent = { +type MicrosoftGraphEvent = { id: string; iCalUId?: string; subject?: string; @@ -41,7 +41,7 @@ type MicrosoftGraphEventsResponse = { }; const BASE_URL = "https://graph.microsoft.com/v1.0"; -const SUBSCRIPTION_TTL_MS = 6 * 24 * 60 * 60 * 1000; // 7 days (max allowed for MS Graph) +const SUBSCRIPTION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max allowed for MS Graph) const BUSY_STATES = ["busy", "tentative", "oof"]; export class Office365CalendarSubscriptionAdapter implements ICalendarSubscriptionPort { @@ -148,12 +148,12 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti const now = dayjs().startOf("day"); const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day"); - const start = now.toISOString(); - const end = monthsAhead.toISOString(); + const startDateTime = now.toISOString(); + const endDateTime = monthsAhead.toISOString(); let next: | string - | null = `/me/calendars/${selectedCalendar.externalId}/events?$filter=start/dateTime ge '${start}' and start/dateTime le '${end}'&$orderby=start/dateTime asc`; + | null = `/me/calendars/${selectedCalendar.externalId}/calendarView/delta?startDateTime=${startDateTime}&endDateTime=${endDateTime}`; log.info("Initial fetch", { url: next }); @@ -164,10 +164,9 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti next ); items.push(...response.value); + deltaLink = response["@odata.deltaLink"] ?? deltaLink; next = response["@odata.nextLink"] ?? null; } - - deltaLink = `/me/calendars/${selectedCalendar.externalId}/events/delta`; } return { @@ -291,7 +290,7 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti if (!res.ok) { const text = await res.text().catch(() => ""); log.error("Graph API error", { method, url, status: res.status, text }); - throw new Error(`Graph ${res.status} ${res.statusText}: ${text}`); + throw new Error(`Graph ${res.status} ${res.statusText}`); } return method === "DELETE" || res.status === 204 ? ({} as T) : await res.json(); diff --git a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts index 0d37211ed7705c..ff75976946c723 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/AdaptersFactory.test.ts @@ -42,13 +42,13 @@ describe("DefaultAdapterFactory", () => { test("should return all available providers", () => { const providers = factory.getProviders(); - expect(providers).toEqual(["google_calendar", "office365_calendar"]); + expect(providers).toEqual(["google_calendar"]); }); test("should return array with correct length", () => { const providers = factory.getProviders(); - expect(providers).toHaveLength(2); + expect(providers).toHaveLength(1); }); }); }); diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts index a8687191187591..03f37fc18e16d9 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -2,10 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; import type { SelectedCalendar } from "@calcom/prisma/client"; -import { - MicrosoftGraphEvent, - Office365CalendarSubscriptionAdapter, -} from "../Office365CalendarSubscription.adapter"; +import { Office365CalendarSubscriptionAdapter } from "../Office365CalendarSubscription.adapter"; const _mockSelectedCalendar: SelectedCalendar = { id: "test-calendar-id", @@ -213,7 +210,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { const res = await adapter.fetchEvents({ ..._mockSelectedCalendar, syncToken: null }, _mockCredential); expect(res.items.length).toBe(2); - expect(res.syncToken).toBe("/me/calendars/test@example.com/events/delta"); + expect(res.syncToken).toBe("done"); }); }); @@ -256,7 +253,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { describe("parseEvents", () => { test("parses events correctly", () => { const adapter = new Office365CalendarSubscriptionAdapter(); - const events: MicrosoftGraphEvent[] = [ + const events = [ { id: "1", subject: "Meeting", @@ -273,7 +270,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { test("handles events with different showAs values", () => { const adapter = new Office365CalendarSubscriptionAdapter(); - const events: MicrosoftGraphEvent[] = [ + const events = [ { id: "1", showAs: "free" }, { id: "2", showAs: "tentative" }, { id: "3", showAs: "oof" }, @@ -288,7 +285,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { test("handles cancelled events", () => { const adapter = new Office365CalendarSubscriptionAdapter(); - const events: MicrosoftGraphEvent[] = [ + const events = [ { id: "1", isCancelled: true }, { id: "2", isCancelled: false }, ]; @@ -299,7 +296,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { test("handles all-day events", () => { const adapter = new Office365CalendarSubscriptionAdapter(); - const events: MicrosoftGraphEvent[] = [ + const events = [ { id: "1", isAllDay: true, start: { date: "2025-10-18" }, end: { date: "2025-10-19" } }, ]; const parsed = adapter["parseEvents"](events); @@ -308,7 +305,7 @@ describe("Office365CalendarSubscriptionAdapter", () => { test("filters out events without id", () => { const adapter = new Office365CalendarSubscriptionAdapter(); - const events: MicrosoftGraphEvent[] = [{ id: "" }, { id: "valid" }]; + const events = [{ id: "" }, { id: "valid" }]; const parsed = adapter["parseEvents"](events); expect(parsed.length).toBe(1); expect(parsed[0].id).toBe("valid"); diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts index 78e9ae5933f3a3..353242a5ab1fee 100644 --- a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -320,7 +320,7 @@ describe("CalendarSubscriptionService", () => { const _result = await service.processWebhook("google_calendar", mockRequest); - expect(_result).toBeUndefined(); + expect(_result).toBeNull(); }); }); From 4594507cb41d4d8dd6c4d34c53fd08b7834f775f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:07:18 +0000 Subject: [PATCH 14/14] fix: address review feedback for Office365 calendar subscription adapter - Fix webhookUrl to return null when env vars are unset instead of constructing an invalid URL with 'undefined' prefix - Fix validate() to handle Microsoft Graph validationToken handshake (GET ?validationToken=...) before checking clientState - Fix MICROSOFT_WEBHOOK_URL test env stub to use base URL (adapter appends the webhook path) - Save/restore original global.fetch in test afterEach to prevent mock leakage between tests - Add test for validationToken handshake validation - Update .env.example: MS_GRAPH_CLIENT_ID/SECRET are required for Outlook calendar cache token refresh, not optional - Apply biome lint/format fixes Co-authored-by: Volnei Munhoz Co-Authored-By: unknown <> --- .env.example | 4 +-- .../Office365CalendarSubscription.adapter.ts | 26 ++++++++++++------- ...fice365CalendarSubscriptionAdapter.test.ts | 17 ++++++++---- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 953171046ddd50..67de7fbb90096b 100644 --- a/.env.example +++ b/.env.example @@ -145,9 +145,9 @@ GOOGLE_WEBHOOK_TOKEN= # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. GOOGLE_WEBHOOK_URL= -# Optional Microsoft Graph Client ID +# Microsoft Graph Client ID (required for Outlook calendar cache token refresh) MS_GRAPH_CLIENT_ID= -# Optional Microsoft Graph Client Secret +# Microsoft Graph Client Secret (required for Outlook calendar cache token refresh) MS_GRAPH_CLIENT_SECRET= # Token to verify incoming webhooks from Microsoft Calendar MICROSOFT_WEBHOOK_TOKEN= diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 0d519348ed24b8..e048c40e22c010 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -1,13 +1,13 @@ +import process from "node:process"; import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; - import type { - CalendarSubscriptionEvent, - ICalendarSubscriptionPort, - CalendarSubscriptionResult, CalendarCredential, + CalendarSubscriptionEvent, CalendarSubscriptionEventItem, + CalendarSubscriptionResult, + ICalendarSubscriptionPort, } from "../lib/CalendarSubscriptionPort.interface"; import { CalendarCacheEventService } from "../lib/cache/CalendarCacheEventService"; @@ -49,13 +49,20 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti private readonly subscriptionTtlMs = SUBSCRIPTION_TTL_MS; private readonly webhookToken = process.env.MICROSOFT_WEBHOOK_TOKEN ?? null; - private readonly webhookUrl = `${ - process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL - }/api/webhooks/calendar-subscription/office365_calendar`; + private readonly webhookUrl = (() => { + const base = process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL; + return base ? `${base}/api/webhooks/calendar-subscription/office365_calendar` : null; + })(); private tokenCache = new Map(); async validate(request: Request): Promise { try { + if (request?.url) { + const urlObj = new URL(request.url); + const validationToken = urlObj.searchParams.get("validationToken"); + if (validationToken) return true; + } + const body = await request .clone() .json() @@ -151,9 +158,8 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti const startDateTime = now.toISOString(); const endDateTime = monthsAhead.toISOString(); - let next: - | string - | null = `/me/calendars/${selectedCalendar.externalId}/calendarView/delta?startDateTime=${startDateTime}&endDateTime=${endDateTime}`; + let next: string | null = + `/me/calendars/${selectedCalendar.externalId}/calendarView/delta?startDateTime=${startDateTime}&endDateTime=${endDateTime}`; log.info("Initial fetch", { url: next }); diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts index 03f37fc18e16d9..e313cdd6206ee5 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -1,7 +1,5 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; - import type { SelectedCalendar } from "@calcom/prisma/client"; - +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { Office365CalendarSubscriptionAdapter } from "../Office365CalendarSubscription.adapter"; const _mockSelectedCalendar: SelectedCalendar = { @@ -64,16 +62,18 @@ vi.mock("@calcom/lib/logger", () => ({ })); describe("Office365CalendarSubscriptionAdapter", () => { - const mockWebhookUrl = "https://example.com/api/webhooks/calendar-subscription/office365_calendar"; + const mockWebhookBaseUrl = "https://example.com"; const mockWebhookToken = "test-webhook-token"; + const originalFetch = global.fetch; beforeEach(() => { - vi.stubEnv("MICROSOFT_WEBHOOK_URL", mockWebhookUrl); + vi.stubEnv("MICROSOFT_WEBHOOK_URL", mockWebhookBaseUrl); vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", mockWebhookToken); vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", "https://app.example.com"); }); afterEach(() => { + global.fetch = originalFetch; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -97,6 +97,13 @@ describe("Office365CalendarSubscriptionAdapter", () => { expect(res).toBe(false); }); + test("returns true for validationToken handshake", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com?validationToken=abc123"); + const res = await adapter.validate(req); + expect(res).toBe(true); + }); + test("returns false if MICROSOFT_WEBHOOK_TOKEN is missing", async () => { vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", ""); const adapter = new Office365CalendarSubscriptionAdapter();