diff --git a/.env.example b/.env.example index f01c94434413e6..31d9cb4ffd2131 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,11 @@ GOOGLE_WEBHOOK_TOKEN= # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. GOOGLE_WEBHOOK_URL= +# Token to verify incoming webhooks from Microsoft Outlook +OUTLOOK_WEBHOOK_TOKEN= +# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. +OUTLOOK_WEBHOOK_URL= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= diff --git a/apps/api/v1/test/lib/selected-calendars/_post.test.ts b/apps/api/v1/test/lib/selected-calendars/_post.test.ts index 5e23d003a257fd..1132790efc17d4 100644 --- a/apps/api/v1/test/lib/selected-calendars/_post.test.ts +++ b/apps/api/v1/test/lib/selected-calendars/_post.test.ts @@ -89,6 +89,8 @@ describe("POST /api/selected-calendars", () => { googleChannelResourceUri: null, googleChannelExpiration: null, error: null, + outlookSubscriptionExpiration: null, + outlookSubscriptionId: null, lastErrorAt: null, watchAttempts: 0, maxAttempts: 3, @@ -134,6 +136,8 @@ describe("POST /api/selected-calendars", () => { domainWideDelegationCredentialId: null, eventTypeId: null, error: null, + outlookSubscriptionExpiration: null, + outlookSubscriptionId: null, lastErrorAt: null, watchAttempts: 0, maxAttempts: 3, diff --git a/packages/app-store/office365calendar/api/index.ts b/packages/app-store/office365calendar/api/index.ts index eb12c1b4ed2c4f..567bbf79794bf8 100644 --- a/packages/app-store/office365calendar/api/index.ts +++ b/packages/app-store/office365calendar/api/index.ts @@ -1,2 +1,3 @@ export { default as add } from "./add"; export { default as callback } from "./callback"; +export { default as webhook } from "./webhook"; diff --git a/packages/app-store/office365calendar/api/webhook.ts b/packages/app-store/office365calendar/api/webhook.ts new file mode 100644 index 00000000000000..adec495b8c92f4 --- /dev/null +++ b/packages/app-store/office365calendar/api/webhook.ts @@ -0,0 +1,106 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { uniqueBy } from "@calcom/lib/array"; +import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; + +import { getCalendar } from "../../_utils/getCalendar"; +import { graphValidationTokenChallengeSchema, changeNotificationWebhookPayloadSchema } from "../zod"; + +const log = logger.getSubLogger({ prefix: ["Office365CalendarWebhook"] }); + +interface WebhookResponse { + [key: string]: { + processed: boolean; + message?: string; + }; +} + +function isApiKeyValid(clientState?: string) { + return clientState === process.env.OUTLOOK_WEBHOOK_TOKEN; +} + +function getValidationToken(req: NextApiRequest) { + const graphValidationTokenChallengeParseRes = graphValidationTokenChallengeSchema.safeParse(req.query); + if (!graphValidationTokenChallengeParseRes.success) { + return null; + } + + return graphValidationTokenChallengeParseRes.data.validationToken; +} + +async function postHandler(req: NextApiRequest, res: NextApiResponse) { + const validationToken = getValidationToken(req); + if (validationToken) { + res.setHeader("Content-Type", "text/plain"); + return res.status(200).send(validationToken); + } + + const webhookBodyParseRes = changeNotificationWebhookPayloadSchema.safeParse(req.body); + + if (!webhookBodyParseRes.success) { + log.error("postHandler", safeStringify(webhookBodyParseRes.error)); + return res.status(400).json({ + message: "Invalid request body", + }); + } + + const events = webhookBodyParseRes.data.value; + const body: WebhookResponse = {}; + + const uniqueEvents = uniqueBy(events, ["subscriptionId"]); + + const promises = uniqueEvents.map(async (event) => { + if (!isApiKeyValid(event.clientState)) { + body[event.subscriptionId] = { + processed: false, + message: "Invalid API key", + }; + return; + } + + const selectedCalendar = await SelectedCalendarRepository.findByOutlookSubscriptionId( + event.subscriptionId + ); + if (!selectedCalendar) { + body[event.subscriptionId] = { + processed: false, + message: `No selected calendar found for outlookSubscriptionId: ${event.subscriptionId}`, + }; + return; + } + + const { credential } = selectedCalendar; + if (!credential) { + body[event.subscriptionId] = { + processed: false, + message: `No credential found for selected calendar for outlookSubscriptionId: ${event.subscriptionId}`, + }; + return; + } + + const { selectedCalendars } = credential; + const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id }); + const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache); + + await calendarServiceForCalendarCache?.fetchAvailabilityAndSetCache?.(selectedCalendars); + body[event.subscriptionId] = { + processed: true, + message: "ok", + }; + return; + }); + + await Promise.all(promises); + + return res.status(200).json(body); +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return postHandler(req, res); + + res.setHeader("Allow", "POST"); + return res.status(405).json({}); +} diff --git a/packages/app-store/office365calendar/lib/CalendarService.test.ts b/packages/app-store/office365calendar/lib/CalendarService.test.ts new file mode 100644 index 00000000000000..bdc0a9474365cc --- /dev/null +++ b/packages/app-store/office365calendar/lib/CalendarService.test.ts @@ -0,0 +1,934 @@ +import prismock from "../../../../tests/libs/__mocks__/prisma"; +import oAuthManagerMock from "../../tests/__mocks__/OAuthManager"; +import { eventsBatchMockResponse, getEventsBatchMockResponse } from "./__mocks__/office365apis"; + +import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta"; +import type { Mock } from "vitest"; +import { describe, test, expect, beforeEach, vi } from "vitest"; + +import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; +import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; +import logger from "@calcom/lib/logger"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; +import type { CredentialForCalendarServiceWithTenantId } from "@calcom/types/Credential"; + +import CalendarService from "./CalendarService"; + +const log = logger.getSubLogger({ prefix: ["Office365CalendarService.test"] }); + +vi.stubEnv("OUTLOOK_WEBHOOK_TOKEN", "test-webhook-token"); + +const office365calendarTestCredentialKey = { + scope: "https://graph.microsoft.com/.default", + token_type: "Bearer", + expiry_date: 1625097600000, + access_token: "", + refresh_token: "", +}; + +const getSampleCredential = () => { + return { + invalid: false, + key: office365calendarTestCredentialKey, + type: "office365_calendar", + }; +}; + +function createInMemoryCredential({ + userId, + delegationCredentialId, + delegatedTo, +}: { + userId: number; + delegationCredentialId: string | null; + delegatedTo: NonNullable; +}) { + return { + id: -1, + userId, + key: { + access_token: "NOOP_UNUSED_DELEGATION_TOKEN", + }, + invalid: false, + teamId: null, + team: null, + type: "office365_calendar", + appId: "office365_calendar", + delegatedToId: delegationCredentialId, + delegatedTo: delegatedTo.serviceAccountKey + ? { + serviceAccountKey: delegatedTo.serviceAccountKey, + } + : null, + }; +} + +async function createCredentialForCalendarService({ + user = undefined, + delegatedTo = null, + delegationCredentialId = null, +}: { + user?: { email: string | null }; + delegatedTo?: NonNullable | null; + delegationCredentialId?: string | null; +} = {}): Promise { + const defaultUser = await prismock.user.create({ + data: { + email: user?.email ?? "", + }, + }); + + const app = await prismock.app.create({ + data: { + slug: "office365_calendar", + dirName: "office365_calendar", + }, + }); + + const credential = { + ...getSampleCredential(), + ...(delegationCredentialId ? { delegationCredential: { connect: { id: delegationCredentialId } } } : {}), + key: { + ...office365calendarTestCredentialKey, + expiry_date: Date.now() - 1000, + }, + }; + + const credentialInDb = !delegatedTo + ? await prismock.credential.create({ + data: { + ...credential, + user: { + connect: { + id: defaultUser.id, + }, + }, + app: { + connect: { + slug: app.slug, + }, + }, + }, + include: { + user: true, + }, + }) + : createInMemoryCredential({ + userId: defaultUser.id, + delegationCredentialId, + delegatedTo, + }); + + return { + ...credentialInDb, + user: user ? { email: user.email ?? "" } : null, + delegatedTo, + } as CredentialForCalendarServiceWithTenantId; +} + +const testSelectedCalendar = { + userId: 1, + integration: "office365_calendar", + externalId: "example@cal.com", +}; + +vi.mock("@calcom/features/flags/features.repository", () => ({ + FeaturesRepository: vi.fn().mockImplementation(() => ({ + checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + })), +})); + +let requestRawSpyInstance: Mock; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let updateTokenObject: any; + +beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn().mockImplementation((url: string) => { + log.debug("Mocked request URL:", url); + if (url.includes("/token")) { + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + clone: vi.fn().mockImplementation(() => ({ + json: async () => ({ + access_token: "mock-access-token", + }), + })), + json: async () => ({ + access_token: "mock-access-token", + }), + }); + } + + if (url.includes("/users?")) { + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + json: async () => ({ + value: [{ id: "mock-user-id" }], + }), + }); + } + }); + oAuthManagerMock.OAuthManager = vi.fn().mockImplementation((arg) => { + updateTokenObject = arg.updateTokenObject; + const requestRawSpy = vi.fn().mockImplementation(({ url }: { url: string; options?: RequestInit }) => { + log.debug("Mocked request URL:", url); + if (url.includes("/$batch")) { + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + json: async () => Promise.resolve(JSON.stringify({ responses: eventsBatchMockResponse })), + }); + } + + if (url.includes("/subscriptions")) { + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + json: async () => ({ + id: "mock-subscription-id", + expirationDateTime: "11111111111111", + }), + }); + } + + if (url.includes("/calendars")) { + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + json: async (): Promise<{ value: OfficeCalendar[] }> => ({ + value: [ + { + id: "mock-calendar-id", + name: "Mock Calendar", + }, + ], + }), + }); + } + + if (url.includes("/me") || url.includes("/users/")) { + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + json: async (): Promise => ({ + mail: "example@cal.com", + }), + }); + } + + return Promise.resolve({ + status: 404, + headers: new Map([["Content-Type", "application/json"]]), + json: async () => ({ message: "Not Found" }), + }); + }); + + requestRawSpyInstance = requestRawSpy; + return { + requestRaw: requestRawSpy, + getTokenObjectOrFetch: vi.fn().mockImplementation(() => { + return { + token: { + access_token: "FAKE_ACCESS_TOKEN", + }, + }; + }), + request: vi.fn().mockResolvedValue({ + json: [], + }), + }; + }); +}); + +describe("CalendarCache", () => { + test("Calendar Cache is being read on cache HIT", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const dateFrom1 = new Date().toISOString(); + const dateTo1 = new Date().toISOString(); + + const mockedFreeBusyTimes: BufferedBusyTime[] = [ + { + start: "2025-05-02T07:00:00.0000000Z", + end: "2025-05-02T07:30:00.0000000Z", + }, + ]; + + const calendarCache = await CalendarCache.init(null); + await calendarCache.upsertCachedAvailability({ + credentialId: credentialInDb.id, + userId: credentialInDb.userId, + args: { + timeMin: getTimeMin(dateFrom1), + timeMax: getTimeMax(dateTo1), + items: [{ id: testSelectedCalendar.externalId }], + }, + value: JSON.parse(JSON.stringify(mockedFreeBusyTimes)), + }); + + const calendarService = new CalendarService(credentialInDb); + + // Test cache hit + const data = await calendarService.getAvailability(dateFrom1, dateTo1, [testSelectedCalendar], true); + expect(data).toEqual(mockedFreeBusyTimes); + }); + + test("Calendar Cache is being ignored on cache MISS", async () => { + const calendarCache = await CalendarCache.init(null); + const credentialInDb = await createCredentialForCalendarService(); + const dateFrom = new Date(Date.now()).toISOString(); + // Tweak date so that it's a cache miss + const dateTo = new Date(Date.now() + 100000000).toISOString(); + const calendarService = new CalendarService(credentialInDb); + + // Test Cache Miss + await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar], true); + + // Expect cache to be ignored in case of a MISS + const cachedAvailability = await calendarCache.getCachedAvailability({ + credentialId: credentialInDb.id, + userId: credentialInDb.userId, + args: { + timeMin: dateFrom, + timeMax: dateTo, + items: [{ id: testSelectedCalendar.externalId }], + }, + }); + + expect(cachedAvailability).toBeNull(); + }); + + test("fetchAvailabilityAndSetCache should fetch and cache availability for selected calendars grouped by eventTypeId", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + + const selectedCalendars = [ + { + externalId: "calendar1@test.com", + eventTypeId: 1, + }, + { + externalId: "calendar2@test.com", + eventTypeId: 1, + }, + { + externalId: "calendar1@test.com", + eventTypeId: 2, + }, + { + externalId: "calendar1@test.com", + eventTypeId: null, + }, + { + externalId: "calendar2@test.com", + eventTypeId: null, + }, + ]; + + const mockedFreeBusyTimes: BufferedBusyTime[] = [ + { + start: "2025-05-02T07:00:00.0000000", + end: "2025-05-02T07:30:00.0000000", + }, + ]; + + vi.spyOn(calendarService, "fetchAvailability").mockResolvedValue( + getEventsBatchMockResponse({ + calendarIds: [selectedCalendars[0].externalId], + startDateTime: mockedFreeBusyTimes[0].start as string, + endDateTime: mockedFreeBusyTimes[0].end as string, + }) + ); + const setAvailabilityInCacheSpy = vi.spyOn(calendarService, "setAvailabilityInCache"); + + await calendarService.fetchAvailabilityAndSetCache(selectedCalendars); + + // Should make 2 calls - one for each unique eventTypeId + expect(calendarService.fetchAvailability).toHaveBeenCalledTimes(3); + + // First call for eventTypeId 1 calendars + expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(1, { + timeMin: expect.any(String), + timeMax: expect.any(String), + items: [{ id: "calendar1@test.com" }, { id: "calendar2@test.com" }], + }); + + // Second call for eventTypeId 2 calendar + expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(2, { + timeMin: expect.any(String), + timeMax: expect.any(String), + items: [{ id: "calendar1@test.com" }], + }); + + // Second call for eventTypeId 2 calendar + expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(3, { + timeMin: expect.any(String), + timeMax: expect.any(String), + items: [{ id: "calendar1@test.com" }, { id: "calendar2@test.com" }], + }); + + // Should cache results for both calls + expect(setAvailabilityInCacheSpy).toHaveBeenCalledTimes(3); + expect(setAvailabilityInCacheSpy).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.any(Array), + }), + mockedFreeBusyTimes.map((time) => ({ + start: `${time.start}Z`, + end: `${time.end}Z`, + })) + ); + }); + + test("A cache set through fetchAvailabilityAndSetCache should be used when doing getAvailability", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + vi.setSystemTime(new Date("2025-04-01T00:00:00.000Z")); + const selectedCalendars = [ + { + externalId: "calendar1@test.com", + integration: "office365_calendar", + eventTypeId: null, + credentialId: credentialInDb.id, + userId: credentialInDb.userId as number, + }, + ]; + + const calendarCachesBefore = await prismock.calendarCache.findMany(); + expect(calendarCachesBefore).toHaveLength(0); + + const dateFromToCompare = "2025-05-02T07:00:00.0000000"; + const dateToToCompare = "2025-05-02T07:30:00.0000000"; + + vi.spyOn(calendarService, "fetchAvailability").mockResolvedValue( + getEventsBatchMockResponse({ + calendarIds: selectedCalendars.map((calendar) => calendar.externalId), + startDateTime: dateFromToCompare, + endDateTime: dateToToCompare, + }) + ); + await calendarService.fetchAvailabilityAndSetCache(selectedCalendars); + const calendarCachesAfter = await prismock.calendarCache.findMany(); + console.log({ calendarCachesAfter }); + expect(calendarCachesAfter).toHaveLength(1); + const dateFrom = "2025-04-01T00:00:00.000Z"; + const dateTo = "2025-06-01T00:00:00.000Z"; + const result = await calendarService.getAvailability(dateFrom, dateTo, selectedCalendars, true); + expect(result).toEqual([{ start: `${dateFromToCompare}Z`, end: `${dateToToCompare}Z` }]); + }); +}); + +const defaultDelegatedCredential = { + serviceAccountKey: { + client_id: "service-client-id", + private_key: "service-private-key", + tenant_id: "service-tenant-id", + }, +} as const; + +async function createDelegationCredentialForCalendarService({ + user, + delegatedTo, + delegationCredentialId, +}: { + user?: { email: string } | null; + delegatedTo?: typeof defaultDelegatedCredential; + delegationCredentialId: string; +}) { + return await createCredentialForCalendarService({ + user: user || { + email: "service@example.com", + }, + delegatedTo: delegatedTo || defaultDelegatedCredential, + delegationCredentialId, + }); +} + +async function expectSelectedCalendarToHaveOutlookSubscriptionProps( + id: string, + outlookSubscriptionProps: { + outlookSubscriptionId: string; + outlookSubscriptionExpiration: string; + } +) { + const selectedCalendar = await SelectedCalendarRepository.findById(id); + + expect(selectedCalendar).toEqual(expect.objectContaining(outlookSubscriptionProps)); +} + +async function createDelegationCredentialForCalendarCache({ + user, + delegatedTo, + delegationCredentialId, +}: { + user?: { email: string } | null; + delegatedTo?: typeof defaultDelegatedCredential; + delegationCredentialId: string; +}) { + delegatedTo = delegatedTo || defaultDelegatedCredential; + const credentialInDb = await createCredentialForCalendarService({ + user: user || { + email: "service@example.com", + }, + }); + + return { + ...createInMemoryCredential({ + userId: credentialInDb.userId as number, + delegationCredentialId, + delegatedTo, + }), + ...credentialInDb, + }; +} + +async function createSelectedCalendarForDelegationCredential(data: { + userId: number; + credentialId: number | null; + delegationCredentialId: string; + externalId: string; + integration: string; + outlookSubscriptionId: string | null; + outlookSubscriptionExpiration: string | null; +}) { + if (!data.delegationCredentialId) { + throw new Error("delegationCredentialId is required"); + } + return await prismock.selectedCalendar.create({ + data: { + ...data, + }, + }); +} + +async function expectSelectedCalendarToNotHaveOutlookSubscriptionProps(id: string) { + const selectedCalendar = await SelectedCalendarRepository.findById(id); + + expect(selectedCalendar).toEqual( + expect.objectContaining({ + outlookSubscriptionId: null, + outlookSubscriptionExpiration: null, + }) + ); +} + +describe("Watching and unwatching calendar", () => { + test("Calendar can be watched and unwatched", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + + await calendarService.watchCalendar({ + calendarId: testSelectedCalendar.externalId, + eventTypeIds: [null], + }); + + /** + * Watching a non-existent selectedCalendar creates it, + * not sure if this is the expected behavior + */ + const watchedCalendar = await prismock.selectedCalendar.findFirst({ + where: { + userId: credentialInDb.userId as number, + externalId: testSelectedCalendar.externalId, + integration: "office365_calendar", + }, + }); + + expect(watchedCalendar).toEqual( + expect.objectContaining({ + userId: testSelectedCalendar.userId, + eventTypeId: null, + integration: testSelectedCalendar.integration, + externalId: testSelectedCalendar.externalId, + credentialId: 1, + delegationCredentialId: null, + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "11111111111111", + }) + ); + + expect(watchedCalendar?.id).toBeDefined(); + + await calendarService.unwatchCalendar({ + calendarId: testSelectedCalendar.externalId, + eventTypeIds: [null], + }); + const calendarAfterUnwatch = await prismock.selectedCalendar.findFirst({ + where: { + userId: credentialInDb.userId as number, + externalId: testSelectedCalendar.externalId, + integration: "office365_calendar", + }, + }); + + expect(calendarAfterUnwatch).toEqual( + expect.objectContaining({ + userId: 1, + eventTypeId: null, + integration: "office365_calendar", + externalId: "example@cal.com", + credentialId: 1, + delegationCredentialId: null, + outlookSubscriptionId: null, + outlookSubscriptionExpiration: null, + }) + ); + expect(calendarAfterUnwatch?.id).toBeDefined(); + }); + + test("watchCalendar should not do outlook subscription if already subscribed for the same calendarId", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb.id); + const userLevelCalendar = await SelectedCalendarRepository.create({ + userId: credentialInDb.userId as number, + externalId: "externalId@cal.com", + integration: "office365_calendar", + eventTypeId: null, + credentialId: credentialInDb.id, + }); + + const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ + userId: credentialInDb.userId as number, + externalId: "externalId@cal.com", + integration: "office365_calendar", + eventTypeId: 1, + credentialId: credentialInDb.id, + }); + + await calendarCache.watchCalendar({ + calendarId: userLevelCalendar.externalId, + eventTypeIds: [userLevelCalendar.eventTypeId], + }); + + /** + * Should call /subscriptions to create a subscription + * for the first time + */ + expect(requestRawSpyInstance).toHaveBeenCalledTimes(1); + + await expectSelectedCalendarToHaveOutlookSubscriptionProps(userLevelCalendar.id, { + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "11111111111111", + }); + + // Watch different selectedcalendar with same externalId and credentialId + await calendarCache.watchCalendar({ + calendarId: eventTypeLevelCalendar.externalId, + eventTypeIds: [eventTypeLevelCalendar.eventTypeId], + }); + + /** + * Should not call /subscriptions again, as the calendar is already subscribed + */ + expect(requestRawSpyInstance).toHaveBeenCalledTimes(1); + + await expectSelectedCalendarToHaveOutlookSubscriptionProps(eventTypeLevelCalendar.id, { + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "11111111111111", + }); + }); + + test("watchCalendar should do outlook subscription if already subscribed but for different calendarId", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb.id); + const userLevelCalendar = await SelectedCalendarRepository.create({ + userId: credentialInDb.userId as number, + externalId: "externalId@cal.com", + integration: "office365_calendar", + eventTypeId: null, + credentialId: credentialInDb.id, + }); + + const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ + userId: credentialInDb.userId as number, + externalId: "externalId2@cal.com", + integration: "office365_calendar", + eventTypeId: 1, + credentialId: credentialInDb.id, + }); + + await calendarCache.watchCalendar({ + calendarId: userLevelCalendar.externalId, + eventTypeIds: [userLevelCalendar.eventTypeId], + }); + + /** + * Should call /subscriptions to create a subscription + * for the first time + */ + expect(requestRawSpyInstance).toHaveBeenCalledTimes(1); + + await expectSelectedCalendarToHaveOutlookSubscriptionProps(userLevelCalendar.id, { + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "11111111111111", + }); + + // Watch different selectedcalendar with same externalId and credentialId + await calendarCache.watchCalendar({ + calendarId: eventTypeLevelCalendar.externalId, + eventTypeIds: [eventTypeLevelCalendar.eventTypeId], + }); + + /** + * Should call /subscriptions again, as the calendar is different + */ + expect(requestRawSpyInstance).toHaveBeenCalledTimes(2); + + await expectSelectedCalendarToHaveOutlookSubscriptionProps(eventTypeLevelCalendar.id, { + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "11111111111111", + }); + }); + + async function expectCacheToBeSet({ + credentialId, + itemsInKey, + }: { + credentialId: number; + itemsInKey: { id: string }[]; + }) { + const caches = await prismock.calendarCache.findMany({ + where: { + credentialId, + }, + }); + expect(caches).toHaveLength(1); + expect(JSON.parse(caches[0].key)).toEqual( + expect.objectContaining({ + items: itemsInKey, + }) + ); + } + + test("unwatchCalendar should not unsubscribe from outlook if there is another selectedCalendar with same externalId and credentialId", async () => { + const credentialInDb1 = await createCredentialForCalendarService(); + const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); + + await prismock.calendarCache.create({ + data: { + key: "test-key", + value: "test-value", + expiresAt: new Date(Date.now() + 100000000), + credentialId: credentialInDb1.id, + }, + }); + + const someOtherCache = await prismock.calendarCache.create({ + data: { + key: JSON.stringify({ + items: [{ id: "someOtherExternalId@cal.com" }], + }), + value: "test-value-2", + expiresAt: new Date(Date.now() + 100000000), + credentialId: 999, + }, + }); + + const commonProps = { + userId: credentialInDb1.userId as number, + externalId: "externalId@cal.com", + integration: "office365_calendar", + credentialId: credentialInDb1.id, + }; + + const userLevelCalendar = await SelectedCalendarRepository.create({ + ...commonProps, + outlookSubscriptionId: "user-level-id", + outlookSubscriptionExpiration: "11111111111111", + eventTypeId: null, + }); + + const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ + ...commonProps, + outlookSubscriptionId: "event-type-level-id", + outlookSubscriptionExpiration: "11111111111111", + eventTypeId: 1, + }); + + const eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId = + await SelectedCalendarRepository.create({ + ...commonProps, + outlookSubscriptionId: "other-external-id-but-same-credential-id", + outlookSubscriptionExpiration: "11111111111111", + externalId: "externalId2@cal.com", + eventTypeId: 2, + }); + + await calendarCache.unwatchCalendar({ + calendarId: userLevelCalendar.externalId, + eventTypeIds: [userLevelCalendar.eventTypeId], + }); + + // There is another selectedCalendar with same externalId and credentialId, so actual unsubscription does not happen + expect(requestRawSpyInstance).toHaveBeenCalledTimes(0); + await expectSelectedCalendarToNotHaveOutlookSubscriptionProps(userLevelCalendar.id); + + await calendarCache.unwatchCalendar({ + calendarId: eventTypeLevelCalendar.externalId, + eventTypeIds: [eventTypeLevelCalendar.eventTypeId], + }); + + /** + * Will be called twice, because we are unsubscribing from + * all calendar with same externalId + */ + expect(requestRawSpyInstance).toHaveBeenCalledTimes(2); + + // Concerned cache will just have remaining externalIds + await expectCacheToBeSet({ + credentialId: credentialInDb1.id, + itemsInKey: [{ id: eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.externalId }], + }); + + await expectCacheToBeSet({ + credentialId: someOtherCache.credentialId, + itemsInKey: JSON.parse(someOtherCache.key).items, + }); + + await expectSelectedCalendarToNotHaveOutlookSubscriptionProps(eventTypeLevelCalendar.id); + + // Some other selectedCalendar stays unaffected + await expectSelectedCalendarToHaveOutlookSubscriptionProps( + eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.id, + { + outlookSubscriptionId: "other-external-id-but-same-credential-id", + outlookSubscriptionExpiration: "11111111111111", + } + ); + }); + + describe("Delegation Credential", () => { + test("On watching a SelectedCalendar having delegationCredential, it should set outlookSubscriptionId and other props", async () => { + const delegationCredential1Member = await createDelegationCredentialForCalendarService({ + user: { email: "user1@example.com" }, + delegationCredentialId: "delegation-credential-id-1", + }); + + await prismock.selectedCalendar.create({ + data: { + userId: delegationCredential1Member.userId as number, + externalId: testSelectedCalendar.externalId, + integration: "office365_calendar", + }, + }); + + const calendarService = new CalendarService(delegationCredential1Member); + await calendarService.watchCalendar({ + calendarId: testSelectedCalendar.externalId, + eventTypeIds: [null], + }); + + const calendars = await prismock.selectedCalendar.findMany(); + // Ensure no new calendar is created + expect(calendars).toHaveLength(1); + const watchedCalendar = calendars[0]; + + await expectSelectedCalendarToHaveOutlookSubscriptionProps(watchedCalendar.id, { + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "11111111111111", + }); + }); + + test("On unwatching a SelectedCalendar connected to Delegation Credential, it should remove outlookSubscriptionId and other props", async () => { + const delegationCredential1Member1 = await createDelegationCredentialForCalendarCache({ + user: { email: "user1@example.com" }, + delegationCredentialId: "delegation-credential-id-1", + }); + + const selectedCalendar = await createSelectedCalendarForDelegationCredential({ + userId: delegationCredential1Member1.userId as number, + delegationCredentialId: delegationCredential1Member1.delegatedToId as string, + credentialId: delegationCredential1Member1.id, + externalId: testSelectedCalendar.externalId, + integration: "office365_calendar", + outlookSubscriptionId: "mock-subscription-id", + outlookSubscriptionExpiration: "1111111111", + }); + + const calendarService = new CalendarService(delegationCredential1Member1); + await calendarService.unwatchCalendar({ + calendarId: selectedCalendar.externalId, + eventTypeIds: [null], + }); + + const calendars = await prismock.selectedCalendar.findMany(); + expect(calendars).toHaveLength(1); + const calendarAfterUnwatch = calendars[0]; + + expectSelectedCalendarToNotHaveOutlookSubscriptionProps(calendarAfterUnwatch.id); + }); + }); +}); + +test("`updateTokenObject` should update credential in DB", async () => { + const credentialInDb = await createCredentialForCalendarService(); + + const newTokenObject = { + access_token: "NEW_FAKE_ACCESS_TOKEN", + }; + + // Scenario: OAuthManager causes `updateTokenObject` to be called + await updateTokenObject(newTokenObject); + + const newCredential = await prismock.credential.findFirst({ + where: { + id: credentialInDb.id, + }, + }); + + // Expect update in DB + expect(newCredential).toEqual( + expect.objectContaining({ + key: newTokenObject, + }) + ); +}); + +// eslint-disable-next-line playwright/no-skipped-test -- TODO: Handle errors and create tests +describe.skip("Delegation Credential Error handling"); + +describe("getAvailability", () => { + test("returns availability for selected calendars", async () => { + const credential = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credential); + const mockedBusyTimes = [ + { + start: "2025-05-02", + end: "2025-05-02", + }, + ]; + + const currentRequestRawSpyImplementation = requestRawSpyInstance.getMockImplementation(); + + requestRawSpyInstance.mockImplementation(({ url }: { url: string; options?: RequestInit }) => { + if (url.includes("/$batch")) { + log.debug("Mocked request URL:", url); + return Promise.resolve({ + status: 200, + headers: new Map([["Content-Type", "application/json"]]), + json: async () => + Promise.resolve( + JSON.stringify({ + responses: getEventsBatchMockResponse({ + calendarIds: ["example@cal.com"], + endDateTime: mockedBusyTimes[0].end, + startDateTime: mockedBusyTimes[0].start, + }), + }) + ), + }); + } + + return currentRequestRawSpyImplementation?.({ url }); + }); + + const availability = await calendarService.getAvailability("2024-05-01", "2026-05-03", [], false); + + expect(availability).toEqual([ + { start: `${mockedBusyTimes[0].start}Z`, end: `${mockedBusyTimes[0].end}Z` }, + ]); + }); +}); diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index 2ee9990f635d83..3d7b9fb86fdde7 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -1,7 +1,11 @@ import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta"; +import { type Prisma } from "@prisma/client"; import type { DefaultBodyType } from "msw"; import dayjs from "@calcom/dayjs"; +import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; +import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface"; +import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; import { getLocation } from "@calcom/lib/CalEventParser"; import { CalendarAppDelegationCredentialInvalidGrantError, @@ -9,11 +13,15 @@ import { } from "@calcom/lib/CalendarAppError"; import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import prisma from "@calcom/prisma"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; import type { Calendar, CalendarServiceEvent, EventBusyDate, + SelectedCalendarEventTypeIds, IntegrationCalendar, NewCalendarEventType, } from "@calcom/types/Calendar"; @@ -23,6 +31,7 @@ import { OAuthManager } from "../../_utils/oauth/OAuthManager"; import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper"; import metadata from "../_metadata"; +import { startWatchingCalendarResponseSchema } from "../zod"; import { getOfficeAppKeys } from "./getOfficeAppKeys"; interface IRequest { @@ -31,7 +40,7 @@ interface IRequest { id: number; } -interface ISettledResponse { +export interface ISettledResponse { id: string; status: number; headers: { @@ -51,6 +60,15 @@ interface BodyValue { start: { dateTime: string }; } +// eslint-disable-next-line turbo/no-undeclared-env-vars -- OUTLOOK_WEBHOOK_URL only for local testing +const OUTLOOK_WEBHOOK_URL_BASE = process.env.OUTLOOK_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL; +const OUTLOOK_WEBHOOK_URL = `${OUTLOOK_WEBHOOK_URL_BASE}/api/integrations/office365calendar/webhook`; + +type OutlookSubscriptionProps = { + id?: string | null; + expiration?: string | null; +}; + export default class Office365CalendarService implements Calendar { private url = ""; private integrationName = ""; @@ -295,13 +313,9 @@ export default class Office365CalendarService implements Calendar { } } - async getAvailability( - dateFrom: string, - dateTo: string, - selectedCalendars: IntegrationCalendar[] - ): Promise { - const dateFromParsed = new Date(dateFrom); - const dateToParsed = new Date(dateTo); + async fetchAvailability(args: FreeBusyArgs): Promise { + const dateFromParsed = new Date(args.timeMin); + const dateToParsed = new Date(args.timeMax); const filter = `?startDateTime=${encodeURIComponent( dateFromParsed.toISOString() @@ -309,6 +323,72 @@ export default class Office365CalendarService implements Calendar { const calendarSelectParams = "$select=showAs,start,end"; + const selectedCalendarIds = args.items.map((item) => item.id); + + const ids = await (selectedCalendarIds.length === 0 + ? this.listCalendars().then((cals) => cals.map((e_2) => e_2.externalId).filter(Boolean) || []) + : Promise.resolve(selectedCalendarIds)); + const requestsPromises = ids.map(async (calendarId, id) => ({ + id, + method: "GET", + url: `${await this.getUserEndpoint()}/calendars/${calendarId}/calendarView${filter}&${calendarSelectParams}`, + })); + const requests = await Promise.all(requestsPromises); + const response = await this.apiGraphBatchCall(requests); + const responseBody = await this.handleErrorJsonOffice365Calendar(response); + let responseBatchApi: IBatchResponse = { responses: [] }; + if (typeof responseBody === "string") { + responseBatchApi = this.handleTextJsonResponseWithHtmlInBody(responseBody); + } + let alreadySuccessResponse = [] as ISettledResponse[]; + + // Validate if any 429 status Retry-After is present + const retryAfter = + !!responseBatchApi?.responses && this.findRetryAfterResponse(responseBatchApi.responses); + + if (retryAfter && responseBatchApi.responses) { + responseBatchApi = await this.fetchRequestWithRetryAfter(requests, responseBatchApi.responses, 2); + } + + // Recursively fetch nextLink responses + alreadySuccessResponse = await this.fetchResponsesWithNextLink(responseBatchApi.responses); + return alreadySuccessResponse ? alreadySuccessResponse : []; + } + + async getFreeBusyResult(args: FreeBusyArgs, shouldServeCache?: boolean): Promise { + if (shouldServeCache === false) return this.processBusyTimes(await this.fetchAvailability(args)); + + const calendarCache = await CalendarCache.init(null); + const cached = await calendarCache.getCachedAvailability({ + credentialId: this.credential.id, + userId: this.credential.userId, + args: { + timeMin: getTimeMin(args.timeMin), + timeMax: getTimeMax(args.timeMax), + items: args.items, + }, + }); + if (cached) { + this.log.debug("[Cache Hit] Returning cached freebusy result", safeStringify({ cached, args })); + return cached.value as unknown as BufferedBusyTime[]; + } + this.log.debug("[Cache Miss] Fetching freebusy result", safeStringify({ args })); + return this.processBusyTimes(await this.fetchAvailability(args)); + } + + async getCacheOrFetchAvailability( + args: FreeBusyArgs, + shouldServeCache?: boolean + ): Promise { + return await this.getFreeBusyResult(args, shouldServeCache); + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[], + shouldServeCache?: boolean + ): Promise { try { const selectedCalendarIds = selectedCalendars.reduce((calendarIds, calendar) => { if (calendar.integration === this.integrationName && calendar.externalId) @@ -322,35 +402,14 @@ export default class Office365CalendarService implements Calendar { return Promise.resolve([]); } - const ids = await (selectedCalendarIds.length === 0 - ? this.listCalendars().then((cals) => cals.map((e_2) => e_2.externalId).filter(Boolean) || []) - : Promise.resolve(selectedCalendarIds)); - const requestsPromises = ids.map(async (calendarId, id) => ({ - id, - method: "GET", - url: `${await this.getUserEndpoint()}/calendars/${calendarId}/calendarView${filter}&${calendarSelectParams}`, - })); - const requests = await Promise.all(requestsPromises); - const response = await this.apiGraphBatchCall(requests); - const responseBody = await this.handleErrorJsonOffice365Calendar(response); - let responseBatchApi: IBatchResponse = { responses: [] }; - if (typeof responseBody === "string") { - responseBatchApi = this.handleTextJsonResponseWithHtmlInBody(responseBody); - } - let alreadySuccessResponse = [] as ISettledResponse[]; - - // Validate if any 429 status Retry-After is present - const retryAfter = - !!responseBatchApi?.responses && this.findRetryAfterResponse(responseBatchApi.responses); - - if (retryAfter && responseBatchApi.responses) { - responseBatchApi = await this.fetchRequestWithRetryAfter(requests, responseBatchApi.responses, 2); - } - - // Recursively fetch nextLink responses - alreadySuccessResponse = await this.fetchResponsesWithNextLink(responseBatchApi.responses); - - return alreadySuccessResponse ? this.processBusyTimes(alreadySuccessResponse) : []; + return this.getCacheOrFetchAvailability( + { + timeMin: dateFrom, + timeMax: dateTo, + items: selectedCalendarIds.map((id) => ({ id })), + }, + shouldServeCache + ); } catch (err) { return Promise.reject([]); } @@ -625,4 +684,260 @@ export default class Office365CalendarService implements Calendar { return response.json(); }; + + async setAvailabilityInCache(args: FreeBusyArgs, data: BufferedBusyTime[]): Promise { + this.log.debug("setAvailabilityInCache", safeStringify({ args, data })); + const calendarCache = await CalendarCache.init(null); + await calendarCache.upsertCachedAvailability({ + credentialId: this.credential.id, + userId: this.credential.userId, + args, + value: JSON.parse(JSON.stringify(data)), + }); + } + + async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) { + this.log.debug("fetchAvailabilityAndSetCache", safeStringify({ selectedCalendars })); + const selectedCalendarsPerEventType = new Map< + SelectedCalendarEventTypeIds[number], + IntegrationCalendar[] + >(); + + selectedCalendars.reduce((acc, selectedCalendar) => { + const eventTypeId = selectedCalendar.eventTypeId ?? null; + const mapValue = selectedCalendarsPerEventType.get(eventTypeId); + if (mapValue) { + mapValue.push(selectedCalendar); + } else { + acc.set(eventTypeId, [selectedCalendar]); + } + + return acc; + }, selectedCalendarsPerEventType); + + for (const [_eventTypeId, selectedCalendars] of Array.from(selectedCalendarsPerEventType.entries())) { + const parsedArgs = { + /** Expand the start date to the start of the month to increase cache hits */ + timeMin: getTimeMin(), + /** Expand the end date to the end of the month to increase cache hits */ + timeMax: getTimeMax(), + // Dont use eventTypeId in key because it can be used by any eventType + // The only reason we are building it per eventType is because there can be different groups of calendars to lookup the availability for + items: selectedCalendars.map((sc) => ({ id: sc.externalId })), + }; + const data = await this.fetchAvailability(parsedArgs); + await this.setAvailabilityInCache(parsedArgs, this.processBusyTimes(data)); + } + } + + async upsertSelectedCalendarsForEventTypeIds( + data: Omit, + eventTypeIds: SelectedCalendarEventTypeIds + ) { + this.log.debug( + "upsertSelectedCalendarsForEventTypeIds", + safeStringify({ data, eventTypeIds, credential: this.credential }) + ); + if (!this.credential.userId) { + this.log.error("upsertSelectedCalendarsForEventTypeIds failed. userId is missing."); + return; + } + + await SelectedCalendarRepository.upsertManyForEventTypeIds({ + data: { + ...data, + integration: this.integrationName, + credentialId: this.credential.id, + delegationCredentialId: this.credential.delegatedToId ?? null, + userId: this.credential.userId, + }, + eventTypeIds, + }); + } + + async startWatchingCalendarInOutlook({ calendarId }: { calendarId: string }) { + this.log.debug(`Subscribing to calendar ${calendarId}`); + + const rawRes = await this.fetcher("/subscriptions", { + method: "POST", + body: JSON.stringify({ + changeType: "created,updated,deleted", + notificationUrl: OUTLOOK_WEBHOOK_URL, + resource: `${await this.getUserEndpoint()}/calendars/${calendarId}/events`, + clientState: process.env.OUTLOOK_WEBHOOK_TOKEN, + /** + * Under 7 days + * https://learn.microsoft.com/en-us/graph/change-notifications-overview#subscription-lifetime + */ + expirationDateTime: dayjs(new Date()).add(10_000, "minutes").toISOString(), + }), + }); + const res = startWatchingCalendarResponseSchema.parse(await rawRes.json()); + return res; + } + + async stopWatchingCalendarsInOutlook(subscriptions: { outlookSubscriptionId: string | null }[]) { + const subs = subscriptions.filter((sub) => !!sub.outlookSubscriptionId); + + this.log.debug(`Unsubscribing from calendars ${subs.map((sub) => sub.outlookSubscriptionId).join(", ")}`); + return await Promise.all( + subs.map(async (sub) => { + try { + return await this.fetcher(`/subscriptions/${sub.outlookSubscriptionId}`, { + method: "DELETE", + }); + } catch (e) { + this.log.error( + `Failed to unsubscribe from calendar with subscriptionId: ${sub.outlookSubscriptionId}`, + e + ); + return; + } + }) + ); + } + + async watchCalendar({ + calendarId, + eventTypeIds, + }: { + calendarId: string; + eventTypeIds: SelectedCalendarEventTypeIds; + }) { + this.log.debug("watchCalendar", safeStringify({ calendarId, eventTypeIds })); + if (!process.env.OUTLOOK_WEBHOOK_TOKEN) { + this.log.warn("OUTLOOK_WEBHOOK_TOKEN is not set, skipping watching calendar"); + return; + } + + const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({ + where: { + credentialId: this.credential.id, + externalId: calendarId, + integration: this.integrationName, + outlookSubscriptionId: { + not: null, + }, + }, + }); + + const otherCalendarsWithSameSubscription = allCalendarsWithSubscription.filter( + (sc) => !eventTypeIds?.includes(sc.eventTypeId) + ); + + let outlookSubscriptionProps: OutlookSubscriptionProps = otherCalendarsWithSameSubscription.length + ? { + id: otherCalendarsWithSameSubscription[0].outlookSubscriptionId, + expiration: otherCalendarsWithSameSubscription[0].outlookSubscriptionExpiration, + } + : {}; + let error: string | undefined; + + if (!otherCalendarsWithSameSubscription.length) { + try { + const res = await this.startWatchingCalendarInOutlook({ calendarId }); + outlookSubscriptionProps = { + expiration: res.expirationDateTime, + id: res.id, + }; + } catch (error) { + this.log.error(`Failed to watch ${calendarId}`, error); + // We set error to prevent attempting to watch on next cron run + error = error instanceof Error ? error.message : "Unknown error"; + } + } else { + this.log.info( + `Calendar ${calendarId} is already being watched for event types ${otherCalendarsWithSameSubscription + .map((calendar) => calendar.eventTypeId) + .join(", ")}. So not watching again and instead reusing the existing subscription.` + ); + } + + await this.upsertSelectedCalendarsForEventTypeIds( + { + externalId: calendarId, + outlookSubscriptionId: outlookSubscriptionProps.id, + outlookSubscriptionExpiration: outlookSubscriptionProps.expiration, + error, + }, + eventTypeIds + ); + } + + async unwatchCalendar({ + calendarId, + eventTypeIds, + }: { + calendarId: string; + eventTypeIds: SelectedCalendarEventTypeIds; + }) { + const credentialId = this.credential.id; + const eventTypeIdsToBeUnwatched = eventTypeIds; + + const calendarsWithSameCredentialId = await SelectedCalendarRepository.findMany({ + where: { + credentialId, + }, + }); + + const calendarWithSameExternalId = calendarsWithSameCredentialId.filter( + (sc) => sc.externalId === calendarId && sc.integration === this.integrationName + ); + + const calendarsWithSameExternalIdThatAreBeingWatched = calendarWithSameExternalId.filter( + (sc) => !!sc.outlookSubscriptionId + ); + + // Except those requested to be un-watched, other calendars are still being watched + const calendarsWithSameExternalIdToBeStillWatched = calendarsWithSameExternalIdThatAreBeingWatched.filter( + (sc) => !eventTypeIdsToBeUnwatched.includes(sc.eventTypeId) + ); + + if (calendarsWithSameExternalIdToBeStillWatched.length) { + this.log.info( + `There are other ${calendarsWithSameExternalIdToBeStillWatched.length} calendars with the same externalId_credentialId. Not unwatching. Just removing the subscriptionId from this selected calendar` + ); + + // CalendarCache still need to exist + // We still need to keep the subscription + + // Just remove the outlook subscription related fields from this selected calendar + await this.upsertSelectedCalendarsForEventTypeIds( + { + externalId: calendarId, + outlookSubscriptionExpiration: null, + outlookSubscriptionId: null, + }, + eventTypeIdsToBeUnwatched + ); + return; + } + + const allChannelsForThisCalendarBeingUnwatched = calendarsWithSameExternalIdThatAreBeingWatched.map( + (sc) => ({ + outlookSubscriptionId: sc.outlookSubscriptionId, + }) + ); + + // Delete the calendar cache to force a fresh cache + await prisma.calendarCache.deleteMany({ where: { credentialId } }); + await this.stopWatchingCalendarsInOutlook(allChannelsForThisCalendarBeingUnwatched); + await this.upsertSelectedCalendarsForEventTypeIds( + { + externalId: calendarId, + outlookSubscriptionExpiration: null, + outlookSubscriptionId: null, + }, + eventTypeIdsToBeUnwatched + ); + + // Populate the cache back for the remaining calendars, if any + const remainingCalendars = + calendarsWithSameCredentialId.filter( + (sc) => sc.externalId !== calendarId && sc.integration === this.integrationName + ) || []; + if (remainingCalendars.length > 0) { + await this.fetchAvailabilityAndSetCache(remainingCalendars); + } + } } diff --git a/packages/app-store/office365calendar/lib/__mocks__/office365apis.ts b/packages/app-store/office365calendar/lib/__mocks__/office365apis.ts new file mode 100644 index 00000000000000..6e7117d81f5cc3 --- /dev/null +++ b/packages/app-store/office365calendar/lib/__mocks__/office365apis.ts @@ -0,0 +1,71 @@ +import type { ISettledResponse } from "office365calendar/lib/CalendarService"; + +interface IGetEventsBatchMockResponse { + endDateTime?: string; + startDateTime?: string; + calendarIds: string[]; +} + +function getEventsBatchMockResponse(config: IGetEventsBatchMockResponse): ISettledResponse[] { + return config.calendarIds?.map((calId, index) => ({ + id: `${index}`, + body: { + value: [ + { + id: calId, + end: { + dateTime: config.endDateTime || "2025-05-02T07:30:00.0000000", + timeZone: "UTC", + }, + start: { + dateTime: config.startDateTime || "2025-05-02T07:00:00.0000000", + timeZone: "UTC", + }, + showAs: "busy", + "@odata.etag": 'W/"Wz6uxrnAKUWCfrn23GUlUQAIDskjFg=="', + }, + ], + "@odata.context": + "https://graph.microsoft.com/v1.0/$metadata#users('example%40cal.com')/calendars('calendar1%40test.com')/calendarView(showAs,start,end)", + }, + status: 200, + headers: { + "Content-Type": + "application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8", + "Retry-After": "0", + }, + })); +} + +const eventsBatchMockResponse: ISettledResponse[] = [ + { + id: "0", + body: { + value: [ + { + id: "calendar1%40test.com", + end: { + dateTime: "2025-05-02T07:30:00.0000000", + timeZone: "UTC", + }, + start: { + dateTime: "2025-05-02T07:00:00.0000000", + timeZone: "UTC", + }, + showAs: "busy", + "@odata.etag": 'W/"Wz6uxrnAKUWCfrn23GUlUQAIDskjFg=="', + }, + ], + "@odata.context": + "https://graph.microsoft.com/v1.0/$metadata#users('example%40cal.com')/calendars('calendar1%40test.com')/calendarView(showAs,start,end)", + }, + status: 200, + headers: { + "Content-Type": + "application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8", + "Retry-After": "0", + }, + }, +]; + +export { eventsBatchMockResponse, getEventsBatchMockResponse }; diff --git a/packages/app-store/office365calendar/zod.ts b/packages/app-store/office365calendar/zod.ts index a69e2e86d866ef..8147c7648eb63f 100644 --- a/packages/app-store/office365calendar/zod.ts +++ b/packages/app-store/office365calendar/zod.ts @@ -9,3 +9,33 @@ export const appDataSchema = z.object({ client_id: z.string(), client_secret: z.string(), }); + +export const graphValidationTokenChallengeSchema = z.object({ + validationToken: z.string().min(1), +}); + +/** + * https://learn.microsoft.com/en-us/graph/api/resources/changenotification?view=graph-rest-1.0#properties + */ +export const changeNotificationWebhookPayloadSchema = z.object({ + value: z.array( + z.object({ + changeType: z.enum(["created", "updated", "deleted"]), + clientState: z.string().optional(), + id: z.string().optional(), + lifecycleEvent: z.enum(["missed", "subscriptionRemoved", "reauthorizationRequired"]).optional(), + resource: z.string(), + subscriptionExpirationDateTime: z.string().optional(), + subscriptionId: z.string(), + tenantId: z.string(), + }) + ), +}); + +/** + * https://learn.microsoft.com/en-us/graph/api/subscription-post-subscriptions?view=graph-rest-1.0&tabs=http#response-1 + */ +export const startWatchingCalendarResponseSchema = z.object({ + id: z.string(), + expirationDateTime: z.string(), +}); diff --git a/packages/features/calendar-cache/CalendarCache.md b/packages/features/calendar-cache/CalendarCache.md index de561e8019f9a9..05f79935b88b98 100644 --- a/packages/features/calendar-cache/CalendarCache.md +++ b/packages/features/calendar-cache/CalendarCache.md @@ -3,12 +3,12 @@ ## Responsibilities - File:`/api/calendar-cache/cron` - Runs every minute and takes care of two things: 1. Watching calendars - - Identifies selectedCalendars that are not watched(identified by `googleChannelId` being null) and watches them + - Identifies selectedCalendars that are not watched(identified by `googleChannelId` or `outlookSubscriptionId` being null) and watches them - When feature is enabled, it starts watching all related calendars - SelectedCalendars with same `externalId` are considered same from CalendarCache perspective - This is how a newly added SelectedCalendar record gets its googleChannel props set - CalendarService.watchCalendar ensures that the new subscription is not created unnecessarily, reusing existing SelectedCalendar googleChannel props when possible. - - Identifies calendars that are watched but have their subscription about to expire(identified by `googleChannelExpiration` being less than current tomorrow's date) and watches them again + - Identifies calendars that are watched but have their subscription about to expire(identified by `googleChannelExpiration` or `outlookSubscriptionExpiration` being less than current tomorrow's date) and watches them again 2. Unwatching calendars - It takes care of cleaning up when the calendar-cache feature flag is disabled. - File:`calendar-cache-cleanup` @@ -21,4 +21,4 @@ - It checks if CalendarCache exists for the calendar - If it does, it fetches availability from CalendarCache - If it doesn't, it fetches availability from the third party calendar service -- It doesn't populate/update CalendarCache. That is solely the responsibility of webhook \ No newline at end of file +- It doesn't populate/update CalendarCache. That is solely the responsibility of webhook diff --git a/packages/lib/server/repository/selectedCalendar.ts b/packages/lib/server/repository/selectedCalendar.ts index b1553f8ca712c6..2f7c3b159b492a 100644 --- a/packages/lib/server/repository/selectedCalendar.ts +++ b/packages/lib/server/repository/selectedCalendar.ts @@ -28,6 +28,12 @@ export type FindManyArgs = { | { not: null; }; + outlookSubscriptionId?: + | string + | null + | { + not: null; + }; }; orderBy?: { userId?: "asc" | "desc"; @@ -165,9 +171,28 @@ export class SelectedCalendarRepository { }, }, }, - // RN we only support google calendar subscriptions for now - integration: "google_calendar", + // We skip retrying calendars that have errored + error: null, AND: [ + // We only support google calendar and outlook subscriptions for now + { + OR: [ + { + integration: "google_calendar", + OR: [ + { googleChannelExpiration: null }, + { googleChannelExpiration: { lt: tomorrowTimestamp } }, + ], + }, + { + integration: "office365_calendar", + OR: [ + { outlookSubscriptionExpiration: null }, + { outlookSubscriptionExpiration: { lt: tomorrowTimestamp } }, + ], + }, + ], + }, { OR: [ // Either is a calendar that has not errored @@ -188,14 +213,6 @@ export class SelectedCalendarRepository { }, ], }, - { - OR: [ - // Either is a calendar pending to be watched - { googleChannelExpiration: null }, - // Or is a calendar that is about to expire - { googleChannelExpiration: { lt: tomorrowTimestamp } }, - ], - }, ], }, }); @@ -207,9 +224,17 @@ export class SelectedCalendarRepository { */ static async getNextBatchToUnwatch(limit = 100) { const where: Prisma.SelectedCalendarWhereInput = { - // RN we only support google calendar subscriptions for now - integration: "google_calendar", - googleChannelExpiration: { not: null }, + // We only support google calendar and outlook subscriptions for now + OR: [ + { + integration: "office365_calendar", + outlookSubscriptionExpiration: { not: null }, + }, + { + integration: "google_calendar", + googleChannelExpiration: { not: null }, + }, + ], AND: [ { OR: [ @@ -288,6 +313,26 @@ export class SelectedCalendarRepository { }); } + static async findByOutlookSubscriptionId(subscriptionId: string) { + return await prisma.selectedCalendar.findFirst({ + where: { + outlookSubscriptionId: subscriptionId, + }, + select: { + credential: { + select: { + ...credentialForCalendarServiceSelect, + selectedCalendars: { + orderBy: { + externalId: "asc", + }, + }, + }, + }, + }, + }); + } + static async findFirst({ where }: { where: Prisma.SelectedCalendarWhereInput }) { return await prisma.selectedCalendar.findFirst({ where, @@ -369,6 +414,36 @@ export class SelectedCalendarRepository { }); } + static async updateManyForEventTypeIds({ + data, + eventTypeIds, + }: { + data: Prisma.SelectedCalendarUncheckedCreateInput; + eventTypeIds: SelectedCalendarEventTypeIds; + }) { + const userId = data.userId; + return await Promise.allSettled( + eventTypeIds.map((eventTypeId) => { + SelectedCalendarRepository.update({ + where: { + userId: data.userId, + integration: data.integration, + externalId: data.externalId, + eventTypeId: data.eventTypeId || null, + }, + data: { + ...data, + eventTypeId, + userId, + integration: data.integration, + externalId: data.externalId, + credentialId: data.credentialId, + }, + }); + }) + ); + } + static async upsertManyForEventTypeIds({ data, eventTypeIds, diff --git a/packages/prisma/migrations/20250502011606_add_outlook_subscription_id_field/migration.sql b/packages/prisma/migrations/20250502011606_add_outlook_subscription_id_field/migration.sql new file mode 100644 index 00000000000000..37a53c3f6e1dcf --- /dev/null +++ b/packages/prisma/migrations/20250502011606_add_outlook_subscription_id_field/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[outlookSubscriptionId,eventTypeId]` on the table `SelectedCalendar` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "SelectedCalendar" ADD COLUMN "outlookSubscriptionExpiration" TEXT, +ADD COLUMN "outlookSubscriptionId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "SelectedCalendar_outlookSubscriptionId_eventTypeId_key" ON "SelectedCalendar"("outlookSubscriptionId", "eventTypeId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8298a97a7985aa..ad8a9f22291e8e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -865,6 +865,10 @@ model SelectedCalendar { googleChannelResourceUri String? googleChannelExpiration String? + // Used to identify a watched calendar in Outlook + outlookSubscriptionId String? + outlookSubscriptionExpiration String? + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) delegationCredentialId String? @@ -885,6 +889,7 @@ model SelectedCalendar { // Think about introducing a generated unique key ${userId}_${integration}_${externalId}_${eventTypeId} @@unique([userId, integration, externalId, eventTypeId]) @@unique([googleChannelId, eventTypeId]) + @@unique([outlookSubscriptionId, eventTypeId]) @@index([userId]) @@index([externalId]) @@index([eventTypeId]) diff --git a/turbo.json b/turbo.json index 9dc1c9b24873e5..8e412744379525 100644 --- a/turbo.json +++ b/turbo.json @@ -377,6 +377,7 @@ "NODE_ENV", "ORGANIZATIONS_ENABLED", "ORGANIZATIONS_AUTOLINK", + "OUTLOOK_WEBHOOK_TOKEN", "PAYMENT_FEE_FIXED", "PAYMENT_FEE_PERCENTAGE", "PLAYWRIGHT_HEADLESS",