From e38086b8fe6cab117e3929bb01f8fd3bb500643b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Tue, 26 Oct 2021 10:17:24 -0600 Subject: [PATCH] Refactors video integrations (#1037) * Fixes error types * Type fixes * Refactors video meeting handling * More type fixes * Type fixes * More fixes * Makes language non optional * Adds missing translations * Apply suggestions from code review Co-authored-by: Alex Johansson * Feedback Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Alex Johansson --- .../api/integrations/stripepayment/webhook.ts | 11 +- lib/calendarClient.ts | 168 +++++++----- lib/dailyVideoClient.ts | 233 ---------------- lib/errors.ts | 23 +- lib/events/EventManager.ts | 72 ++--- .../Daily/DailyVideoApiAdapter.ts | 131 +++++++++ lib/integrations/Zoom/ZoomVideoApiAdapter.ts | 205 ++++++++++++++ lib/types/booking.ts | 7 +- lib/types/utils.ts | 6 +- lib/videoClient.ts | 257 +++++------------- package.json | 1 + pages/api/availability/[user].ts | 3 +- pages/api/book/confirm.ts | 11 +- pages/api/book/event.ts | 49 +--- pages/api/cancel.ts | 39 ++- pages/api/cron/bookingReminder.ts | 9 +- yarn.lock | 5 + 17 files changed, 618 insertions(+), 612 deletions(-) delete mode 100644 lib/dailyVideoClient.ts create mode 100644 lib/integrations/Daily/DailyVideoApiAdapter.ts create mode 100644 lib/integrations/Zoom/ZoomVideoApiAdapter.ts diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts index 42e7fb804b..e906aae9ef 100644 --- a/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/ee/pages/api/integrations/stripepayment/webhook.ts @@ -9,6 +9,9 @@ import { HttpError } from "@lib/core/http/error"; import { getErrorFromUnknown } from "@lib/errors"; import EventManager from "@lib/events/EventManager"; import prisma from "@lib/prisma"; +import { Ensure } from "@lib/types/utils"; + +import { getTranslation } from "@server/lib/i18n"; export const config = { api: { @@ -69,7 +72,9 @@ async function handlePaymentSuccess(event: Stripe.Event) { if (!user) throw new Error("No user found"); - const evt: CalendarEvent = { + const t = await getTranslation(/* FIXME handle mulitple locales here */ "en", "common"); + + const evt: Ensure = { type: booking.title, title: booking.title, description: booking.description || undefined, @@ -77,12 +82,14 @@ async function handlePaymentSuccess(event: Stripe.Event) { endTime: booking.endTime.toISOString(), organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone }, attendees: booking.attendees, + uid: booking.uid, + language: t, }; if (booking.location) evt.location = booking.location; if (booking.confirmed) { const eventManager = new EventManager(user.credentials); - const scheduleResult = await eventManager.create(evt, booking.uid); + const scheduleResult = await eventManager.create(evt); await prisma.booking.update({ where: { diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index b0dfcfe6bc..0e45b8345d 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,7 +1,11 @@ -import { Credential } from "@prisma/client"; +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; +import { Credential, Prisma, SelectedCalendar } from "@prisma/client"; +import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client"; +import { Auth, calendar_v3, google } from "googleapis"; import { TFunction } from "next-i18next"; -import { EventResult } from "@lib/events/EventManager"; +import { Event, EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; import { VideoCallData } from "@lib/videoClient"; @@ -14,34 +18,34 @@ import prisma from "./prisma"; const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { google } = require("googleapis"); - -const googleAuth = (credential) => { - const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; +const googleAuth = (credential: Credential) => { + const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web; const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); - myGoogleAuth.setCredentials(credential.key); + const googleCredentials = credential.key as Auth.Credentials; + myGoogleAuth.setCredentials(googleCredentials); + // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ const isExpired = () => myGoogleAuth.isTokenExpiring(); const refreshAccessToken = () => myGoogleAuth - .refreshToken(credential.key.refresh_token) - .then((res) => { - const token = res.res.data; - credential.key.access_token = token.access_token; - credential.key.expiry_date = token.expiry_date; + // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ + .refreshToken(googleCredentials.refresh_token) + .then((res: GetTokenResponse) => { + const token = res.res?.data; + googleCredentials.access_token = token.access_token; + googleCredentials.expiry_date = token.expiry_date; return prisma.credential .update({ where: { id: credential.id, }, data: { - key: credential.key, + key: googleCredentials as Prisma.InputJsonValue, }, }) .then(() => { - myGoogleAuth.setCredentials(credential.key); + myGoogleAuth.setCredentials(googleCredentials); return myGoogleAuth; }); }) @@ -71,13 +75,21 @@ function handleErrorsRaw(response: Response) { return response.text(); } -const o365Auth = (credential) => { - const isExpired = (expiryDate) => expiryDate < Math.round(+new Date() / 1000); +type O365AuthCredentials = { + expiry_date: number; + access_token: string; + refresh_token: string; +}; - const refreshAccessToken = (refreshToken) => { +const o365Auth = (credential: Credential) => { + const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); + const o365AuthCredentials = credential.key as O365AuthCredentials; + + const refreshAccessToken = (refreshToken: string) => { return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, + // FIXME types - IDK how to type this TBH body: new URLSearchParams({ scope: "User.Read Calendars.Read Calendars.ReadWrite", client_id: process.env.MS_GRAPH_CLIENT_ID, @@ -88,26 +100,26 @@ const o365Auth = (credential) => { }) .then(handleErrorsJson) .then((responseBody) => { - credential.key.access_token = responseBody.access_token; - credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); + o365AuthCredentials.access_token = responseBody.access_token; + o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); return prisma.credential .update({ where: { id: credential.id, }, data: { - key: credential.key, + key: o365AuthCredentials, }, }) - .then(() => credential.key.access_token); + .then(() => o365AuthCredentials.access_token); }); }; return { getToken: () => - !isExpired(credential.key.expiry_date) - ? Promise.resolve(credential.key.access_token) - : refreshAccessToken(credential.key.refresh_token), + !isExpired(o365AuthCredentials.expiry_date) + ? Promise.resolve(o365AuthCredentials.access_token) + : refreshAccessToken(o365AuthCredentials.refresh_token), }; }; @@ -146,24 +158,22 @@ export interface CalendarEvent { conferenceData?: ConferenceData; language: TFunction; additionInformation?: AdditionInformation; + /** If this property exist it we can assume it's a reschedule/update */ uid?: string | null; videoCallData?: VideoCallData; } export interface ConferenceData { - createRequest: unknown; + createRequest: calendar_v3.Schema$CreateConferenceRequest; } - -export interface IntegrationCalendar { - integration: string; - primary: boolean; - externalId: string; - name: string; +export interface IntegrationCalendar extends Partial { + primary?: boolean; + name?: string; } type BufferedBusyTime = { start: string; end: string }; export interface CalendarApiAdapter { - createEvent(event: CalendarEvent): Promise; + createEvent(event: CalendarEvent): Promise; updateEvent(uid: string, event: CalendarEvent): Promise; @@ -178,15 +188,10 @@ export interface CalendarApiAdapter { listCalendars(): Promise; } -const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { +const MicrosoftOffice365Calendar = (credential: Credential): CalendarApiAdapter => { const auth = o365Auth(credential); const translateEvent = (event: CalendarEvent) => { - const optional = {}; - if (event.location) { - optional.location = { displayName: event.location }; - } - return { subject: event.title, body: { @@ -208,7 +213,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }, type: "required", })), - ...optional, + location: event.location ? { displayName: event.location } : undefined, }; }; @@ -224,13 +229,13 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }, }) .then(handleErrorsJson) - .then((responseBody) => { + .then((responseBody: { value: OfficeCalendar[] }) => { return responseBody.value.map((cal) => { const calendar: IntegrationCalendar = { - externalId: cal.id, + externalId: cal.id ?? "No Id", integration: integrationType, - name: cal.name, - primary: cal.isDefaultCalendar, + name: cal.name ?? "No calendar name", + primary: cal.isDefaultCalendar ?? false, }; return calendar; }); @@ -248,17 +253,18 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { .then((accessToken) => { const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === integrationType) - .map((e) => e.externalId); - if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) { + .map((e) => e.externalId) + .filter(Boolean); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { // Only calendars of other integrations selected return Promise.resolve([]); } return ( - selectedCalendarIds.length == 0 - ? listCalendars().then((cals) => cals.map((e) => e.externalId)) - : Promise.resolve(selectedCalendarIds).then((x) => x) - ).then((ids: string[]) => { + selectedCalendarIds.length === 0 + ? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || []) + : Promise.resolve(selectedCalendarIds) + ).then((ids) => { const requests = ids.map((calendarId, id) => ({ id, method: "GET", @@ -268,6 +274,13 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { url: `/me/calendars/${calendarId}/calendarView${filter}`, })); + type BatchResponse = { + responses: SubResponse[]; + }; + type SubResponse = { + body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] }; + }; + return fetch("https://graph.microsoft.com/v1.0/$batch", { method: "POST", headers: { @@ -277,9 +290,9 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { body: JSON.stringify({ requests }), }) .then(handleErrorsJson) - .then((responseBody) => + .then((responseBody: BatchResponse) => responseBody.responses.reduce( - (acc, subResponse) => + (acc: BufferedBusyTime[], subResponse) => acc.concat( subResponse.body.value.map((evt) => { return { @@ -295,6 +308,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }) .catch((err) => { console.log(err); + return Promise.reject([]); }); }, createEvent: (event: CalendarEvent) => @@ -337,7 +351,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }; }; -const GoogleCalendar = (credential): CalendarApiAdapter => { +const GoogleCalendar = (credential: Credential): CalendarApiAdapter => { const auth = googleAuth(credential); const integrationType = "google_calendar"; @@ -352,14 +366,16 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === integrationType) .map((e) => e.externalId); - if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) { + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { // Only calendars of other integrations selected resolve([]); return; } - (selectedCalendarIds.length == 0 - ? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id)) + (selectedCalendarIds.length === 0 + ? calendar.calendarList + .list() + .then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || []) : Promise.resolve(selectedCalendarIds) ) .then((calsIds) => { @@ -375,6 +391,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { if (err) { reject(err); } + // @ts-ignore FIXME resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); } ); @@ -388,7 +405,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => auth.getToken().then((myGoogleAuth) => { - const payload = { + const payload: calendar_v3.Schema$Event = { summary: event.title, description: event.description, start: { @@ -422,14 +439,15 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { { auth: myGoogleAuth, calendarId: "primary", - resource: payload, + requestBody: payload, conferenceDataVersion: 1, }, function (err, event) { - if (err) { + if (err || !event?.data) { console.error("There was an error contacting google calendar service: ", err); return reject(err); } + // @ts-ignore FIXME return resolve(event.data); } ); @@ -438,7 +456,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { updateEvent: (uid: string, event: CalendarEvent) => new Promise((resolve, reject) => auth.getToken().then((myGoogleAuth) => { - const payload = { + const payload: calendar_v3.Schema$Event = { summary: event.title, description: event.description, start: { @@ -471,14 +489,14 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { eventId: uid, sendNotifications: true, sendUpdates: "all", - resource: payload, + requestBody: payload, }, function (err, event) { if (err) { console.error("There was an error contacting google calendar service: ", err); return reject(err); } - return resolve(event.data); + return resolve(event?.data); } ); }) @@ -503,7 +521,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { console.error("There was an error contacting google calendar service: ", err); return reject(err); } - return resolve(event.data); + return resolve(event?.data); } ); }) @@ -519,15 +537,15 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { .list() .then((cals) => { resolve( - cals.data.items.map((cal) => { + cals.data.items?.map((cal) => { const calendar: IntegrationCalendar = { - externalId: cal.id, + externalId: cal.id ?? "No id", integration: integrationType, - name: cal.summary, - primary: cal.primary, + name: cal.summary ?? "No name", + primary: cal.primary ?? false, }; return calendar; - }) + }) || [] ); }) .catch((err) => { @@ -576,7 +594,12 @@ const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] => }) .flatMap((item) => (item ? [item as CalendarApiAdapter] : [])); -const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => +const getBusyCalendarTimes = ( + withCredentials: Credential[], + dateFrom: string, + dateTo: string, + selectedCalendars: SelectedCalendar[] +) => Promise.all( calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) ).then((results) => { @@ -588,7 +611,7 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda * @param withCredentials * @deprecated */ -const listCalendars = (withCredentials) => +const listCalendars = (withCredentials: Credential[]) => Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null) ); @@ -609,14 +632,15 @@ const createEvent = async ( let success = true; - const creationResult: any = credential + const creationResult = credential ? await calendars([credential])[0] .createEvent(richEvent) .catch((e) => { log.error("createEvent failed", e, calEvent); success = false; + return undefined; }) - : null; + : undefined; const metadata: AdditionInformation = {}; if (creationResult) { diff --git a/lib/dailyVideoClient.ts b/lib/dailyVideoClient.ts deleted file mode 100644 index 736eaee93e..0000000000 --- a/lib/dailyVideoClient.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { Credential } from "@prisma/client"; -import short from "short-uuid"; -import { v5 as uuidv5 } from "uuid"; - -import CalEventParser from "@lib/CalEventParser"; -import { getIntegrationName } from "@lib/emails/helpers"; -import { EventResult } from "@lib/events/EventManager"; -import logger from "@lib/logger"; - -import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient"; -import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; -import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; -import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; -import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; - -const log = logger.getChildLogger({ prefix: ["[lib] dailyVideoClient"] }); - -const translator = short(); - -export interface DailyVideoCallData { - type: string; - id: string; - password: string; - url: string; -} - -function handleErrorsJson(response: Response) { - if (!response.ok) { - response.json().then(console.log); - throw Error(response.statusText); - } - return response.json(); -} - -const dailyCredential = process.env.DAILY_API_KEY; - -interface DailyVideoApiAdapter { - dailyCreateMeeting(event: CalendarEvent): Promise; - - dailyUpdateMeeting(uid: string, event: CalendarEvent): Promise; - - dailyDeleteMeeting(uid: string): Promise; - - getAvailability(dateFrom, dateTo): Promise; -} - -const DailyVideo = (credential: Credential): DailyVideoApiAdapter => { - const 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; - 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, - }, - }; - }; - - return { - getAvailability: () => { - return credential; - }, - dailyCreateMeeting: (event: CalendarEvent) => - fetch("https://api.daily.co/v1/rooms", { - method: "POST", - headers: { - Authorization: "Bearer " + dailyCredential, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsJson), - dailyDeleteMeeting: (uid: string) => - fetch("https://api.daily.co/v1/rooms/" + uid, { - method: "DELETE", - headers: { - Authorization: "Bearer " + dailyCredential, - }, - }).then(handleErrorsJson), - dailyUpdateMeeting: (uid: string, event: CalendarEvent) => - fetch("https://api.daily.co/v1/rooms/" + uid, { - method: "POST", - headers: { - Authorization: "Bearer " + dailyCredential, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsJson), - }; -}; - -// factory -const videoIntegrations = (withCredentials): DailyVideoApiAdapter[] => - withCredentials - .map((cred) => { - return DailyVideo(cred); - }) - .filter(Boolean); - -const getBusyVideoTimes: (withCredentials) => Promise = (withCredentials) => - Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) => - results.reduce((acc, availability) => acc.concat(availability), []) - ); - -const dailyCreateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent); - const uid: string = parser.getUid(); - - 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." - ); - } - - let success = true; - - const creationResult = await videoIntegrations([credential])[0] - .dailyCreateMeeting(calEvent) - .catch((e) => { - log.error("createMeeting failed", e, calEvent); - success = false; - }); - - const currentRoute = process.env.BASE_URL; - - const videoCallData: DailyVideoCallData = { - type: "Daily.co Video", - id: creationResult.name, - password: creationResult.password, - url: currentRoute + "/call/" + uid, - }; - - const entryPoint: EntryPoint = { - entryPointType: getIntegrationName(videoCallData), - uri: videoCallData.url, - label: calEvent.language("enter_meeting"), - pin: "", - }; - - const additionInformation: AdditionInformation = { - entryPoints: [entryPoint], - }; - const emailEvent = { ...calEvent, uid, additionInformation, videoCallData }; - - try { - const organizerMail = new VideoEventOrganizerMail(emailEvent); - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - - if (!creationResult || !creationResult.disableConfirmationEmail) { - try { - const attendeeMail = new VideoEventAttendeeMail(emailEvent); - await attendeeMail.sendEmail(); - } catch (e) { - console.error("attendeeMail.sendEmail failed", e); - } - } - - return { - type: "daily", - success, - uid, - createdEvent: creationResult, - originalEvent: calEvent, - }; -}; - -const dailyUpdateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { - const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); - - 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." - ); - } - - let success = true; - - const updateResult = - credential && calEvent.uid - ? await videoIntegrations([credential])[0] - .dailyUpdateMeeting(calEvent.uid, calEvent) - .catch((e) => { - log.error("updateMeeting failed", e, calEvent); - success = false; - }) - : null; - - const emailEvent = { ...calEvent, uid: newUid }; - - try { - const organizerMail = new EventOrganizerRescheduledMail(emailEvent); - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - - if (!updateResult || !updateResult.disableConfirmationEmail) { - try { - const attendeeMail = new EventAttendeeRescheduledMail(emailEvent); - await attendeeMail.sendEmail(); - } catch (e) { - console.error("attendeeMail.sendEmail failed", e); - } - } - - return { - type: credential.type, - success, - uid: newUid, - updatedEvent: updateResult, - originalEvent: calEvent, - }; -}; - -const dailyDeleteMeeting = (credential: Credential, uid: string): Promise => { - if (credential) { - return videoIntegrations([credential])[0].dailyDeleteMeeting(uid); - } - - return Promise.resolve({}); -}; - -export { getBusyVideoTimes, dailyCreateMeeting, dailyUpdateMeeting, dailyDeleteMeeting }; diff --git a/lib/errors.ts b/lib/errors.ts index a274c699bf..78d35001e5 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -1,4 +1,9 @@ -export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number; code?: unknown } { +import { Prisma } from "@prisma/client"; + +export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number; code?: string } { + if (cause instanceof Prisma.PrismaClientKnownRequestError) { + return cause; + } if (cause instanceof Error) { return cause; } @@ -9,3 +14,19 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb return new Error(`Unhandled error of type '${typeof cause}''`); } + +export function handleErrorsJson(response: Response) { + if (!response.ok) { + response.json().then(console.log); + throw Error(response.statusText); + } + return response.json(); +} + +export function handleErrorsRaw(response: Response) { + if (!response.ok) { + response.text().then(console.log); + throw Error(response.statusText); + } + return response.text(); +} diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index ee624a42c7..97ef3946e6 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -3,20 +3,27 @@ import async from "async"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; -import { CalendarEvent, AdditionInformation, createEvent, updateEvent } from "@lib/calendarClient"; -import { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient"; +import { AdditionInformation, CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient"; import EventAttendeeMail from "@lib/emails/EventAttendeeMail"; import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail"; +import { DailyEventResult, FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; +import { ZoomEventResult } from "@lib/integrations/Zoom/ZoomVideoApiAdapter"; import { LocationType } from "@lib/location"; import prisma from "@lib/prisma"; +import { Ensure } from "@lib/types/utils"; import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient"; +export type Event = AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean } & ( + | ZoomEventResult + | DailyEventResult + ); + export interface EventResult { type: string; success: boolean; uid: string; - createdEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean }; - updatedEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean }; + createdEvent?: Event; + updatedEvent?: Event; originalEvent: CalendarEvent; videoCallData?: VideoCallData; } @@ -44,9 +51,6 @@ interface GetLocationRequestFromIntegrationRequest { location: string; } -//const to idenfity a daily event location -const dailyLocation = "integrations:daily"; - export default class EventManager { calendarCredentials: Array; videoCredentials: Array; @@ -61,16 +65,9 @@ export default class EventManager { this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video")); //for Daily.co video, temporarily pushes a credential for the daily-video-client - const hasDailyIntegration = process.env.DAILY_API_KEY; - const dailyCredential: Credential = { - id: +new Date().getTime(), - type: "daily_video", - key: { apikey: process.env.DAILY_API_KEY }, - userId: +new Date().getTime(), - }; if (hasDailyIntegration) { - this.videoCredentials.push(dailyCredential); + this.videoCredentials.push(FAKE_DAILY_CREDENTIAL); } } @@ -81,7 +78,7 @@ export default class EventManager { * * @param event */ - public async create(event: CalendarEvent): Promise { + public async create(event: Ensure): Promise { let evt = EventManager.processLocation(event); const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null; @@ -103,13 +100,14 @@ export default class EventManager { results = results.concat(await this.createAllCalendarEvents(evt, isDedicated)); const referencesToCreate: Array = results.map((result: EventResult) => { - const isDailyResult = result.type === "daily"; let uid = ""; - if (isDailyResult && result.createdEvent) { - uid = result.createdEvent.name.toString(); - } - if (!isDailyResult && result.createdEvent) { - uid = result.createdEvent.id.toString(); + if (result.createdEvent) { + const isDailyResult = result.type === "daily_video"; + if (isDailyResult) { + uid = (result.createdEvent as DailyEventResult).name.toString(); + } else { + uid = (result.createdEvent as ZoomEventResult).id.toString(); + } } return { type: result.type, @@ -132,11 +130,11 @@ export default class EventManager { * * @param event */ - public async update(event: CalendarEvent): Promise { + public async update(event: Ensure): Promise { let evt = EventManager.processLocation(event); if (!evt.uid) { - throw new Error("missing uid"); + throw new Error("You called eventManager.update without an `uid`. This should never happen."); } // Get details of existing booking. @@ -163,9 +161,7 @@ export default class EventManager { throw new Error("booking not found"); } - const isDedicated = evt.location - ? EventManager.isDedicatedIntegration(evt.location) || evt.location === dailyLocation - : null; + const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null; let results: Array = []; @@ -259,15 +255,11 @@ export default class EventManager { * @param event * @private */ - private createVideoEvent(event: CalendarEvent): Promise { + private createVideoEvent(event: Ensure): Promise { const credential = this.getVideoCredential(event); - const isDaily = event.location === dailyLocation; - - if (credential && !isDaily) { + if (credential) { return createMeeting(credential, event); - } else if (credential && isDaily) { - return dailyCreateMeeting(credential, event); } else { return Promise.reject("No suitable credentials given for the requested integration name."); } @@ -307,9 +299,8 @@ export default class EventManager { */ private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) { const credential = this.getVideoCredential(event); - const isDaily = event.location === dailyLocation; - if (credential && !isDaily) { + if (credential) { const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null; const evt = { ...event, uid: bookingRef?.uid }; return updateMeeting(credential, evt).then((returnVal: EventResult) => { @@ -320,13 +311,6 @@ export default class EventManager { return returnVal; }); } else { - if (credential && isDaily) { - const bookingRefUid = booking - ? booking.references.filter((ref) => ref.type === "daily")[0].uid - : null; - const evt = { ...event, uid: bookingRefUid }; - return dailyUpdateMeeting(credential, evt); - } return Promise.reject("No suitable credentials given for the requested integration name."); } } @@ -345,7 +329,7 @@ export default class EventManager { private static isDedicatedIntegration(location: string): boolean { // Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't. - return location === "integrations:zoom" || location === dailyLocation; + return location === "integrations:zoom" || location === "integrations:daily"; } /** @@ -385,7 +369,7 @@ export default class EventManager { * @param event * @private */ - private static processLocation(event: CalendarEvent): CalendarEvent { + private static processLocation(event: T): T { // If location is set to an integration location // Build proper transforms for evt object // Extend evt object with those transformations diff --git a/lib/integrations/Daily/DailyVideoApiAdapter.ts b/lib/integrations/Daily/DailyVideoApiAdapter.ts new file mode 100644 index 0000000000..dd4c11afa1 --- /dev/null +++ b/lib/integrations/Daily/DailyVideoApiAdapter.ts @@ -0,0 +1,131 @@ +import { Credential } from "@prisma/client"; + +import { CalendarEvent } from "@lib/calendarClient"; +import { handleErrorsJson } from "@lib/errors"; +import prisma from "@lib/prisma"; +import { VideoApiAdapter } from "@lib/videoClient"; + +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; +} + +type DailyKey = { + apikey: string; +}; + +export const FAKE_DAILY_CREDENTIAL: Credential = { + id: +new Date().getTime(), + type: "daily_video", + key: { apikey: process.env.DAILY_API_KEY }, + userId: +new Date().getTime(), +}; + +const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => { + const dailyApiToken = (credential.key as DailyKey).apikey; + + function postToDailyAPI(endpoint: string, body: Record) { + return fetch("https://api.daily.co/v1" + endpoint, { + method: "POST", + headers: { + Authorization: "Bearer " + dailyApiToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent) { + if (!event.uid) { + throw new Error("We need need the booking uid to create the Daily reference in DB"); + } + const response = await postToDailyAPI(endpoint, translateEvent(event)); + const dailyEvent: DailyReturnType = await handleErrorsJson(response); + const res = await 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 dailyEvent; + } + + const 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; + 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, + }, + }; + }; + + return { + /** Daily doesn't need to return busy times, so we return empty */ + getAvailability: () => { + return Promise.resolve([]); + }, + createMeeting: async (event: CalendarEvent) => createOrUpdateMeeting("/rooms", event), + deleteMeeting: (uid: string) => + fetch("https://api.daily.co/v1/rooms/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + dailyApiToken, + }, + }).then(handleErrorsJson), + updateMeeting: (uid: string, event: CalendarEvent) => createOrUpdateMeeting("/rooms/" + uid, event), + }; +}; + +export default DailyVideoApiAdapter; diff --git a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts new file mode 100644 index 0000000000..ffb4e3065b --- /dev/null +++ b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts @@ -0,0 +1,205 @@ +import { Credential } from "@prisma/client"; + +import { CalendarEvent } from "@lib/calendarClient"; +import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; +import prisma from "@lib/prisma"; +import { VideoApiAdapter } from "@lib/videoClient"; + +/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */ +export interface ZoomEventResult { + 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"; + expires_in: number; + token_type: "bearer"; + access_token: string; + refresh_token: string; +} + +const zoomAuth = (credential: Credential) => { + const credentialKey = credential.key as unknown as ZoomToken; + const isExpired = (expiryDate: number) => expiryDate < +new Date(); + 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) => { + // Store new tokens in database. + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: responseBody, + }, + }); + credentialKey.access_token = responseBody.access_token; + credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in); + return credentialKey.access_token; + }); + + return { + getToken: () => + !isExpired(credentialKey.expires_in) + ? 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: (event: CalendarEvent) => + auth.getToken().then((accessToken) => + 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) + ), + deleteMeeting: (uid: string) => + auth.getToken().then((accessToken) => + fetch("https://api.zoom.us/v2/meetings/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + accessToken, + }, + }).then(handleErrorsRaw) + ), + updateMeeting: (uid: string, event: CalendarEvent) => + auth.getToken().then((accessToken: string) => + fetch("https://api.zoom.us/v2/meetings/" + uid, { + method: "PATCH", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsRaw) + ), + }; +}; + +export default ZoomVideoApiAdapter; diff --git a/lib/types/booking.ts b/lib/types/booking.ts index 1b0b8d819e..8be00d4fb6 100644 --- a/lib/types/booking.ts +++ b/lib/types/booking.ts @@ -2,12 +2,17 @@ import { Booking } from "@prisma/client"; import { LocationType } from "@lib/location"; +export type BookingConfirmBody = { + confirmed: boolean; + id: number; +}; + export type BookingCreateBody = { email: string; end: string; eventTypeId: number; guests: string[]; - location?: LocationType; + location: LocationType; name: string; notes: string; rescheduleUid?: string; diff --git a/lib/types/utils.ts b/lib/types/utils.ts index 718c55eb07..3b8a05449e 100644 --- a/lib/types/utils.ts +++ b/lib/types/utils.ts @@ -1,5 +1,3 @@ -export type RequiredNotNull = { - [P in keyof T]: NonNullable; +export type Ensure = Omit & { + [EK in K]-?: NonNullable; }; - -export type Ensure = T & RequiredNotNull>; diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 15719fa7d1..ac9dc8ebc7 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -3,29 +3,24 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import CalEventParser from "@lib/CalEventParser"; +import "@lib/emails/EventMail"; import { getIntegrationName } from "@lib/emails/helpers"; import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; -import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient"; +import { AdditionInformation, CalendarEvent, EntryPoint } from "./calendarClient"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; -import prisma from "./prisma"; +import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter"; +import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter"; +import { Ensure } from "./types/utils"; const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] }); const translator = short(); -export interface ZoomToken { - scope: "meeting:write"; - expires_in: number; - token_type: "bearer"; - access_token: string; - refresh_token: string; -} - export interface VideoCallData { type: string; id: string; @@ -33,176 +28,27 @@ export interface VideoCallData { url: string; } -function handleErrorsJson(response: Response) { - if (!response.ok) { - response.json().then(console.log); - throw Error(response.statusText); - } - return response.json(); -} +type EventBusyDate = Record<"start" | "end", Date>; -function handleErrorsRaw(response: Response) { - if (!response.ok) { - response.text().then(console.log); - throw Error(response.statusText); - } - return response.text(); -} - -const zoomAuth = (credential: Credential) => { - const credentialKey = credential.key as unknown as ZoomToken; - const isExpired = (expiryDate: number) => expiryDate < +new Date(); - 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) => { - // Store new tokens in database. - await prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - key: responseBody, - }, - }); - credentialKey.access_token = responseBody.access_token; - credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in); - return credentialKey.access_token; - }); - - return { - getToken: () => - !isExpired(credentialKey.expires_in) - ? Promise.resolve(credentialKey.access_token) - : refreshAccessToken(credentialKey.refresh_token), - }; -}; - -interface VideoApiAdapter { +export interface VideoApiAdapter { createMeeting(event: CalendarEvent): Promise; updateMeeting(uid: string, event: CalendarEvent): Promise; deleteMeeting(uid: string): Promise; - getAvailability(dateFrom: string, dateTo: string): Promise; + getAvailability(dateFrom?: string, dateTo?: string): Promise; } -const ZoomVideo = (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: 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: (event: CalendarEvent) => - auth.getToken().then((accessToken) => - 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) - ), - deleteMeeting: (uid: string) => - auth.getToken().then((accessToken) => - fetch("https://api.zoom.us/v2/meetings/" + uid, { - method: "DELETE", - headers: { - Authorization: "Bearer " + accessToken, - }, - }).then(handleErrorsRaw) - ), - updateMeeting: (uid: string, event: CalendarEvent) => - auth.getToken().then((accessToken) => - fetch("https://api.zoom.us/v2/meetings/" + uid, { - method: "PATCH", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw) - ), - }; -}; - // factory -const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] => +const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] => withCredentials.reduce((acc, cred) => { switch (cred.type) { case "zoom_video": - acc.push(ZoomVideo(cred)); + acc.push(ZoomVideoApiAdapter(cred)); + break; + case "daily_video": + acc.push(DailyVideoApiAdapter(cred)); break; default: break; @@ -211,11 +57,14 @@ const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] => }, []); const getBusyVideoTimes: (withCredentials: Credential[]) => Promise = (withCredentials) => - Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) => + Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { +const createMeeting = async ( + credential: Credential, + calEvent: Ensure +): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); @@ -227,20 +76,35 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent): P let success = true; - const creationResult = await videoIntegrations([credential])[0] - .createMeeting(calEvent) - .catch((e) => { - log.error("createMeeting failed", e, calEvent); - success = false; - }); + const videoAdapters = getVideoAdapters([credential]); + const [firstVideoAdapter] = videoAdapters; + const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch((e) => { + log.error("createMeeting failed", e, calEvent); + success = false; + }); + + if (!createdMeeting) { + return { + type: credential.type, + success, + uid, + originalEvent: calEvent, + }; + } const videoCallData: VideoCallData = { type: credential.type, - id: creationResult.id, - password: creationResult.password, - url: creationResult.join_url, + id: createdMeeting.id, + password: createdMeeting.password, + url: createdMeeting.join_url, }; + if (credential.type === "daily_video") { + videoCallData.type = "Daily.co Video"; + videoCallData.id = createdMeeting.name; + videoCallData.url = process.env.BASE_URL + "/call/" + uid; + } + const entryPoint: EntryPoint = { entryPointType: getIntegrationName(videoCallData), uri: videoCallData.url, @@ -261,7 +125,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent): P console.error("organizerMail.sendEmail failed", e); } - if (!creationResult || !creationResult.disableConfirmationEmail) { + if (!createdMeeting || !createdMeeting.disableConfirmationEmail) { try { const attendeeMail = new VideoEventAttendeeMail(emailEvent); await attendeeMail.sendEmail(); @@ -274,7 +138,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent): P type: credential.type, success, uid, - createdEvent: creationResult, + createdEvent: createdMeeting, originalEvent: calEvent, videoCallData: videoCallData, }; @@ -289,17 +153,26 @@ const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): P ); } + if (!calEvent.uid) { + throw new Error("You can't update an meeting without it's UID."); + } + let success = true; - const updateResult = - credential && calEvent.uid - ? await videoIntegrations([credential])[0] - .updateMeeting(calEvent.uid, calEvent) - .catch((e) => { - log.error("updateMeeting failed", e, calEvent); - success = false; - }) - : null; + const [firstVideoAdapter] = getVideoAdapters([credential]); + const updatedMeeting = await firstVideoAdapter.updateMeeting(calEvent.uid, calEvent).catch((e) => { + log.error("updateMeeting failed", e, calEvent); + success = false; + }); + + if (!updatedMeeting) { + return { + type: credential.type, + success, + uid: calEvent.uid, + originalEvent: calEvent, + }; + } const emailEvent = { ...calEvent, uid: newUid }; @@ -310,7 +183,7 @@ const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): P console.error("organizerMail.sendEmail failed", e); } - if (!updateResult || !updateResult.disableConfirmationEmail) { + if (!updatedMeeting.disableConfirmationEmail) { try { const attendeeMail = new EventAttendeeRescheduledMail(emailEvent); await attendeeMail.sendEmail(); @@ -323,14 +196,14 @@ const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): P type: credential.type, success, uid: newUid, - updatedEvent: updateResult, + updatedEvent: updatedMeeting, originalEvent: calEvent, }; }; const deleteMeeting = (credential: Credential, uid: string): Promise => { if (credential) { - return videoIntegrations([credential])[0].deleteMeeting(uid); + return getVideoAdapters([credential])[0].deleteMeeting(uid); } return Promise.resolve({}); diff --git a/package.json b/package.json index ce177cdbbf..aaade78e8b 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "zod": "^3.8.2" }, "devDependencies": { + "@microsoft/microsoft-graph-types-beta": "0.15.0-preview", "@trivago/prettier-plugin-sort-imports": "2.0.4", "@types/accept-language-parser": "1.5.2", "@types/async": "^3.2.7", diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index 8a2663b967..05b1a13589 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -1,4 +1,5 @@ // import { getBusyVideoTimes } from "@lib/videoClient"; +import { Prisma } from "@prisma/client"; import dayjs from "dayjs"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -6,8 +7,6 @@ import { asStringOrNull } from "@lib/asStringOrNull"; import { getBusyCalendarTimes } from "@lib/calendarClient"; import prisma from "@lib/prisma"; -import { Prisma } from ".prisma/client"; - export default async function handler(req: NextApiRequest, res: NextApiResponse) { const user = asStringOrNull(req.query.user); const dateFrom = dayjs(asStringOrNull(req.query.dateFrom)); diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 0eda3b247c..6c0eea15ec 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -7,6 +7,7 @@ import { CalendarEvent } from "@lib/calendarClient"; import EventRejectionMail from "@lib/emails/EventRejectionMail"; import EventManager from "@lib/events/EventManager"; import prisma from "@lib/prisma"; +import { BookingConfirmBody } from "@lib/types/booking"; import { getTranslation } from "@server/lib/i18n"; @@ -18,7 +19,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Not authenticated" }); } - const bookingId = req.body.id; + const reqBody = req.body as BookingConfirmBody; + const bookingId = reqBody.id; + if (!bookingId) { return res.status(400).json({ message: "bookingId missing" }); } @@ -60,7 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - if (!booking || booking.userId != currentUser.id) { + if (!booking || booking.userId !== currentUser.id) { return res.status(404).json({ message: "booking not found" }); } if (booking.confirmed) { @@ -75,12 +78,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) endTime: booking.endTime.toISOString(), organizer: { email: currentUser.email, name: currentUser.name!, timeZone: currentUser.timeZone }, attendees: booking.attendees, - location: booking.location, + location: booking.location ?? "", uid: booking.uid, language: t, }; - if (req.body.confirmed) { + if (reqBody.confirmed) { const eventManager = new EventManager(currentUser.credentials); const scheduleResult = await eventManager.create(evt); diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 2e8a9a53f3..893b689daf 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -15,7 +15,7 @@ import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient"; import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; import { getErrorFromUnknown } from "@lib/errors"; import { getEventName } from "@lib/event"; -import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager"; +import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager"; import logger from "@lib/logger"; import prisma from "@lib/prisma"; import { BookingCreateBody } from "@lib/types/booking"; @@ -329,6 +329,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let booking: Booking | null = null; try { booking = await createBooking(); + evt.uid = booking.uid; } catch (_err) { const err = getErrorFromUnknown(_err); log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message); @@ -431,7 +432,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (rescheduleUid) { // Use EventManager to conditionally use all needed integrations. const eventManagerCalendarEvent = { ...evt, uid: rescheduleUid }; - const updateResults: CreateUpdateResult = await eventManager.update(eventManagerCalendarEvent); + const updateResults = await eventManager.update(eventManagerCalendarEvent); results = updateResults.results; referencesToCreate = updateResults.referencesToCreate; @@ -444,9 +445,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) log.error(`Booking ${user.name} failed`, error, results); } + // If it's not a reschedule, doesn't require confirmation and there's no price, + // Create a booking } else if (!eventType.requiresConfirmation && !eventType.price) { // Use EventManager to conditionally use all needed integrations. - const createResults: CreateUpdateResult = await eventManager.create(evt); + const createResults = await eventManager.create(evt); results = createResults.results; referencesToCreate = createResults.referencesToCreate; @@ -461,46 +464,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - //for Daily.co video calls will grab the meeting token for the call - const isDaily = evt.location === "integrations:daily"; - - let dailyEvent: DailyReturnType; - - if (!rescheduleUid) { - dailyEvent = results.filter((ref) => ref.type === "daily")[0]?.createdEvent as DailyReturnType; - } else { - dailyEvent = results.filter((ref) => ref.type === "daily_video")[0]?.updatedEvent as DailyReturnType; - } - - let meetingToken; - if (isDaily) { - const response = await fetch("https://api.daily.co/v1/meeting-tokens", { - method: "POST", - body: JSON.stringify({ properties: { room_name: dailyEvent.name, is_owner: true } }), - headers: { - Authorization: "Bearer " + process.env.DAILY_API_KEY, - "Content-Type": "application/json", - }, - }); - meetingToken = await response.json(); - } - - //for Daily.co video calls will update the dailyEventReference table - - if (isDaily) { - await prisma.dailyEventReference.create({ - data: { - dailyurl: dailyEvent.url, - dailytoken: meetingToken.token, - booking: { - connect: { - uid: booking.uid, - }, - }, - }, - }); - } - if (eventType.requiresConfirmation && !rescheduleUid) { await new EventOrganizerRequestMail({ ...evt, uid }).sendEmail(); } diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 4cb2020cda..7c889d3f97 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -1,19 +1,21 @@ import { BookingStatus } from "@prisma/client"; import async from "async"; +import { NextApiRequest, NextApiResponse } from "next"; import { refund } from "@ee/lib/stripe/server"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { CalendarEvent, deleteEvent } from "@lib/calendarClient"; +import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; import prisma from "@lib/prisma"; import { deleteMeeting } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; -import { dailyDeleteMeeting } from "../../lib/dailyVideoClient"; +import { getTranslation } from "@server/lib/i18n"; -export default async function handler(req, res) { +export default async function handler(req: NextApiRequest, res: NextApiResponse) { // just bail if it not a DELETE if (req.method !== "DELETE" && req.method !== "POST") { return res.status(405).end(); @@ -48,7 +50,6 @@ export default async function handler(req, res) { }, payment: true, paid: true, - location: true, title: true, description: true, startTime: true, @@ -62,32 +63,45 @@ export default async function handler(req, res) { return res.status(404).end(); } - if ((!session || session.user?.id != bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) { + if ((!session || session.user?.id !== bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) { return res.status(403).json({ message: "Cannot cancel past events" }); } + if (!bookingToDelete.userId) { + return res.status(404).json({ message: "User not found" }); + } + const organizer = await prisma.user.findFirst({ where: { - id: bookingToDelete.userId as number, + id: bookingToDelete.userId, }, select: { name: true, email: true, timeZone: true, }, + rejectOnNotFound: true, }); + const t = await getTranslation(req.body.language ?? "en", "common"); + const evt: CalendarEvent = { type: bookingToDelete?.title, title: bookingToDelete?.title, description: bookingToDelete?.description || "", startTime: bookingToDelete?.startTime.toString(), endTime: bookingToDelete?.endTime.toString(), - organizer: organizer, + organizer: { + email: organizer.email, + name: organizer.name ?? "Nameless", + timeZone: organizer.timeZone, + }, attendees: bookingToDelete?.attendees.map((attendee) => { const retObj = { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone }; return retObj; }), + uid: bookingToDelete?.uid, + language: t, }; // Hook up the webhook logic here @@ -112,6 +126,10 @@ export default async function handler(req, res) { }, }); + if (bookingToDelete.location === "integrations:daily") { + bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL); + } + const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid; if (bookingRefUid) { @@ -121,13 +139,6 @@ export default async function handler(req, res) { return await deleteMeeting(credential, bookingRefUid); } } - //deleting a Daily meeting - - const isDaily = bookingToDelete.location === "integrations:daily"; - const bookingUID = bookingToDelete.references.filter((ref) => ref.type === "daily")[0]?.uid; - if (isDaily) { - return await dailyDeleteMeeting(credential, bookingUID); - } }); if (bookingToDelete && bookingToDelete.paid) { @@ -144,6 +155,8 @@ export default async function handler(req, res) { }, attendees: bookingToDelete.attendees, location: bookingToDelete.location ?? "", + uid: bookingToDelete.uid ?? "", + language: t, }; await refund(bookingToDelete, evt); await prisma.booking.update({ diff --git a/pages/api/cron/bookingReminder.ts b/pages/api/cron/bookingReminder.ts index 3b5bab4141..c9eb0d17ae 100644 --- a/pages/api/cron/bookingReminder.ts +++ b/pages/api/cron/bookingReminder.ts @@ -6,6 +6,8 @@ import { CalendarEvent } from "@lib/calendarClient"; import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail"; import prisma from "@lib/prisma"; +import { getTranslation } from "@server/lib/i18n"; + export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { const apiKey = req.headers.authorization || req.query.apiKey; if (process.env.CRON_API_KEY !== apiKey) { @@ -31,6 +33,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) select: { title: true, description: true, + location: true, startTime: true, endTime: true, attendees: true, @@ -59,10 +62,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user }); continue; } + const t = await getTranslation(req.body.language ?? "en", "common"); const evt: CalendarEvent = { type: booking.title, title: booking.title, description: booking.description || undefined, + location: booking.location ?? "", startTime: booking.startTime.toISOString(), endTime: booking.endTime.toISOString(), organizer: { @@ -71,9 +76,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) timeZone: user.timeZone, }, attendees: booking.attendees, + uid: booking.uid, + language: t, }; - await new EventOrganizerRequestReminderMail(evt, booking.uid).sendEmail(); + await new EventOrganizerRequestReminderMail(evt).sendEmail(); await prisma.reminderMail.create({ data: { referenceId: booking.id, diff --git a/yarn.lock b/yarn.lock index 663ee66955..c404d5d745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1113,6 +1113,11 @@ resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== +"@microsoft/microsoft-graph-types-beta@0.15.0-preview": + version "0.15.0-preview" + resolved "https://registry.yarnpkg.com/@microsoft/microsoft-graph-types-beta/-/microsoft-graph-types-beta-0.15.0-preview.tgz#fed0a99be4e1151d566cf063f024913fb48640cd" + integrity sha512-M0zC4t3pmkDz7Qsjx/iZcS+zRuckzsbHESvT9qjLFv64RUgkRmDdmhcvPMiUqUzw/h3YxfYAq9MU+XWjROk/dg== + "@napi-rs/triples@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.0.3.tgz#76d6d0c3f4d16013c61e45dfca5ff1e6c31ae53c"