diff --git a/packages/app-store/_utils/getAppKeysFromSlug.ts b/packages/app-store/_utils/getAppKeysFromSlug.ts index a106ec9622..117e5608fe 100644 --- a/packages/app-store/_utils/getAppKeysFromSlug.ts +++ b/packages/app-store/_utils/getAppKeysFromSlug.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import prisma from "@calcom/prisma"; diff --git a/packages/app-store/_utils/getParsedAppKeysFromSlug.ts b/packages/app-store/_utils/getParsedAppKeysFromSlug.ts new file mode 100644 index 0000000000..1c8d8b99fa --- /dev/null +++ b/packages/app-store/_utils/getParsedAppKeysFromSlug.ts @@ -0,0 +1,10 @@ +import type Zod from "zod"; + +import getAppKeysFromSlug from "./getAppKeysFromSlug"; + +export async function getParsedAppKeysFromSlug(slug: string, schema: Zod.Schema) { + const appKeys = await getAppKeysFromSlug(slug); + return schema.parse(appKeys); +} + +export default getParsedAppKeysFromSlug; diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index cfab5e584f..8f5a715ce1 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -3,7 +3,6 @@ import { Credential } from "@prisma/client"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; -import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; @@ -15,39 +14,52 @@ import type { NewCalendarEventType, } from "@calcom/types/Calendar"; -import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import { O365AuthCredentials } from "../types/Office365Calendar"; +import { getOfficeAppKeys } from "./getOfficeAppKeys"; -let client_id = ""; -let client_secret = ""; +interface IRequest { + method: string; + url: string; + id: number; +} + +interface ISettledResponse { + id: string; + status: number; + headers: { + "Retry-After": string; + "Content-Type": string; + }; + body: Record; +} + +interface IBatchResponse { + responses: ISettledResponse[]; +} export default class Office365CalendarService implements Calendar { private url = ""; private integrationName = ""; private log: typeof logger; - auth: Promise<{ getToken: () => Promise }>; + private accessToken: string | null = null; + auth: { getToken: () => Promise }; + private apiGraphUrl = "https://graph.microsoft.com/v1.0"; constructor(credential: Credential) { this.integrationName = "office365_calendar"; - this.auth = this.o365Auth(credential).then((t) => t); + this.auth = this.o365Auth(credential); this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } async createEvent(event: CalendarEvent): Promise { try { - const accessToken = await (await this.auth).getToken(); - const calendarId = event.destinationCalendar?.externalId ? `${event.destinationCalendar.externalId}/` : ""; - const response = await fetch(`https://graph.microsoft.com/v1.0/me/calendars/${calendarId}events`, { + const response = await this.fetcher(`/me/calendars/${calendarId}events`, { method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, body: JSON.stringify(this.translateEvent(event)), }); @@ -61,14 +73,8 @@ export default class Office365CalendarService implements Calendar { async updateEvent(uid: string, event: CalendarEvent): Promise { try { - const accessToken = await (await this.auth).getToken(); - - const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + const response = await this.fetcher(`/me/calendar/events/${uid}`, { method: "PATCH", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, body: JSON.stringify(this.translateEvent(event)), }); @@ -82,13 +88,8 @@ export default class Office365CalendarService implements Calendar { async deleteEvent(uid: string): Promise { try { - const accessToken = await (await this.auth).getToken(); - - const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + const response = await this.fetcher(`/me/calendar/events/${uid}`, { method: "DELETE", - headers: { - Authorization: "Bearer " + accessToken, - }, }); handleErrorsRaw(response); @@ -110,95 +111,72 @@ export default class Office365CalendarService implements Calendar { const filter = `?startDateTime=${encodeURIComponent( dateFromParsed.toISOString() )}&endDateTime=${encodeURIComponent(dateToParsed.toISOString())}`; - return (await this.auth) - .getToken() - .then(async (accessToken) => { - const selectedCalendarIds = selectedCalendars - .filter((e) => e.integration === this.integrationName) - .map((e) => e.externalId) - .filter(Boolean); - if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - 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 requests = ids.map((calendarId, id) => ({ - id, - method: "GET", - url: `/me/calendars/${calendarId}/calendarView${filter}`, - })); - const response = await fetch("https://graph.microsoft.com/v1.0/$batch", { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify({ requests }), - }); - const responseBody = await handleErrorsJson(response); - return responseBody.responses.reduce( - (acc: BufferedBusyTime[], subResponse: { body: { value?: any[] } }) => - acc.concat( - subResponse.body?.value - ? subResponse.body.value - .filter((evt) => evt.showAs !== "free" && evt.showAs !== "workingElsewhere") - .map((evt) => { - return { - start: evt.start.dateTime + "Z", - end: evt.end.dateTime + "Z", - }; - }) - : [] - ), - [] - ); - }) - .catch((err: unknown) => { - console.log(err); - return Promise.reject([]); - }); + try { + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === this.integrationName) + .map((e) => e.externalId) + .filter(Boolean); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { + // Only calendars of other integrations selected + 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 requests = ids.map((calendarId, id) => ({ + id, + method: "GET", + url: `/me/calendars/${calendarId}/calendarView${filter}`, + })); + const response = await this.apiGraphBatchCall(requests); + const responseBody = await handleErrorsJson(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) : []; + } catch (err) { + console.log(err); + return Promise.reject([]); + } } async listCalendars(): Promise { - return (await this.auth).getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendars", { - method: "get", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - }) - .then(handleErrorsJson) - .then((responseBody: { value: OfficeCalendar[] }) => { - return responseBody.value.map((cal) => { - const calendar: IntegrationCalendar = { - externalId: cal.id ?? "No Id", - integration: this.integrationName, - name: cal.name ?? "No calendar name", - primary: cal.isDefaultCalendar ?? false, - readOnly: !cal.canEdit && true, - }; - return calendar; - }); - }) - ); + const response = await this.fetcher(`/me/calendars`); + const responseBody = (await handleErrorsJson(response)) as { value: OfficeCalendar[] }; + return responseBody.value.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id ?? "No Id", + integration: this.integrationName, + name: cal.name ?? "No calendar name", + primary: cal.isDefaultCalendar ?? false, + readOnly: !cal.canEdit && true, + }; + return calendar; + }); } - private o365Auth = async (credential: Credential) => { - const appKeys = await getAppKeysFromSlug("office365-calendar"); - if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; - if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) throw new HttpError({ statusCode: 400, message: "office365 client_id missing." }); - if (!client_secret) throw new HttpError({ statusCode: 400, message: "office365 client_secret missing." }); - + private o365Auth = (credential: Credential) => { const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); - const o365AuthCredentials = credential.key as O365AuthCredentials; const refreshAccessToken = async (refreshToken: string) => { + const { client_id, client_secret } = await getOfficeAppKeys(); const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -257,4 +235,158 @@ export default class Office365CalendarService implements Calendar { location: event.location ? { displayName: getLocation(event) } : undefined, }; }; + + private fetcher = async (endpoint: string, init?: RequestInit | undefined) => { + this.accessToken = await this.auth.getToken(); + return fetch(`${this.apiGraphUrl}${endpoint}`, { + method: "get", + headers: { + Authorization: "Bearer " + this.accessToken, + "Content-Type": "application/json", + }, + ...init, + }); + }; + + private fetchResponsesWithNextLink = async ( + settledResponses: ISettledResponse[] + ): Promise => { + const alreadySuccess = [] as ISettledResponse[]; + const newLinkRequest = [] as IRequest[]; + settledResponses?.forEach((response) => { + if (response.status === 200 && response.body["@odata.nextLink"] === undefined) { + alreadySuccess.push(response); + } else { + const nextLinkUrl = response.body["@odata.nextLink"] + ? response.body["@odata.nextLink"].replace(this.apiGraphUrl, "") + : ""; + if (nextLinkUrl) { + // Saving link for later use + newLinkRequest.push({ + id: Number(response.id), + method: "GET", + url: nextLinkUrl, + }); + } + delete response.body["@odata.nextLink"]; + // Pushing success body content + alreadySuccess.push(response); + } + }); + + if (newLinkRequest.length === 0) { + return alreadySuccess; + } + + const newResponse = await this.apiGraphBatchCall(newLinkRequest); + let newResponseBody = await handleErrorsJson(newResponse); + + if (typeof newResponseBody === "string") { + newResponseBody = this.handleTextJsonResponseWithHtmlInBody(newResponseBody); + } + + // Going recursive to fetch next link + const newSettledResponses = await this.fetchResponsesWithNextLink(newResponseBody.responses); + return [...alreadySuccess, ...newSettledResponses]; + }; + + private fetchRequestWithRetryAfter = async ( + originalRequests: IRequest[], + settledPromises: ISettledResponse[], + maxRetries: number, + retryCount = 0 + ): Promise => { + let retryAfterTimeout = 0; + if (retryCount >= maxRetries) { + return { responses: settledPromises }; + } + const alreadySuccessRequest = [] as ISettledResponse[]; + const failedRequest = [] as IRequest[]; + settledPromises.forEach((item) => { + if (item.status === 200) { + alreadySuccessRequest.push(item); + } else if (item.status === 429) { + const newTimeout = Number(item.headers["Retry-After"]) * 1000 || 0; + retryAfterTimeout = newTimeout > retryAfterTimeout ? newTimeout : retryAfterTimeout; + failedRequest.push(originalRequests[Number(item.id)]); + } + }); + + if (failedRequest.length === 0) { + return { responses: alreadySuccessRequest }; + } + + // Await certain time from retry-after header + await new Promise((r) => setTimeout(r, retryAfterTimeout)); + + const newResponses = await this.apiGraphBatchCall(failedRequest); + let newResponseBody = await handleErrorsJson(newResponses); + if (typeof newResponseBody === "string") { + newResponseBody = this.handleTextJsonResponseWithHtmlInBody(newResponseBody); + } + const retryAfter = !!newResponseBody?.responses && this.findRetryAfterResponse(newResponseBody.responses); + + if (retryAfter && newResponseBody.responses) { + newResponseBody = await this.fetchRequestWithRetryAfter( + failedRequest, + newResponseBody.responses, + maxRetries, + retryCount + 1 + ); + } + return { responses: [...alreadySuccessRequest, ...(newResponseBody?.responses || [])] }; + }; + + private apiGraphBatchCall = async (requests: IRequest[]): Promise => { + try { + const response = await this.fetcher(`/$batch`, { + method: "POST", + body: JSON.stringify({ requests }), + }); + + return response; + } catch (error: any) { + throw new Error(error); + } + }; + + private handleTextJsonResponseWithHtmlInBody = (response: string): IBatchResponse => { + try { + const parsedJson = JSON.parse(response); + return parsedJson; + } catch (error) { + // Looking for html in body + const openTag = '"body":<'; + const closeTag = ""; + const htmlBeginning = response.indexOf(openTag) + openTag.length - 1; + const htmlEnding = response.indexOf(closeTag) + closeTag.length + 2; + const resultString = `${response.repeat(1).substring(0, htmlBeginning)} ""${response + .repeat(1) + .substring(htmlEnding, response.length)}`; + + return JSON.parse(resultString); + } + }; + + private findRetryAfterResponse = (response: ISettledResponse[]) => { + const foundRetry = response.find((request: ISettledResponse) => request.status === 429); + return !!foundRetry; + }; + + private processBusyTimes = (responses: ISettledResponse[]) => { + return responses.reduce( + (acc: BufferedBusyTime[], subResponse: { body: { value?: any[]; error?: any[] } }) => { + if (!subResponse.body?.value) return acc; + return acc.concat( + subResponse.body.value + .filter((evt) => evt.showAs !== "free" && evt.showAs !== "workingElsewhere") + .map((evt) => ({ + start: evt.start.dateTime + "Z", + end: evt.end.dateTime + "Z", + })) + ); + }, + [] + ); + }; } diff --git a/packages/app-store/office365calendar/lib/getOfficeAppKeys.ts b/packages/app-store/office365calendar/lib/getOfficeAppKeys.ts new file mode 100644 index 0000000000..eff3fb3389 --- /dev/null +++ b/packages/app-store/office365calendar/lib/getOfficeAppKeys.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug"; + +const officeAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +export const getOfficeAppKeys = async () => { + return getParsedAppKeysFromSlug("office365-calendar", officeAppKeysSchema); +}; diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index ce24f0883b..e945313e0b 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -100,11 +100,12 @@ const getCachedResults = async ( const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type); /** We extract external Ids so we don't cache too much */ const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId); - /** We create a unque hash key based on the input data */ + /** We create a unique hash key based on the input data */ const cacheKey = JSON.stringify({ id, selectedCalendarIds, dateFrom, dateTo }); const cacheHashedKey = createHash("md5").update(cacheKey).digest("hex"); /** Check if we already have cached data and return */ const cachedAvailability = cache.get(cacheHashedKey); + if (cachedAvailability) { log.debug(`Cache HIT: Calendar Availability for key: ${cacheKey}`); return cachedAvailability; @@ -113,6 +114,7 @@ const getCachedResults = async ( /** If we don't then we actually fetch external calendars (which can be very slow) */ const availability = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars); /** We save the availability to a few seconds so recurrent calls are nearly instant */ + cache.put(cacheHashedKey, availability, CACHING_TIME); return availability; }); diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 08dfaea5f9..26d7ec1340 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -47,7 +47,7 @@ export async function getBusyTimes(params: { logger.debug(`prisma booking get took ${endPrismaBookingGet - startPrismaBookingGet}ms`); if (credentials.length > 0) { const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars); - // console.log("calendarBusyTimes", calendarBusyTimes); + busyTimes.push(...calendarBusyTimes); /* // TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency. const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 2b34d2a9d8..1565a72f5a 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -151,6 +151,7 @@ export async function getUserAvailability( ); const endGetWorkingHours = performance.now(); logger.debug(`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`); + return { busy: bufferedBusyTimes, timeZone, diff --git a/packages/lib/errors.ts b/packages/lib/errors.ts index 4b8368dd9e..02ccce210b 100644 --- a/packages/lib/errors.ts +++ b/packages/lib/errors.ts @@ -11,6 +11,9 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb } export function handleErrorsJson(response: Response) { + if (response.headers.get("content-encoding") === "gzip") { + return response.text(); + } if (response.status === 204) { return new Promise((resolve) => resolve({})); } @@ -18,6 +21,7 @@ export function handleErrorsJson(response: Response) { response.json().then(console.log); throw Error(response.statusText); } + return response.json(); } diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.tsx index 8d7f283f96..0a18e96b9e 100644 --- a/packages/trpc/server/routers/viewer/slots.tsx +++ b/packages/trpc/server/routers/viewer/slots.tsx @@ -203,7 +203,7 @@ export async function getSchedule( }) ); - const workingHours = userSchedules.flatMap((s) => s.workingHours); + const workingHours = userSchedules?.flatMap((s) => s.workingHours); const slots: Record = {}; const availabilityCheckProps = { @@ -279,6 +279,7 @@ export async function getSchedule( `checkForAvailability took ${checkForAvailabilityTime}ms and executed ${checkForAvailabilityCount} times` ); logger.silly(`Available slots: ${JSON.stringify(slots)}`); + return { slots, };