From 1c165bd9349891c64bbb7152f5d054dca993c2e0 Mon Sep 17 00:00:00 2001 From: alannnc Date: Thu, 13 Oct 2022 20:36:43 -0600 Subject: [PATCH] Fix/fallback to calvideo (#4992) * try/catch on createMeeting zoom * add handle response body from zoom * db invalid credential flag * Update VideoApiAdapter.ts * Added notes and try/catches * Remove double response error validation * Fallback to calvideo * Fix types for credentials * Add nullable to invalid field from credential table * Fix calendar service credential creation with bad type * revert db migration invalid on credential table * Revert "Fix types for credentials" This reverts commit 1623305aea23a33c39ba7b6fb5604dd71c6205d7. * Revert "Fix calendar service credential creation with bad type" This reverts commit 3fd3b08f2ee6c7019428a8ff454d371e23759ad7. * Remove migration * Fix for property name for api key on dailyvideo * remove unused prop from credential * Revert "Fix for property name for api key on dailyvideo" This reverts commit 32976934e05e6923210718ccd5634c1357828f65. * Tests fixes Co-authored-by: Peer Richelsen Co-authored-by: zomars --- .../zoomvideo/lib/VideoApiAdapter.ts | 104 +++++++++++------- packages/core/videoClient.ts | 47 ++++++-- 2 files changed, 99 insertions(+), 52 deletions(-) diff --git a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts index 3219b085c3..79119124d2 100644 --- a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts @@ -20,6 +20,7 @@ const zoomEventResultSchema = z.object({ export type ZoomEventResult = z.infer; +// @TODO: add link to the docs export const zoomMeetingsSchema = z.object({ next_page_token: z.string(), page_count: z.number(), @@ -45,6 +46,7 @@ export const zoomMeetingsSchema = z.object({ }); // Successful API response +// @TODO: add link to the docs const zoomTokenSchema = z.object({ scope: z.string().regex(new RegExp("meeting:write")), expiry_date: z.number(), @@ -58,11 +60,21 @@ type ZoomToken = z.infer; const isTokenValid = (token: ZoomToken) => (token.expires_in || token.expiry_date) < Date.now(); +/** @link https://marketplace.zoom.us/docs/guides/auth/oauth/#request */ +const zoomRefreshedTokenSchema = z.object({ + access_token: z.string(), + token_type: z.literal("bearer"), + refresh_token: z.string(), + expires_in: z.number(), + scope: z.string(), +}); + const zoomAuth = (credential: Credential) => { const refreshAccessToken = async (refreshToken: string) => { const { client_id, client_secret } = await getZoomAppKeys(); const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64"); - return fetch("https://zoom.us/oauth/token", { + + const response = await fetch("https://zoom.us/oauth/token", { method: "POST", headers: { Authorization: authHeader, @@ -72,24 +84,32 @@ const zoomAuth = (credential: Credential) => { refresh_token: refreshToken, grant_type: "refresh_token", }), - }) - .then(handleErrorsJson) - .then(async (responseBody) => { - if (!responseBody.refresh_token) Promise.reject(new Error("Invalid credentials")); - // set expiry date as offset from current time. - responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); - delete responseBody.expires_in; - // Store new tokens in database. - await prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - key: responseBody, - }, - }); - return responseBody.access_token; - }); + }); + const responseBody = await handleErrorsJson(response); + + // We check the if the new credentials matches the expected response structure + const parsedToken = zoomRefreshedTokenSchema.safeParse(responseBody); + + // TODO: If the new token is invalid, initiate the fallback sequence instead of throwing + // Expanding on this we can use server-to-server app and create meeting from admin calcom account + if (!parsedToken.success) { + return Promise.reject(new Error("Invalid refreshed tokens were returned")); + } + const newTokens = parsedToken.data; + const oldCredential = await prisma.credential.findUniqueOrThrow({ where: { id: credential.id } }); + const parsedKey = zoomTokenSchema.safeParse(oldCredential.key); + if (!parsedKey.success) { + return Promise.reject(new Error("Invalid credentials were saved in the DB")); + } + + const key = parsedKey.data; + key.access_token = newTokens.access_token; + key.refresh_token = newTokens.refresh_token; + // set expiry date as offset from current time. + key.expiry_date = Math.round(Date.now() + newTokens.expires_in * 1000); + // Store new tokens in database. + await prisma.credential.update({ where: { id: credential.id }, data: { key } }); + return newTokens.access_token; }; return { @@ -98,12 +118,7 @@ const zoomAuth = (credential: Credential) => { try { credentialKey = zoomTokenSchema.parse(credential.key); } catch (error) { - // If the parse fails, it means we have an invalid credential saved. We should delete it. - await prisma.credential.delete({ where: { id: credential.id } }); - // We reject the promise, and should handle the error on the UI - return Promise.reject( - "This Zoom credential was malformed so we've removed it from the DB. Please add a new Zoom credential" - ); + return Promise.reject("Zoom credential keys parsing error"); } return !isTokenValid(credentialKey) @@ -237,24 +252,31 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => { } }, createMeeting: async (event: CalendarEvent): Promise => { - const response: ZoomEventResult = await fetchZoomApi("users/me/meetings", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }); - - const result = zoomEventResultSchema.parse(response); - if (result.id && result.join_url) { - return Promise.resolve({ - type: "zoom_video", - id: result.id.toString(), - password: result.password || "", - url: result.join_url, + try { + const response: ZoomEventResult = await fetchZoomApi("users/me/meetings", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), }); + + const result = zoomEventResultSchema.parse(response); + + if (result.id && result.join_url) { + return { + type: "zoom_video", + id: result.id.toString(), + password: result.password || "", + url: result.join_url, + }; + } + throw new Error("Failed to create meeting. Response is " + JSON.stringify(result)); + } catch (err) { + console.error(err); + /* Prevents meeting creation failure when Zoom Token is expired */ + throw new Error("Unexpected error"); } - return Promise.reject(new Error("Failed to create meeting. Response is " + JSON.stringify(result))); }, deleteMeeting: async (uid: string): Promise => { await fetchZoomApi(`meetings/${uid}`, { diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index af5e5ebc9f..cec8ea9820 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -3,6 +3,7 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import appStore from "@calcom/app-store"; +import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys"; import { sendBrokenIntegrationEmail } from "@calcom/emails"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; @@ -44,18 +45,28 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent) => const videoAdapters = getVideoAdapters([credential]); const [firstVideoAdapter] = videoAdapters; - const createdMeeting = await firstVideoAdapter?.createMeeting(calEvent).catch(async (e) => { - await sendBrokenIntegrationEmail(calEvent, "video"); - console.error("createMeeting failed", e, calEvent); - }); + let createdMeeting; + try { + createdMeeting = await firstVideoAdapter?.createMeeting(calEvent); - if (!createdMeeting) { - return { - type: credential.type, - success: false, - uid, - originalEvent: calEvent, - }; + if (!createdMeeting) { + return { + type: credential.type, + success: false, + uid, + originalEvent: calEvent, + }; + } + } catch (err) { + await sendBrokenIntegrationEmail(calEvent, "video"); + console.error("createMeeting failed", err, calEvent); + + // Default to calVideo + const defaultMeeting = await createMeetingWithCalVideo(calEvent); + if (defaultMeeting) { + createdMeeting = defaultMeeting; + calEvent.location = "integrations:dailyvideo"; + } } return { @@ -117,4 +128,18 @@ const deleteMeeting = (credential: Credential, uid: string): Promise => return Promise.resolve({}); }; +// @TODO: This is a temporary solution to create a meeting with cal.com video as fallback url +const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => { + const [videoAdapter] = getVideoAdapters([ + { + id: 0, + appId: "daily-video", + type: "daily_video", + userId: null, + key: await getDailyAppKeys(), + }, + ]); + return videoAdapter?.createMeeting(calEvent); +}; + export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };