diff --git a/packages/app-store/zoomvideo/api/add.ts b/packages/app-store/zoomvideo/api/add.ts index 826cb4b5ea..2ab1ae055d 100644 --- a/packages/app-store/zoomvideo/api/add.ts +++ b/packages/app-store/zoomvideo/api/add.ts @@ -1,37 +1,36 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextApiRequest } from "next"; import { stringify } from "querystring"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; -import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { getZoomAppKeys } from "../lib"; -let client_id = ""; +async function handler(req: NextApiRequest) { + // Get user + await prisma.user.findFirst({ + rejectOnNotFound: true, + where: { + id: req.session?.user?.id, + }, + select: { + id: true, + }, + }); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") { - // Get user - await prisma.user.findFirst({ - rejectOnNotFound: true, - where: { - id: req.session?.user?.id, - }, - select: { - id: true, - }, - }); + const { client_id } = await getZoomAppKeys(); - const appKeys = await getAppKeysFromSlug("zoom"); - if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; - if (!client_id) return res.status(400).json({ message: "Zoom client_id missing." }); - - const params = { - response_type: "code", - client_id, - redirect_uri: WEBAPP_URL + "/api/integrations/zoomvideo/callback", - }; - const query = stringify(params); - const url = `https://zoom.us/oauth/authorize?${query}`; - res.status(200).json({ url }); - } + const params = { + response_type: "code", + client_id, + redirect_uri: WEBAPP_URL + "/api/integrations/zoomvideo/callback", + }; + const query = stringify(params); + const url = `https://zoom.us/oauth/authorize?${query}`; + return { url }; } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/zoomvideo/api/callback.ts b/packages/app-store/zoomvideo/api/callback.ts index 2e3fd823a3..01cf75237f 100644 --- a/packages/app-store/zoomvideo/api/callback.ts +++ b/packages/app-store/zoomvideo/api/callback.ts @@ -3,19 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; -import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; - -let client_id = ""; -let client_secret = ""; +import { getZoomAppKeys } from "../lib"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; - - const appKeys = await getAppKeysFromSlug("zoom"); - 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) return res.status(400).json({ message: "Zoom client_id missing." }); - if (!client_secret) return res.status(400).json({ message: "Zoom client_secret missing." }); + const { client_id, client_secret } = await getZoomAppKeys(); const redirectUri = encodeURI(WEBAPP_URL + "/api/integrations/zoomvideo/callback"); const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64"); diff --git a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts index 623360b713..81c3991974 100644 --- a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts @@ -1,92 +1,167 @@ import { Credential } from "@prisma/client"; +import { z } from "zod"; -import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; -import { HttpError } from "@calcom/lib/http-error"; +import { handleErrorsJson } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { PartialReference } from "@calcom/types/EventManager"; import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; -import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { getZoomAppKeys } from "./getZoomAppKeys"; /** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */ -export interface ZoomEventResult { - password: string; - created_at: string; - duration: number; - host_id: string; - id: number; - join_url: string; - settings: { - alternative_hosts: string; - approval_type: number; - audio: string; - auto_recording: string; - close_registration: boolean; - cn_meeting: boolean; - enforce_login: boolean; - enforce_login_domains: string; - global_dial_in_countries: string[]; - global_dial_in_numbers: { - city: string; - country: string; - country_name: string; - number: string; - type: string; - }[]; - breakout_room: { - enable: boolean; - rooms: { - name: string; - participants: string[]; - }[]; - host_video: boolean; - in_meeting: boolean; - join_before_host: boolean; - mute_upon_entry: boolean; - participant_video: boolean; - registrants_confirmation_email: boolean; - use_pmi: boolean; - waiting_room: boolean; - watermark: boolean; - registrants_email_notification: boolean; - }; - start_time: string; - start_url: string; - status: string; - timezone: string; - topic: string; - type: number; - uuid: string; - }; -} +export const zoomEventResultSchema = z.object({ + assistant_id: z.string(), + host_email: z.string(), + id: z.number(), + registration_url: z.string(), + agenda: z.string(), + created_at: z.string(), + duration: z.number(), + h323_password: z.string(), + join_url: z.string(), + occurrences: z.array( + z.object({ + duration: z.number(), + occurrence_id: z.string(), + start_time: z.string(), + status: z.string(), + }) + ), + password: z.string(), + pmi: z.number(), + pre_schedule: z.boolean(), + recurrence: z.object({ + end_date_time: z.string(), + end_times: z.number(), + monthly_day: z.number(), + monthly_week: z.number(), + monthly_week_day: z.number(), + repeat_interval: z.number(), + type: z.number(), + weekly_days: z.string(), + }), + settings: z.object({ + allow_multiple_devices: z.boolean(), + alternative_hosts: z.string(), + alternative_hosts_email_notification: z.boolean(), + alternative_host_update_polls: z.boolean(), + approval_type: z.number(), + approved_or_denied_countries_or_regions: z.object({ + approved_list: z.array(z.string()), + denied_list: z.array(z.string()), + enable: z.boolean(), + method: z.string(), + }), + audio: z.string(), + authentication_domains: z.string(), + authentication_exception: z.array( + z.object({ email: z.string(), name: z.string(), join_url: z.string() }) + ), + authentication_name: z.string(), + authentication_option: z.string(), + auto_recording: z.string(), + breakout_room: z.object({ + enable: z.boolean(), + rooms: z.array(z.object({ name: z.string(), participants: z.array(z.string()) })), + }), + calendar_type: z.number(), + close_registration: z.boolean(), + cn_meeting: z.boolean(), + contact_email: z.string(), + contact_name: z.string(), + custom_keys: z.array(z.object({ key: z.string(), value: z.string() })), + email_notification: z.boolean(), + encryption_type: z.string(), + enforce_login: z.boolean(), + enforce_login_domains: z.string(), + focus_mode: z.boolean(), + global_dial_in_countries: z.array(z.string()), + global_dial_in_numbers: z.array( + z.object({ + city: z.string(), + country: z.string(), + country_name: z.string(), + number: z.string(), + type: z.string(), + }) + ), + host_video: z.boolean(), + in_meeting: z.boolean(), + jbh_time: z.number(), + join_before_host: z.boolean(), + language_interpretation: z.object({ + enable: z.boolean(), + interpreters: z.array(z.object({ email: z.string(), languages: z.string() })), + }), + meeting_authentication: z.boolean(), + mute_upon_entry: z.boolean(), + participant_video: z.boolean(), + private_meeting: z.boolean(), + registrants_confirmation_email: z.boolean(), + registrants_email_notification: z.boolean(), + registration_type: z.number(), + show_share_button: z.boolean(), + use_pmi: z.boolean(), + waiting_room: z.boolean(), + watermark: z.boolean(), + host_save_video_order: z.boolean(), + }), + start_time: z.string(), + start_url: z.string(), + timezone: z.string(), + topic: z.string(), + tracking_fields: z.array(z.object({ field: z.string(), value: z.string(), visible: z.boolean() })), + type: z.number(), +}); -interface ZoomToken { - scope: "meeting:write"; - expiry_date: number; - expires_in?: number; // deprecated, purely for backwards compatibility; superseeded by expiry_date. - token_type: "bearer"; - access_token: string; - refresh_token: string; -} +export type ZoomEventResult = z.infer; -let client_id = ""; -let client_secret = ""; +export const zoomMeetingsSchema = z.object({ + next_page_token: z.string(), + page_count: z.number(), + page_number: z.number(), + page_size: z.number(), + total_records: z.number(), + meetings: z.array( + z.object({ + agenda: z.string(), + created_at: z.string(), + duration: z.number(), + host_id: z.string(), + id: z.number(), + join_url: z.string(), + pmi: z.string(), + start_time: z.string(), + timezone: z.string(), + topic: z.string(), + type: z.number(), + uuid: z.string(), + }) + ), +}); -const zoomAuth = async (credential: Credential) => { - const appKeys = await getAppKeysFromSlug("zoom"); - 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: "Zoom client_id missing." }); - if (!client_secret) throw new HttpError({ statusCode: 400, message: "Zoom client_secret missing." }); +const zoomTokenSchema = z.object({ + scope: z.literal("meeting:write"), + expiry_date: z.number(), + expires_in: z.number().optional(), // deprecated, purely for backwards compatibility; superseeded by expiry_date. + token_type: z.literal("bearer"), + access_token: z.string(), + refresh_token: z.string(), +}); + +type ZoomToken = z.infer; + +const zoomAuth = (credential: Credential) => { + const credentialKey = zoomTokenSchema.parse(credential.key); - const credentialKey = credential.key as unknown as ZoomToken; const isTokenValid = (token: ZoomToken) => token && token.token_type && token.access_token && (token.expires_in || token.expiry_date) < Date.now(); - const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64"); - const refreshAccessToken = (refreshToken: string) => - fetch("https://zoom.us/oauth/token", { + 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", { method: "POST", headers: { Authorization: authHeader, @@ -115,6 +190,7 @@ const zoomAuth = async (credential: Credential) => { credentialKey.access_token = responseBody.access_token; return credentialKey.access_token; }); + }; return { getToken: () => @@ -125,8 +201,6 @@ const zoomAuth = async (credential: Credential) => { }; const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => { - const auth = zoomAuth(credential); - const translateEvent = (event: CalendarEvent) => { // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate return { @@ -156,77 +230,69 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => { }; }; + const fetchZoomApi = async (endpoint: string, options?: RequestInit) => { + const auth = zoomAuth(credential); + const accessToken = await auth.getToken(); + const responseBody = await fetch(`https://api.zoom.us/v2/${endpoint}`, { + method: "GET", + ...options, + headers: { + Authorization: "Bearer " + accessToken, + ...options?.headers, + }, + }).then(handleErrorsJson); + + return responseBody; + }; + return { getAvailability: async () => { - return (await auth) - .getToken() - .then( - // TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled. - (accessToken) => - fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", { - method: "get", - headers: { - Authorization: "Bearer " + accessToken, - }, - }) - .then(handleErrorsJson) - .then((responseBody) => { - return responseBody.meetings.map((meeting: { start_time: string; duration: number }) => ({ - start: meeting.start_time, - end: new Date( - new Date(meeting.start_time).getTime() + meeting.duration * 60000 - ).toISOString(), - })); - }) - ) - .catch((err) => { - console.error(err); - /* Prevents booking failure when Zoom Token is expired */ - return []; - }); + try { + // TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled. + const responseBody = await fetchZoomApi("users/me/meetings?type=scheduled&page_size=300"); + const data = zoomMeetingsSchema.parse(responseBody); + return data.meetings.map((meeting) => ({ + start: meeting.start_time, + end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(), + })); + } catch (err) { + console.error(err); + /* Prevents booking failure when Zoom Token is expired */ + return []; + } }, createMeeting: async (event: CalendarEvent): Promise => { - const accessToken = await (await auth).getToken(); - - const result = await fetch("https://api.zoom.us/v2/users/me/meetings", { + const response: ZoomEventResult = await fetchZoomApi("users/me/meetings", { method: "POST", headers: { - Authorization: "Bearer " + accessToken, "Content-Type": "application/json", }, body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsJson); + }); + const result = zoomEventResultSchema.parse(response); return Promise.resolve({ type: "zoom_video", - id: result.id as string, - password: result.password ?? "", + id: result.id.toString(), + password: result.password, url: result.join_url, }); }, deleteMeeting: async (uid: string): Promise => { - const accessToken = await (await auth).getToken(); - - await fetch("https://api.zoom.us/v2/meetings/" + uid, { + await fetchZoomApi(`meetings/${uid}`, { method: "DELETE", - headers: { - Authorization: "Bearer " + accessToken, - }, - }).then(handleErrorsRaw); + }); return Promise.resolve(); }, updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise => { - const accessToken = await (await auth).getToken(); - - await fetch("https://api.zoom.us/v2/meetings/" + bookingRef.uid, { + await fetchZoomApi(`meetings/${bookingRef.uid}`, { method: "PATCH", headers: { - Authorization: "Bearer " + accessToken, "Content-Type": "application/json", }, body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw); + }); return Promise.resolve({ type: "zoom_video", diff --git a/packages/app-store/zoomvideo/lib/getZoomAppKeys.ts b/packages/app-store/zoomvideo/lib/getZoomAppKeys.ts new file mode 100644 index 0000000000..b2d6e73503 --- /dev/null +++ b/packages/app-store/zoomvideo/lib/getZoomAppKeys.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; + +const zoomAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +export const getZoomAppKeys = async () => { + const appKeys = await getAppKeysFromSlug("zoom"); + return zoomAppKeysSchema.parse(appKeys); +}; diff --git a/packages/app-store/zoomvideo/lib/index.ts b/packages/app-store/zoomvideo/lib/index.ts index dc61768d60..ac994099e2 100644 --- a/packages/app-store/zoomvideo/lib/index.ts +++ b/packages/app-store/zoomvideo/lib/index.ts @@ -1 +1,2 @@ +export { getZoomAppKeys } from "./getZoomAppKeys"; export { default as VideoApiAdapter } from "./VideoApiAdapter"; diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index 26743a805a..ac5ec83aa8 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -14,7 +14,8 @@ "noUnusedParameters": false, "preserveWatchOutput": true, "skipLibCheck": true, - "strict": true + "strict": true, + "target": "es5" }, "exclude": ["node_modules"], "ts-node": {