Zoom refactoring
This commit is contained in:
@@ -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) }),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"noUnusedParameters": false,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"target": "es5"
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"ts-node": {
|
||||
|
||||
Reference in New Issue
Block a user