Compare commits

...

1 Commits

Author SHA1 Message Date
Edward Fernandez ef13cdfe85 [CAL-770] add video conferencing integration improvement 2022-02-22 12:41:36 -05:00
16 changed files with 585 additions and 11 deletions

View File

@ -3,13 +3,23 @@ import async from "async";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
<<<<<<< HEAD:apps/web/lib/events/EventManager.ts
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import { FAKE_HUDDLE_CREDENTIAL } from "@lib/integrations/Huddle01/Huddle01VideoApiAdapter";
=======
>>>>>>> [CAL-770] add video conferencing integration improvement:lib/events/EventManager.ts
import { createEvent, updateEvent } from "@lib/integrations/calendar/CalendarManager";
import { AdditionInformation, CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { createMeeting, updateMeeting } from "@lib/integrations/videoConferencing/VideoConferencingManager";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/videoConferencing/constants/generals";
import { VideoCallData } from "@lib/integrations/videoConferencing/interfaces/VideoConferencing";
import { LocationType } from "@lib/location";
import prisma from "@lib/prisma";
<<<<<<< HEAD:apps/web/lib/events/EventManager.ts
import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient";
=======
import { Ensure } from "@lib/types/utils";
>>>>>>> [CAL-770] add video conferencing integration improvement:lib/events/EventManager.ts
export type Event = AdditionInformation & VideoCallData;

View File

@ -4,8 +4,8 @@ import { TFunction } from "next-i18next";
import { PaymentInfo } from "@ee/lib/stripe/server";
import type { Event } from "@lib/events/EventManager";
import { VideoCallData } from "@lib/integrations/videoConferencing/interfaces/VideoConferencing";
import { Ensure } from "@lib/types/utils";
import { VideoCallData } from "@lib/videoClient";
import { NewCalendarEventType } from "../constants/types";
import { ConferenceData } from "./GoogleCalendar";

View File

@ -1,4 +1,3 @@
// import { getBusyVideoTimes } from "@lib/videoClient";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
@ -67,8 +66,6 @@ export async function getUserAvailability(query: {
selectedCalendars
);
// busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),

View File

@ -1,4 +1,3 @@
// import { getBusyVideoTimes } from "@lib/videoClient";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
@ -69,8 +68,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
selectedCalendars
);
// busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
const bufferedBusyTimes = busyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),

View File

@ -23,11 +23,11 @@ import EventManager, { EventResult, PartialReference } from "@lib/events/EventMa
import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar";
import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar";
import { getBusyVideoTimes } from "@lib/integrations/videoConferencing/VideoConferencingManager";
import logger from "@lib/logger";
import notEmpty from "@lib/notEmpty";
import prisma from "@lib/prisma";
import { BookingCreateBody } from "@lib/types/booking";
import { getBusyVideoTimes } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
import getSubscribers from "@lib/webhooks/subscriptions";

View File

@ -8,11 +8,11 @@ import { refund } from "@ee/lib/stripe/server";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { sendCancelledEmails } from "@lib/emails/email-manager";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import { getCalendar } from "@lib/integrations/calendar/CalendarManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { deleteMeeting } from "@lib/integrations/videoConferencing/VideoConferencingManager";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/videoConferencing/constants/generals";
import prisma from "@lib/prisma";
import { deleteMeeting } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
import getSubscribers from "@lib/webhooks/subscriptions";

View File

@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { BASE_URL } from "@lib/config/constants";
import { VIDEO_CONFERENCING_INTEGRATIONS_TYPES } from "@lib/integrations/videoConferencing/constants/generals";
import prisma from "../../../../lib/prisma";
@ -43,7 +44,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
credentials: {
create: {
type: "zoom_video",
type: VIDEO_CONFERENCING_INTEGRATIONS_TYPES.zoom,
key: responseBody,
},
},

View File

@ -33,6 +33,7 @@ import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
import { VIDEO_CONFERENCING_INTEGRATIONS_TYPES } from "@lib/integrations/videoConferencing/constants/generals";
import { LocationType } from "@lib/location";
import showToast from "@lib/notification";
import prisma from "@lib/prisma";
@ -1668,12 +1669,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const locationOptions: OptionTypeBase[] = [];
<<<<<<< HEAD:apps/web/pages/event-types/[type].tsx
if (hasIntegration(integrations, "zoom_video")) {
locationOptions.push({
value: LocationType.Zoom,
label: "Zoom Video",
disabled: true,
});
=======
if (hasIntegration(integrations, VIDEO_CONFERENCING_INTEGRATIONS_TYPES.zoom)) {
locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true });
>>>>>>> [CAL-770] add video conferencing integration improvement:pages/event-types/[type].tsx
}
const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment");
if (hasIntegration(integrations, "google_calendar")) {

View File

@ -0,0 +1,129 @@
import { Credential } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getUid } from "@lib/CalEventParser";
import { EventResult, PartialReference } from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { VIDEO_CONFERENCING_INTEGRATIONS_TYPES } from "@lib/integrations/videoConferencing/constants/generals";
import { VideoConferencingServiceType } from "@lib/integrations/videoConferencing/constants/types";
import VideoConferencingService from "@lib/integrations/videoConferencing/services/BaseVideoConferencingService";
import DailyVideoConferencingService from "@lib/integrations/videoConferencing/services/DailyVideoConferencingService";
import ZoomVideoConferencingService from "@lib/integrations/videoConferencing/services/ZoomVideoConferencingService";
import logger from "@lib/logger";
import { Ensure } from "@lib/types/utils";
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
const translator = short();
const VIDEO_CONFERENCING: Record<string, VideoConferencingServiceType> = {
[VIDEO_CONFERENCING_INTEGRATIONS_TYPES.zoom]: ZoomVideoConferencingService,
[VIDEO_CONFERENCING_INTEGRATIONS_TYPES.daily]: DailyVideoConferencingService,
};
export const getVideoConferencing = (credential: Credential): VideoConferencingService | null => {
const { type } = credential;
const videoConferencing = VIDEO_CONFERENCING[type];
if (!videoConferencing) {
log.warn(`video conferencing of type ${type} does not implemented`);
return null;
}
return new videoConferencing(credential);
};
export const getBusyVideoTimes = (withCredentials: Credential[]) => {
const appCredentials = withCredentials
.map((credential) => getVideoConferencing(credential))
.filter((valid) => valid) as VideoConferencingService[];
return Promise.all(appCredentials.map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);
};
export const createMeeting = async (
credential: Credential,
calEvent: Ensure<CalendarEvent, "language">
): Promise<EventResult> => {
const uid: string = getUid(calEvent);
if (!credential) {
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
}
const adapter = getVideoConferencing(credential);
const createdMeeting = await adapter?.createMeeting(calEvent).catch((e: Error) => {
log.error("createMeeting failed", e, calEvent);
});
if (!createdMeeting) {
return {
type: credential.type,
success: false,
uid,
originalEvent: calEvent,
};
}
return {
type: credential.type,
success: true,
uid,
createdEvent: createdMeeting,
originalEvent: calEvent,
};
};
export const updateMeeting = async (
credential: Credential,
calEvent: CalendarEvent,
bookingRef: PartialReference | null
): Promise<EventResult> => {
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true;
const adapter = getVideoConferencing(credential);
const updatedMeeting =
credential && bookingRef
? await adapter?.updateMeeting(bookingRef, calEvent).catch((e: Error) => {
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
if (!updatedMeeting) {
return {
type: credential.type,
success,
uid,
originalEvent: calEvent,
};
}
return {
type: credential.type,
success,
uid,
updatedEvent: updatedMeeting,
originalEvent: calEvent,
};
};
export const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
if (credential) {
const adapter = getVideoConferencing(credential);
if (adapter) {
return adapter.deleteMeeting(uid);
}
}
return Promise.resolve({});
};

View File

@ -0,0 +1 @@
export const DEFAULT_VIDEO_CONFERENCING_INTEGRATION_NAME = "";

View File

@ -0,0 +1,13 @@
import { Credential } from "@prisma/client";
export const VIDEO_CONFERENCING_INTEGRATIONS_TYPES = {
zoom: "zoom_video",
daily: "daily_video",
};
export const FAKE_DAILY_CREDENTIAL: Credential = {
id: +new Date().getTime(),
type: "daily_video",
key: { apikey: process.env.DAILY_API_KEY },
userId: +new Date().getTime(),
};

View File

@ -0,0 +1,6 @@
import DailyVideoConferencingService from "@lib/integrations/videoConferencing/services/DailyVideoConferencingService";
import ZoomVideoConferencingService from "@lib/integrations/videoConferencing/services/ZoomVideoConferencingService";
export type VideoConferencingServiceType =
| typeof ZoomVideoConferencingService
| typeof DailyVideoConferencingService;

View File

@ -0,0 +1,71 @@
import { PartialReference } from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
type EventBusyDate = Record<"start" | "end", Date>;
export interface VideoCallData {
type: string;
id: string;
password: string;
url: string;
}
export 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 interface DailyReturnType {
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
id: string;
/** Not a real name, just a random generated string ie: "ePR84NQ1bPigp79dDezz" */
name: string;
api_created: boolean;
privacy: "private" | "public";
/** https://api-demo.daily.co/ePR84NQ1bPigp79dDezz */
url: string;
created_at: string;
config: {
nbf: number;
exp: number;
enable_chat: boolean;
enable_knocking: boolean;
enable_prejoin_ui: boolean;
enable_new_call_ui: boolean;
};
}
export interface DailyEventResult {
id: string;
name: string;
api_created: boolean;
privacy: string;
url: string;
created_at: string;
config: Record<string, unknown>;
}
export interface DailyVideoCallData {
type: string;
id: string;
password: string;
url: string;
}
export type DailyKey = {
apikey: string;
};
export interface VideoConferencing {
createMeeting(event: CalendarEvent): Promise<VideoCallData>;
updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData>;
deleteMeeting(uid: string): Promise<unknown>;
getAvailability(dateFrom?: string, dateTo?: string): Promise<EventBusyDate[]>;
}

View File

@ -0,0 +1,42 @@
import { Credential } from "@prisma/client";
import { PartialReference } from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { DEFAULT_VIDEO_CONFERENCING_INTEGRATION_NAME } from "@lib/integrations/videoConferencing/constants/defaults";
import {
VideoCallData,
VideoConferencing,
} from "@lib/integrations/videoConferencing/interfaces/VideoConferencing";
import logger from "@lib/logger";
export default abstract class BaseVideoConferencingService implements VideoConferencing {
protected integrationName = DEFAULT_VIDEO_CONFERENCING_INTEGRATION_NAME;
log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
constructor(credential: Credential, integrationName: string) {
this.integrationName = integrationName;
}
createMeeting(event: CalendarEvent): Promise<VideoCallData> {
this.log.info(`creating meeting with ${JSON.stringify(event)}`);
throw new Error("Method not implemented.");
}
updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> {
this.log.info(
`updating meeting with bookingRef ${JSON.stringify(bookingRef)} and event ${JSON.stringify(event)}`
);
throw new Error("Method not implemented.");
}
deleteMeeting(uid: string): Promise<unknown> {
this.log.info(`deleting meeting with uid ${uid}`);
throw new Error("Method not implemented.");
}
getAvailability(dateFrom?: string, dateTo?: string): Promise<{ start: Date; end: Date }[]> {
this.log.info(`get meeting availability between ${dateFrom} and ${dateTo}`);
throw new Error("Method not implemented.");
}
}

View File

@ -0,0 +1,124 @@
import { Credential } from "@prisma/client";
import { BASE_URL } from "@lib/config/constants";
import { handleErrorsJson } from "@lib/errors";
import { PartialReference } from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { VIDEO_CONFERENCING_INTEGRATIONS_TYPES } from "@lib/integrations/videoConferencing/constants/generals";
import {
DailyKey,
DailyReturnType,
VideoCallData,
} from "@lib/integrations/videoConferencing/interfaces/VideoConferencing";
import BaseVideoConferencingService from "@lib/integrations/videoConferencing/services/BaseVideoConferencingService";
import prisma from "@lib/prisma";
export default class DailyVideoConferencingService extends BaseVideoConferencingService {
private dailyApiToken = "";
constructor(credential: Credential) {
super(credential, VIDEO_CONFERENCING_INTEGRATIONS_TYPES.daily);
this.dailyApiToken = (credential.key as DailyKey).apikey;
}
private async postToDailyAPI(endpoint: string, body: Record<string, any>) {
return fetch("https://api.daily.co/v1" + endpoint, {
method: "POST",
headers: {
Authorization: "Bearer " + this.dailyApiToken,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
private translateEvent(event: CalendarEvent) {
// Documentation at: https://docs.daily.co/reference#list-rooms
// added a 1 hour buffer for room expiration and room entry
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
const nbf = Math.round(new Date(event.startTime).getTime() / 1000) - 60 * 60;
const scalePlan = process.env.DAILY_SCALE_PLAN;
if (scalePlan === "true") {
return {
privacy: "private",
properties: {
enable_new_call_ui: true,
enable_prejoin_ui: true,
enable_knocking: true,
enable_screenshare: true,
enable_chat: true,
exp: exp,
nbf: nbf,
enable_recording: "local",
},
};
}
return {
privacy: "private",
properties: {
enable_new_call_ui: true,
enable_prejoin_ui: true,
enable_knocking: true,
enable_screenshare: true,
enable_chat: true,
exp: exp,
nbf: nbf,
},
};
}
private async createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
if (!event.uid) {
throw new Error("We need need the booking uid to create the Daily reference in DB");
}
const response = await this.postToDailyAPI(endpoint, this.translateEvent(event));
const dailyEvent: DailyReturnType = await handleErrorsJson(response);
const res = await this.postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, is_owner: true },
});
const meetingToken: { token: string } = await handleErrorsJson(res);
await prisma.dailyEventReference.create({
data: {
dailyurl: dailyEvent.url,
dailytoken: meetingToken.token,
booking: {
connect: {
uid: event.uid,
},
},
},
});
return Promise.resolve({
type: "daily_video",
id: dailyEvent.name,
password: "",
url: BASE_URL + "/call/" + event.uid,
});
}
async createMeeting(event: CalendarEvent): Promise<VideoCallData> {
return this.createOrUpdateMeeting("/rooms", event);
}
async updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> {
return this.createOrUpdateMeeting("/rooms/" + bookingRef.uid, event);
}
async deleteMeeting(uid: string): Promise<void> {
await fetch("https://api.daily.co/v1/rooms/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + this.dailyApiToken,
},
}).then(handleErrorsJson);
return Promise.resolve();
}
async getAvailability() {
return Promise.resolve([]);
}
}

View File

@ -0,0 +1,177 @@
import { Credential } from "@prisma/client";
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
import { PartialReference } from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { VIDEO_CONFERENCING_INTEGRATIONS_TYPES } from "@lib/integrations/videoConferencing/constants/generals";
import { VideoCallData, ZoomToken } from "@lib/integrations/videoConferencing/interfaces/VideoConferencing";
import BaseVideoConferencingService from "@lib/integrations/videoConferencing/services/BaseVideoConferencingService";
import prisma from "@lib/prisma";
export default class ZoomVideoConferencingService extends BaseVideoConferencingService {
private auth;
constructor(credential: Credential) {
super(credential, VIDEO_CONFERENCING_INTEGRATIONS_TYPES.zoom);
this.auth = this.zoomAuth(credential);
}
private zoomAuth = (credential: Credential) => {
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(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_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),
};
};
private 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,
},
};
};
async createMeeting(event: CalendarEvent): Promise<VideoCallData> {
const accessToken = await this.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(this.translateEvent(event)),
}).then(handleErrorsJson);
return Promise.resolve({
type: VIDEO_CONFERENCING_INTEGRATIONS_TYPES.zoom,
id: result.id as string,
password: result.password ?? "",
url: result.join_url,
});
}
async updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> {
const accessToken = await this.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(this.translateEvent(event)),
}).then(handleErrorsRaw);
return Promise.resolve({
type: VIDEO_CONFERENCING_INTEGRATIONS_TYPES.zoom,
id: bookingRef.meetingId as string,
password: bookingRef.meetingPassword as string,
url: bookingRef.meetingUrl as string,
});
}
async deleteMeeting(uid: string): Promise<void> {
const accessToken = await this.auth.getToken();
await fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw);
return Promise.resolve();
}
async getAvailability() {
return this.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 [];
});
}
}