Zoom refactoring

This commit is contained in:
zomars
2022-06-11 16:15:34 -06:00
parent c03ff0a543
commit 17b8ec65e4
6 changed files with 231 additions and 159 deletions
+27 -28
View File
@@ -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) }),
});
+2 -10
View File
@@ -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");
@@ -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<typeof zoomEventResultSchema>;
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<typeof zoomTokenSchema>;
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<VideoCallData> => {
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<void> => {
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<VideoCallData> => {
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",
@@ -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);
};
@@ -1 +1,2 @@
export { getZoomAppKeys } from "./getZoomAppKeys";
export { default as VideoApiAdapter } from "./VideoApiAdapter";
+2 -1
View File
@@ -14,7 +14,8 @@
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true
"strict": true,
"target": "es5"
},
"exclude": ["node_modules"],
"ts-node": {