diff --git a/apps/web/lib/events/EventManager.ts b/apps/web/lib/events/EventManager.ts index e318b217f1..29dbba3d7a 100644 --- a/apps/web/lib/events/EventManager.ts +++ b/apps/web/lib/events/EventManager.ts @@ -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; diff --git a/apps/web/lib/integrations/calendar/interfaces/Calendar.ts b/apps/web/lib/integrations/calendar/interfaces/Calendar.ts index d794fa1f1a..201ca63649 100644 --- a/apps/web/lib/integrations/calendar/interfaces/Calendar.ts +++ b/apps/web/lib/integrations/calendar/interfaces/Calendar.ts @@ -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"; diff --git a/apps/web/lib/queries/availability/index.ts b/apps/web/lib/queries/availability/index.ts index 3833c05d9c..6a3a3aec7e 100644 --- a/apps/web/lib/queries/availability/index.ts +++ b/apps/web/lib/queries/availability/index.ts @@ -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(), diff --git a/apps/web/pages/api/availability/[user].ts b/apps/web/pages/api/availability/[user].ts index fc6bca48f3..e6716cbbe6 100644 --- a/apps/web/pages/api/availability/[user].ts +++ b/apps/web/pages/api/availability/[user].ts @@ -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(), diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index b860eae863..93e58706a9 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -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"; diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 8e86b2e4b8..1260da3937 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -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"; diff --git a/apps/web/pages/api/integrations/zoomvideo/callback.ts b/apps/web/pages/api/integrations/zoomvideo/callback.ts index 2d6e74790d..5fa1eb854e 100644 --- a/apps/web/pages/api/integrations/zoomvideo/callback.ts +++ b/apps/web/pages/api/integrations/zoomvideo/callback.ts @@ -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, }, }, diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index 3fb8c88edf..c781b62f4d 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -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")) { diff --git a/lib/integrations/videoConferencing/VideoConferencingManager.ts b/lib/integrations/videoConferencing/VideoConferencingManager.ts new file mode 100644 index 0000000000..89908b3f9f --- /dev/null +++ b/lib/integrations/videoConferencing/VideoConferencingManager.ts @@ -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 = { + [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 +): Promise => { + 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 => { + 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 => { + if (credential) { + const adapter = getVideoConferencing(credential); + + if (adapter) { + return adapter.deleteMeeting(uid); + } + } + + return Promise.resolve({}); +}; diff --git a/lib/integrations/videoConferencing/constants/defaults.ts b/lib/integrations/videoConferencing/constants/defaults.ts new file mode 100644 index 0000000000..6af0f2d50f --- /dev/null +++ b/lib/integrations/videoConferencing/constants/defaults.ts @@ -0,0 +1 @@ +export const DEFAULT_VIDEO_CONFERENCING_INTEGRATION_NAME = ""; diff --git a/lib/integrations/videoConferencing/constants/generals.ts b/lib/integrations/videoConferencing/constants/generals.ts new file mode 100644 index 0000000000..2f79c39456 --- /dev/null +++ b/lib/integrations/videoConferencing/constants/generals.ts @@ -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(), +}; diff --git a/lib/integrations/videoConferencing/constants/types.ts b/lib/integrations/videoConferencing/constants/types.ts new file mode 100644 index 0000000000..5700b44898 --- /dev/null +++ b/lib/integrations/videoConferencing/constants/types.ts @@ -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; diff --git a/lib/integrations/videoConferencing/interfaces/VideoConferencing.ts b/lib/integrations/videoConferencing/interfaces/VideoConferencing.ts new file mode 100644 index 0000000000..83b31b79a4 --- /dev/null +++ b/lib/integrations/videoConferencing/interfaces/VideoConferencing.ts @@ -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; +} + +export interface DailyVideoCallData { + type: string; + id: string; + password: string; + url: string; +} + +export type DailyKey = { + apikey: string; +}; + +export interface VideoConferencing { + createMeeting(event: CalendarEvent): Promise; + + updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise; + + deleteMeeting(uid: string): Promise; + + getAvailability(dateFrom?: string, dateTo?: string): Promise; +} diff --git a/lib/integrations/videoConferencing/services/BaseVideoConferencingService.ts b/lib/integrations/videoConferencing/services/BaseVideoConferencingService.ts new file mode 100644 index 0000000000..c325732221 --- /dev/null +++ b/lib/integrations/videoConferencing/services/BaseVideoConferencingService.ts @@ -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 { + this.log.info(`creating meeting with ${JSON.stringify(event)}`); + + throw new Error("Method not implemented."); + } + updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise { + 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 { + 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."); + } +} diff --git a/lib/integrations/videoConferencing/services/DailyVideoConferencingService.ts b/lib/integrations/videoConferencing/services/DailyVideoConferencingService.ts new file mode 100644 index 0000000000..9e6ddeab93 --- /dev/null +++ b/lib/integrations/videoConferencing/services/DailyVideoConferencingService.ts @@ -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) { + 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 { + 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 { + return this.createOrUpdateMeeting("/rooms", event); + } + + async updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise { + return this.createOrUpdateMeeting("/rooms/" + bookingRef.uid, event); + } + + async deleteMeeting(uid: string): Promise { + 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([]); + } +} diff --git a/lib/integrations/videoConferencing/services/ZoomVideoConferencingService.ts b/lib/integrations/videoConferencing/services/ZoomVideoConferencingService.ts new file mode 100644 index 0000000000..dbbe744b7c --- /dev/null +++ b/lib/integrations/videoConferencing/services/ZoomVideoConferencingService.ts @@ -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 { + 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 { + 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 { + 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 []; + }); + } +}