import { Credential } from "@prisma/client"; import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; import { HttpError } from "@calcom/lib/http-error"; 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"; /** @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; }; } 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; } let client_id = ""; let client_secret = ""; 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 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", { method: "POST", headers: { Authorization: authHeader, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ refresh_token: refreshToken, grant_type: "refresh_token", }), }) .then(handleErrorsJson) .then(async (responseBody) => { // 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, }, }); credentialKey.expiry_date = responseBody.expiry_date; credentialKey.access_token = responseBody.access_token; return credentialKey.access_token; }); return { getToken: () => !isTokenValid(credentialKey) ? Promise.resolve(credentialKey.access_token) : refreshAccessToken(credentialKey.refresh_token), }; }; 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 { topic: event.title, type: 2, // Means that this is a scheduled meeting start_time: event.startTime, duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000, //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) timezone: event.attendees[0].timeZone, //password: "string", TODO: Should we use a password? Maybe generate a random one? agenda: event.description, settings: { host_video: true, participant_video: true, cn_meeting: false, // TODO: true if host meeting in China in_meeting: false, // TODO: true if host meeting in India join_before_host: true, mute_upon_entry: false, watermark: false, use_pmi: false, approval_type: 2, audio: "both", auto_recording: "none", enforce_login: false, registrants_email_notification: true, }, }; }; return { getAvailability: () => { return 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 []; }); }, createMeeting: async (event: CalendarEvent): Promise => { const accessToken = await auth.getToken(); const result = await fetch("https://api.zoom.us/v2/users/me/meetings", { method: "POST", headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json", }, body: JSON.stringify(translateEvent(event)), }).then(handleErrorsJson); return Promise.resolve({ type: "zoom_video", id: result.id as string, password: result.password ?? "", url: result.join_url, }); }, deleteMeeting: async (uid: string): Promise => { const accessToken = await auth.getToken(); await fetch("https://api.zoom.us/v2/meetings/" + uid, { method: "DELETE", headers: { Authorization: "Bearer " + accessToken, }, }).then(handleErrorsRaw); return Promise.resolve(); }, updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise => { const accessToken = await auth.getToken(); await fetch("https://api.zoom.us/v2/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", id: bookingRef.meetingId as string, password: bookingRef.meetingPassword as string, url: bookingRef.meetingUrl as string, }); }, }; }; export default ZoomVideoApiAdapter;