From daecc1e0e40a3086f3b693c12d65ebfe7c0b70a0 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 15 Jul 2021 03:19:30 +0200 Subject: [PATCH 01/75] Created EventManager in order to unify event CRUD logic --- lib/calendarClient.ts | 38 ++++- lib/events/EventManager.ts | 124 +++++++++++++++++ lib/videoClient.ts | 274 ++++++++++++++++++++++--------------- pages/api/book/[user].ts | 91 ++++-------- 4 files changed, 347 insertions(+), 180 deletions(-) create mode 100644 lib/events/EventManager.ts diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 3891feab..41720971 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -5,6 +5,10 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail" import prisma from "./prisma"; import { Credential } from "@prisma/client"; import CalEventParser from "./CalEventParser"; +import { EventResult } from "@lib/events/EventManager"; +import logger from "@lib/logger"; + +const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); // eslint-disable-next-line @typescript-eslint/no-var-requires const { google } = require("googleapis"); @@ -494,9 +498,7 @@ const calendars = (withCredentials): CalendarApiAdapter[] => .filter(Boolean); const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => - Promise.all( - calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) - ).then((results) => { + Promise.all(calendars(withCredentials).map((c) => c.getAvailability(selectedCalendars))).then((results) => { return results.reduce((acc, availability) => acc.concat(availability), []); }); @@ -505,12 +507,21 @@ const listCalendars = (withCredentials) => results.reduce((acc, calendars) => acc.concat(calendars), []) ); -const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { +const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); const richEvent: CalendarEvent = parser.asRichEvent(); - const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null; + let success = true; + + const creationResult = credential + ? await calendars([credential])[0] + .createEvent(richEvent) + .catch((e) => { + log.error("createEvent failed", e, calEvent); + success = false; + }) + : null; const maybeHangoutLink = creationResult?.hangoutLink; const maybeEntryPoints = creationResult?.entryPoints; @@ -543,8 +554,11 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro } return { + type: credential.type, + success, uid, createdEvent: creationResult, + originalEvent: calEvent, }; }; @@ -552,13 +566,20 @@ const updateEvent = async ( credential: Credential, uidToUpdate: string, calEvent: CalendarEvent -): Promise => { +): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); const newUid: string = parser.getUid(); const richEvent: CalendarEvent = parser.asRichEvent(); + let success = true; + const updateResult = credential - ? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent) + ? await calendars([credential])[0] + .updateEvent(uidToUpdate, richEvent) + .catch((e) => { + log.error("updateEvent failed", e, calEvent); + success = false; + }) : null; const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); @@ -578,8 +599,11 @@ const updateEvent = async ( } return { + type: credential.type, + success, uid: newUid, updatedEvent: updateResult, + originalEvent: calEvent, }; }; diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts new file mode 100644 index 00000000..69e184df --- /dev/null +++ b/lib/events/EventManager.ts @@ -0,0 +1,124 @@ +import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient"; +import { Credential } from "@prisma/client"; +import async from "async"; +import { createMeeting, updateMeeting } from "@lib/videoClient"; + +export interface EventResult { + type: string; + success: boolean; + uid: string; + createdEvent?: unknown; + updatedEvent?: unknown; + originalEvent: CalendarEvent; +} + +export interface PartialBooking { + id: number; + references: Array; +} + +export interface PartialReference { + id: number; + type: string; + uid: string; +} + +export default class EventManager { + calendarCredentials: Array; + videoCredentials: Array; + + constructor(credentials: Array) { + this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar")); + this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video")); + } + + public async create(event: CalendarEvent): Promise> { + const results: Array = []; + // First, create all calendar events. + results.concat(await this.createAllCalendarEvents(event)); + + // If and only if event type is a video meeting, create a video meeting as well. + if (EventManager.isIntegration(event.location)) { + results.push(await this.createVideoEvent(event)); + } + + return results; + } + + public async update(event: CalendarEvent, booking: PartialBooking): Promise> { + const results: Array = []; + // First, update all calendar events. + results.concat(await this.updateAllCalendarEvents(event, booking)); + + // If and only if event type is a video meeting, update the video meeting as well. + if (EventManager.isIntegration(event.location)) { + results.push(await this.updateVideoEvent(event, booking)); + } + + return results; + } + + /** + * Creates event entries for all calendar integrations given in the credentials. + * + * @param event + * @private + */ + private createAllCalendarEvents(event: CalendarEvent): Promise> { + return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { + return createEvent(credential, event); + }); + } + + private getVideoCredential(event: CalendarEvent): Credential | undefined { + const integrationName = event.location.replace("integrations:", ""); + return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName)); + } + + /** + * Creates a video event entry for the selected integration location. + * + * @param event + * @private + */ + private createVideoEvent(event: CalendarEvent): Promise { + const credential = this.getVideoCredential(event); + + if (credential) { + return createMeeting(credential, event); + } else { + return Promise.reject("No suitable credentials given for the requested integration name."); + } + } + + private updateAllCalendarEvents( + event: CalendarEvent, + booking: PartialBooking + ): Promise> { + return async.mapLimit(this.calendarCredentials, 5, async (credential) => { + const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; + return updateEvent(credential, bookingRefUid, event); + }); + } + + private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) { + const credential = this.getVideoCredential(event); + + if (credential) { + const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; + return updateMeeting(credential, bookingRefUid, event); + } else { + return Promise.reject("No suitable credentials given for the requested integration name."); + } + } + + /** + * Returns true if the given location describes an integration that delivers meeting credentials. + * + * @param location + * @private + */ + private static isIntegration(location: string): boolean { + return location.includes("integrations:"); + } +} diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 0e171ac6..ec1b48ce 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -1,11 +1,15 @@ import prisma from "./prisma"; -import {CalendarEvent} from "./calendarClient"; +import { CalendarEvent } from "./calendarClient"; import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; -import {v5 as uuidv5} from 'uuid'; -import short from 'short-uuid'; +import { v5 as uuidv5 } from "uuid"; +import short from "short-uuid"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; +import { EventResult } from "@lib/events/EventManager"; +import logger from "@lib/logger"; + +const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] }); const translator = short(); @@ -33,63 +37,67 @@ function handleErrorsRaw(response) { } const zoomAuth = (credential) => { + const isExpired = (expiryDate) => expiryDate < +new Date(); + const authHeader = + "Basic " + + Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64"); - const isExpired = (expiryDate) => expiryDate < +(new Date()); - const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64'); - - const refreshAccessToken = (refreshToken) => 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', + const refreshAccessToken = (refreshToken) => + 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 - } + .then(handleErrorsJson) + .then(async (responseBody) => { + // Store new tokens in database. + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: responseBody, + }, + }); + credential.key.access_token = responseBody.access_token; + credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in); + return credential.key.access_token; }); - credential.key.access_token = responseBody.access_token; - credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in); - return credential.key.access_token; - }) return { - getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) + getToken: () => + !isExpired(credential.key.expires_in) + ? Promise.resolve(credential.key.access_token) + : refreshAccessToken(credential.key.refresh_token), }; }; interface VideoApiAdapter { createMeeting(event: CalendarEvent): Promise; - updateMeeting(uid: String, event: CalendarEvent); + updateMeeting(uid: string, event: CalendarEvent); - deleteMeeting(uid: String); + deleteMeeting(uid: string); getAvailability(dateFrom, dateTo): Promise; } const ZoomVideo = (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 + 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, + 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? @@ -97,8 +105,8 @@ const ZoomVideo = (credential): VideoApiAdapter => { 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 + 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, @@ -107,82 +115,107 @@ const ZoomVideo = (credential): VideoApiAdapter => { audio: "both", auto_recording: "none", enforce_login: false, - registrants_email_notification: true - } + registrants_email_notification: true, + }, }; }; return { - getAvailability: (dateFrom, dateTo) => { - 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.log(err); - }); + 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.log(err); + }); }, - 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)), - } + 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): VideoApiAdapter[] => withCredentials.map((cred) => { - switch (cred.type) { - case 'zoom_video': - return ZoomVideo(cred); - default: - return; // unknown credential, could be legacy? In any case, ignore - } -}).filter(Boolean); +const videoIntegrations = (withCredentials): VideoApiAdapter[] => + withCredentials + .map((cred) => { + switch (cred.type) { + case "zoom_video": + return ZoomVideo(cred); + default: + return; // unknown credential, could be legacy? In any case, ignore + } + }) + .filter(Boolean); +const getBusyVideoTimes = (withCredentials) => + Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) => + results.reduce((acc, availability) => acc.concat(availability), []) + ); -const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all( - videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) -).then( - (results) => results.reduce((acc, availability) => acc.concat(availability), []) -); - -const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { +const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { const uid: 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."); + 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 creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent); + let success = true; + + const creationResult = await videoIntegrations([credential])[0] + .createMeeting(calEvent) + .catch((e) => { + log.error("createMeeting failed", e, calEvent); + success = false; + }); const videoCallData: VideoCallData = { type: credential.type, @@ -196,55 +229,76 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise try { await organizerMail.sendEmail(); } catch (e) { - console.error("organizerMail.sendEmail failed", e) + console.error("organizerMail.sendEmail failed", e); } if (!creationResult || !creationResult.disableConfirmationEmail) { try { await attendeeMail.sendEmail(); } catch (e) { - console.error("attendeeMail.sendEmail failed", e) + console.error("attendeeMail.sendEmail failed", e); } } return { + type: credential.type, + success, uid, - createdEvent: creationResult + createdEvent: creationResult, + originalEvent: calEvent, }; }; -const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise => { +const updateMeeting = async ( + credential, + uidToUpdate: string, + 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."); + 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 updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null; + let success = true; + + const updateResult = credential + ? await videoIntegrations([credential])[0] + .updateMeeting(uidToUpdate, calEvent) + .catch((e) => { + log.error("updateMeeting failed", e, calEvent); + success = false; + }) + : null; const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); try { await organizerMail.sendEmail(); } catch (e) { - console.error("organizerMail.sendEmail failed", e) + console.error("organizerMail.sendEmail failed", e); } if (!updateResult || !updateResult.disableConfirmationEmail) { try { await attendeeMail.sendEmail(); } catch (e) { - console.error("attendeeMail.sendEmail failed", e) + console.error("attendeeMail.sendEmail failed", e); } } return { + type: credential.type, + success, uid: newUid, - updatedEvent: updateResult + updatedEvent: updateResult, + originalEvent: calEvent, }; }; -const deleteMeeting = (credential, uid: String): Promise => { +const deleteMeeting = (credential, uid: string): Promise => { if (credential) { return videoIntegrations([credential])[0].deleteMeeting(uid); } @@ -252,4 +306,4 @@ const deleteMeeting = (credential, uid: String): Promise => { return Promise.resolve({}); }; -export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting}; +export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting }; diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 89eb8ca8..44c1c505 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,16 +1,17 @@ import type { NextApiRequest, NextApiResponse } from "next"; import prisma from "../../../lib/prisma"; -import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient"; -import async from "async"; +import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient"; import { v5 as uuidv5 } from "uuid"; import short from "short-uuid"; -import { createMeeting, getBusyVideoTimes, updateMeeting } from "../../../lib/videoClient"; +import { getBusyVideoTimes } from "@lib/videoClient"; import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail"; -import { getEventName } from "../../../lib/event"; -import { LocationType } from "../../../lib/location"; +import { getEventName } from "@lib/event"; +import { LocationType } from "@lib/location"; import merge from "lodash.merge"; import dayjs from "dayjs"; import logger from "../../../lib/logger"; +import EventManager, { EventResult } from "@lib/events/EventManager"; +import { User } from "@prisma/client"; const translator = short(); const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); @@ -63,6 +64,18 @@ const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromI requestId: requestId, }, }, + location, + }; + } else if (location === LocationType.Zoom.valueOf()) { + const requestId = uuidv5(location, uuidv5.URL); + + return { + conferenceData: { + createRequest: { + requestId: requestId, + }, + }, + location, }; } @@ -88,7 +101,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json(error); } - let currentUser = await prisma.user.findFirst({ + let currentUser: User = await prisma.user.findFirst({ where: { username: user, }, @@ -107,10 +120,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - // Split credentials up into calendar credentials and video credentials - let calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")); - let videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")); - const hasCalendarIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0; const hasVideoIntegrations = @@ -152,9 +161,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) name: true, }, }); - calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")); - videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")); + // Initialize EventManager with credentials + const eventManager = new EventManager(currentUser.credentials); const rescheduleUid = req.body.rescheduleUid; const selectedEventType = await prisma.eventType.findFirst({ @@ -228,7 +237,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json(error); } - let results = []; + let results: Array = []; let referencesToCreate = []; if (rescheduleUid) { @@ -249,30 +258,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - // Use all integrations - results = results.concat( - await async.mapLimit(calendarCredentials, 5, async (credential) => { - const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return updateEvent(credential, bookingRefUid, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("updateEvent failed", e, evt); - return { type: credential.type, success: false }; - }); - }) - ); - - results = results.concat( - await async.mapLimit(videoCredentials, 5, async (credential) => { - const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return updateMeeting(credential, bookingRefUid, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("updateMeeting failed", e, evt); - return { type: credential.type, success: false }; - }); - }) - ); + // Use EventManager to conditionally use all needed integrations. + results = await eventManager.update(evt, booking); if (results.length > 0 && results.every((res) => !res.success)) { const error = { @@ -306,28 +293,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); } else { - // Schedule event - results = results.concat( - await async.mapLimit(calendarCredentials, 5, async (credential) => { - return createEvent(credential, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("createEvent failed", e, evt); - return { type: credential.type, success: false }; - }); - }) - ); - - results = results.concat( - await async.mapLimit(videoCredentials, 5, async (credential) => { - return createMeeting(credential, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("createMeeting failed", e, evt); - return { type: credential.type, success: false }; - }); - }) - ); + // Use EventManager to conditionally use all needed integrations. + const results: Array = await eventManager.create(evt); if (results.length > 0 && results.every((res) => !res.success)) { const error = { @@ -342,15 +309,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) referencesToCreate = results.map((result) => { return { type: result.type, - uid: result.response.createdEvent.id.toString(), + uid: result.createdEvent.id.toString(), }; }); } const hashUID = - results.length > 0 - ? results[0].response.uid - : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); + results.length > 0 ? results[0].uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); // TODO Should just be set to the true case as soon as we have a "bare email" integration class. // UID generation should happen in the integration itself, not here. if (results.length === 0) { From b146b807785be684ed72d5023250f884ac418ec5 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 15 Jul 2021 23:34:55 +0200 Subject: [PATCH 02/75] Fixed signature --- lib/calendarClient.ts | 4 +++- pages/api/cancel.ts | 45 +++++++++++++++++++------------------------ 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 41720971..f9c4dfd5 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -498,7 +498,9 @@ const calendars = (withCredentials): CalendarApiAdapter[] => .filter(Boolean); const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => - Promise.all(calendars(withCredentials).map((c) => c.getAvailability(selectedCalendars))).then((results) => { + Promise.all( + calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) + ).then((results) => { return results.reduce((acc, availability) => acc.concat(availability), []); }); diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 90ef2f43..e3c96f98 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -1,7 +1,7 @@ -import prisma from '../../lib/prisma'; -import {deleteEvent} from "../../lib/calendarClient"; -import async from 'async'; -import {deleteMeeting} from "../../lib/videoClient"; +import prisma from "../../lib/prisma"; +import { deleteEvent } from "../../lib/calendarClient"; +import async from "async"; +import { deleteMeeting } from "../../lib/videoClient"; export default async function handler(req, res) { if (req.method == "POST") { @@ -15,36 +15,36 @@ export default async function handler(req, res) { id: true, user: { select: { - credentials: true - } + credentials: true, + }, }, attendees: true, references: { select: { uid: true, - type: true - } - } - } + type: true, + }, + }, + }, }); const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid; - if(credential.type.endsWith("_calendar")) { + if (credential.type.endsWith("_calendar")) { return await deleteEvent(credential, bookingRefUid); - } else if(credential.type.endsWith("_video")) { + } else if (credential.type.endsWith("_video")) { return await deleteMeeting(credential, bookingRefUid); } }); const attendeeDeletes = prisma.attendee.deleteMany({ where: { - bookingId: bookingToDelete.id - } + bookingId: bookingToDelete.id, + }, }); const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ where: { - bookingId: bookingToDelete.id - } + bookingId: bookingToDelete.id, + }, }); const bookingDeletes = prisma.booking.delete({ where: { @@ -52,17 +52,12 @@ export default async function handler(req, res) { }, }); - await Promise.all([ - apiDeletes, - attendeeDeletes, - bookingReferenceDeletes, - bookingDeletes - ]); + await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes, bookingDeletes]); //TODO Perhaps send emails to user and client to tell about the cancellation - res.status(200).json({message: 'Booking successfully deleted.'}); + res.status(200).json({ message: "Booking successfully deleted." }); } else { - res.status(405).json({message: 'This endpoint only accepts POST requests.'}); + res.status(405).json({ message: "This endpoint only accepts POST requests." }); } -} \ No newline at end of file +} From 81e1287693da9767fbe5b6b3b1f1dd7f82bab829 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 18 Jul 2021 16:03:59 +0200 Subject: [PATCH 03/75] Fixed cancellation --- lib/calendarClient.ts | 24 +++++++++++++++++++++++- lib/events/EventManager.ts | 3 +-- pages/api/cancel.ts | 12 +++++++----- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 0b3f9102..f9c4dfd5 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -157,7 +157,29 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { optional.location = { displayName: event.location }; } - return toRet; + return { + subject: event.title, + body: { + contentType: "HTML", + content: event.description, + }, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees.map((attendee) => ({ + emailAddress: { + address: attendee.email, + name: attendee.name, + }, + type: "required", + })), + ...optional, + }; }; const integrationType = "office365_calendar"; diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 69e184df..05adebaa 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -33,9 +33,8 @@ export default class EventManager { } public async create(event: CalendarEvent): Promise> { - const results: Array = []; // First, create all calendar events. - results.concat(await this.createAllCalendarEvents(event)); + const results: Array = await this.createAllCalendarEvents(event); // If and only if event type is a video meeting, create a video meeting as well. if (EventManager.isIntegration(event.location)) { diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index e3c96f98..3610f1da 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -29,11 +29,13 @@ export default async function handler(req, res) { }); const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { - const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid; - if (credential.type.endsWith("_calendar")) { - return await deleteEvent(credential, bookingRefUid); - } else if (credential.type.endsWith("_video")) { - return await deleteMeeting(credential, bookingRefUid); + const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid; + if (bookingRefUid) { + if (credential.type.endsWith("_calendar")) { + return await deleteEvent(credential, bookingRefUid); + } else if (credential.type.endsWith("_video")) { + return await deleteMeeting(credential, bookingRefUid); + } } }); const attendeeDeletes = prisma.attendee.deleteMany({ From a40a5c04fe10709c9106c0b955d282b1b211740c Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 18 Jul 2021 22:17:18 +0200 Subject: [PATCH 04/75] Retain rescheduleUid when picking a date --- lib/events/EventManager.ts | 5 ++--- pages/[user]/[type].tsx | 14 +++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 05adebaa..5fbc58d4 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -45,9 +45,8 @@ export default class EventManager { } public async update(event: CalendarEvent, booking: PartialBooking): Promise> { - const results: Array = []; // First, update all calendar events. - results.concat(await this.updateAllCalendarEvents(event, booking)); + const results: Array = await this.updateAllCalendarEvents(event, booking); // If and only if event type is a video meeting, update the video meeting as well. if (EventManager.isIntegration(event.location)) { @@ -95,7 +94,7 @@ export default class EventManager { booking: PartialBooking ): Promise> { return async.mapLimit(this.calendarCredentials, 5, async (credential) => { - const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; + const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid; return updateEvent(credential, bookingRefUid, event); }); } diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 04cdbd76..313ee133 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -48,9 +48,17 @@ export default function Type(props): Type { router.replace( { - query: { - date: formattedDate, - }, + query: Object.assign( + {}, + { + date: formattedDate, + }, + rescheduleUid + ? { + rescheduleUid: rescheduleUid, + } + : {} + ), }, undefined, { From 7aff32fb502bbefc7fc86fc2dfcbe05d54d434e8 Mon Sep 17 00:00:00 2001 From: nicolas Date: Tue, 20 Jul 2021 20:07:59 +0200 Subject: [PATCH 05/75] Only send single mail when booking zoom --- lib/calendarClient.ts | 73 +++++++++++++++++++++----------------- lib/events/EventManager.ts | 47 ++++++++++++++++++------ pages/api/book/[user].ts | 2 +- 3 files changed, 79 insertions(+), 43 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index f9c4dfd5..bfe0f472 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -509,7 +509,11 @@ const listCalendars = (withCredentials) => results.reduce((acc, calendars) => acc.concat(calendars), []) ); -const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { +const createEvent = async ( + credential: Credential, + calEvent: CalendarEvent, + noMail = false +): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); const richEvent: CalendarEvent = parser.asRichEvent(); @@ -529,29 +533,31 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro const maybeEntryPoints = creationResult?.entryPoints; const maybeConferenceData = creationResult?.conferenceData; - const organizerMail = new EventOrganizerMail(calEvent, uid, { - hangoutLink: maybeHangoutLink, - conferenceData: maybeConferenceData, - entryPoints: maybeEntryPoints, - }); + if (!noMail) { + const organizerMail = new EventOrganizerMail(calEvent, uid, { + hangoutLink: maybeHangoutLink, + conferenceData: maybeConferenceData, + entryPoints: maybeEntryPoints, + }); - const attendeeMail = new EventAttendeeMail(calEvent, uid, { - hangoutLink: maybeHangoutLink, - conferenceData: maybeConferenceData, - entryPoints: maybeEntryPoints, - }); + const attendeeMail = new EventAttendeeMail(calEvent, uid, { + hangoutLink: maybeHangoutLink, + conferenceData: maybeConferenceData, + entryPoints: maybeEntryPoints, + }); - try { - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - - if (!creationResult || !creationResult.disableConfirmationEmail) { try { - await attendeeMail.sendEmail(); + await organizerMail.sendEmail(); } catch (e) { - console.error("attendeeMail.sendEmail failed", e); + console.error("organizerMail.sendEmail failed", e); + } + + if (!creationResult || !creationResult.disableConfirmationEmail) { + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e); + } } } @@ -567,7 +573,8 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro const updateEvent = async ( credential: Credential, uidToUpdate: string, - calEvent: CalendarEvent + calEvent: CalendarEvent, + noMail: false ): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); const newUid: string = parser.getUid(); @@ -584,19 +591,21 @@ const updateEvent = async ( }) : null; - const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); - const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - try { - await organizerMail.sendEmail(); - } catch (e) { - console.error("organizerMail.sendEmail failed", e); - } - - if (!updateResult || !updateResult.disableConfirmationEmail) { + if (!noMail) { + const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); + const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); try { - await attendeeMail.sendEmail(); + await organizerMail.sendEmail(); } catch (e) { - console.error("attendeeMail.sendEmail failed", e); + console.error("organizerMail.sendEmail failed", e); + } + + if (!updateResult || !updateResult.disableConfirmationEmail) { + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e); + } } } diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 5fbc58d4..6c254c3c 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -33,11 +33,13 @@ export default class EventManager { } public async create(event: CalendarEvent): Promise> { - // First, create all calendar events. - const results: Array = await this.createAllCalendarEvents(event); + const isVideo = EventManager.isIntegration(event.location); + + // First, create all calendar events. If this is a video event, don't send a mail right here. + const results: Array = await this.createAllCalendarEvents(event, isVideo); // If and only if event type is a video meeting, create a video meeting as well. - if (EventManager.isIntegration(event.location)) { + if (isVideo) { results.push(await this.createVideoEvent(event)); } @@ -45,11 +47,13 @@ export default class EventManager { } public async update(event: CalendarEvent, booking: PartialBooking): Promise> { - // First, update all calendar events. - const results: Array = await this.updateAllCalendarEvents(event, booking); + const isVideo = EventManager.isIntegration(event.location); + + // First, update all calendar events. If this is a video event, don't send a mail right here. + const results: Array = await this.updateAllCalendarEvents(event, booking, isVideo); // If and only if event type is a video meeting, update the video meeting as well. - if (EventManager.isIntegration(event.location)) { + if (isVideo) { results.push(await this.updateVideoEvent(event, booking)); } @@ -58,13 +62,17 @@ export default class EventManager { /** * Creates event entries for all calendar integrations given in the credentials. + * When noMail is true, no mails will be sent. This is used when the event is + * a video meeting because then the mail containing the video credentials will be + * more important than the mails created for these bare calendar events. * * @param event + * @param noMail * @private */ - private createAllCalendarEvents(event: CalendarEvent): Promise> { + private createAllCalendarEvents(event: CalendarEvent, noMail: boolean): Promise> { return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { - return createEvent(credential, event); + return createEvent(credential, event, noMail); }); } @@ -89,16 +97,35 @@ export default class EventManager { } } + /** + * Updates the event entries for all calendar integrations given in the credentials. + * When noMail is true, no mails will be sent. This is used when the event is + * a video meeting because then the mail containing the video credentials will be + * more important than the mails created for these bare calendar events. + * + * @param event + * @param booking + * @param noMail + * @private + */ private updateAllCalendarEvents( event: CalendarEvent, - booking: PartialBooking + booking: PartialBooking, + noMail: boolean ): Promise> { return async.mapLimit(this.calendarCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid; - return updateEvent(credential, bookingRefUid, event); + return updateEvent(credential, bookingRefUid, event, noMail); }); } + /** + * Updates a single video event. + * + * @param event + * @param booking + * @private + */ private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) { const credential = this.getVideoCredential(event); diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index fef3fac8..e5863cb5 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -356,7 +356,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); } else { // Use EventManager to conditionally use all needed integrations. - const results: Array = await eventManager.create(evt); + results = await eventManager.create(evt); if (results.length > 0 && results.every((res) => !res.success)) { const error = { From 3a18aa10ddab31ce4e3569b12ee88f2e0017c2b5 Mon Sep 17 00:00:00 2001 From: nicolas Date: Tue, 20 Jul 2021 20:24:05 +0200 Subject: [PATCH 06/75] Fixed linting errors --- next.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/next.config.js b/next.config.js index b35afdce..171dc100 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,5 @@ -import nextTranspileModules from "next-transpile-modules"; -const withTM = nextTranspileModules(["react-timezone-select"]); +/* eslint-disable */ +const withTM = require("next-transpile-modules")(["react-timezone-select"]); // TODO: Revisit this later with getStaticProps in App if (process.env.NEXTAUTH_URL) { From da64dae568db42efdab5bc07cbb5eb17b8e4c6a3 Mon Sep 17 00:00:00 2001 From: nicolas Date: Tue, 20 Jul 2021 20:25:36 +0200 Subject: [PATCH 07/75] Fixed linting errors --- next.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 171dc100..bb567c76 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,4 @@ -/* eslint-disable */ +/* eslint-disable @typescript-eslint/no-var-requires */ const withTM = require("next-transpile-modules")(["react-timezone-select"]); // TODO: Revisit this later with getStaticProps in App From cf52df5662be503afe18b8d1eaa0e9823151298f Mon Sep 17 00:00:00 2001 From: nicolas Date: Tue, 20 Jul 2021 20:40:41 +0200 Subject: [PATCH 08/75] Use entrypoint to make zoom location more beautiful --- lib/emails/EventMail.ts | 5 +++-- lib/emails/VideoEventAttendeeMail.ts | 17 ++++++++++++----- lib/emails/VideoEventOrganizerMail.ts | 9 ++++++++- lib/videoClient.ts | 17 +++++++++++++++-- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index 90cee0b5..1c9253fc 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -4,7 +4,7 @@ import { CalendarEvent, ConferenceData } from "../calendarClient"; import { serverConfig } from "../serverConfig"; import nodemailer from "nodemailer"; -interface EntryPoint { +export interface EntryPoint { entryPointType?: string; uri?: string; label?: string; @@ -15,7 +15,7 @@ interface EntryPoint { password?: string; } -interface AdditionInformation { +export interface AdditionInformation { conferenceData?: ConferenceData; entryPoints?: EntryPoint[]; hangoutLink?: string; @@ -34,6 +34,7 @@ export default abstract class EventMail { * * @param calEvent * @param uid + * @param additionInformation */ constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) { this.calEvent = calEvent; diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts index 7855f36d..2a8aa58e 100644 --- a/lib/emails/VideoEventAttendeeMail.ts +++ b/lib/emails/VideoEventAttendeeMail.ts @@ -1,14 +1,21 @@ -import {CalendarEvent} from "../calendarClient"; +import { CalendarEvent } from "../calendarClient"; import EventAttendeeMail from "./EventAttendeeMail"; -import {getFormattedMeetingId, getIntegrationName} from "./helpers"; -import {VideoCallData} from "../videoClient"; +import { getFormattedMeetingId, getIntegrationName } from "./helpers"; +import { VideoCallData } from "../videoClient"; +import { AdditionInformation } from "@lib/emails/EventMail"; export default class VideoEventAttendeeMail extends EventAttendeeMail { videoCallData: VideoCallData; - constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { + constructor( + calEvent: CalendarEvent, + uid: string, + videoCallData: VideoCallData, + additionInformation: AdditionInformation = null + ) { super(calEvent, uid); this.videoCallData = videoCallData; + this.additionInformation = additionInformation; } /** @@ -24,4 +31,4 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail { Meeting URL: ${this.videoCallData.url}
`; } -} \ No newline at end of file +} diff --git a/lib/emails/VideoEventOrganizerMail.ts b/lib/emails/VideoEventOrganizerMail.ts index 1f5b2384..cfb11e05 100644 --- a/lib/emails/VideoEventOrganizerMail.ts +++ b/lib/emails/VideoEventOrganizerMail.ts @@ -2,13 +2,20 @@ import { CalendarEvent } from "../calendarClient"; import EventOrganizerMail from "./EventOrganizerMail"; import { VideoCallData } from "../videoClient"; import { getFormattedMeetingId, getIntegrationName } from "./helpers"; +import { AdditionInformation } from "@lib/emails/EventMail"; export default class VideoEventOrganizerMail extends EventOrganizerMail { videoCallData: VideoCallData; - constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { + constructor( + calEvent: CalendarEvent, + uid: string, + videoCallData: VideoCallData, + additionInformation: AdditionInformation = null + ) { super(calEvent, uid); this.videoCallData = videoCallData; + this.additionInformation = additionInformation; } /** diff --git a/lib/videoClient.ts b/lib/videoClient.ts index ec1b48ce..faea7d0b 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -8,6 +8,8 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail" import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; +import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail"; +import { getIntegrationName } from "@lib/emails/helpers"; const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] }); @@ -224,8 +226,19 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise Date: Sat, 24 Jul 2021 22:30:14 +0200 Subject: [PATCH 09/75] Added JSDoc --- lib/events/EventManager.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 50087a22..28a7876a 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -33,11 +33,23 @@ export default class EventManager { calendarCredentials: Array; videoCredentials: Array; + /** + * Takes an array of credentials and initializes a new instance of the EventManager. + * + * @param credentials + */ constructor(credentials: Array) { this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar")); this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video")); } + /** + * Takes a CalendarEvent and creates all necessary integration entries for it. + * When a video integration is chosen as the event's location, a video integration + * event will be scheduled for it as well. + * + * @param event + */ public async create(event: CalendarEvent): Promise { const isVideo = EventManager.isIntegration(event.location); @@ -62,6 +74,13 @@ export default class EventManager { }; } + /** + * Takes a calendarEvent and a rescheduleUid and updates the event that has the + * given uid using the data delivered in the given CalendarEvent. + * + * @param event + * @param rescheduleUid + */ public async update(event: CalendarEvent, rescheduleUid: string): Promise { // Get details of existing booking. const booking = await prisma.booking.findFirst({ @@ -132,6 +151,12 @@ export default class EventManager { }); } + /** + * Checks which video integration is needed for the event's location and returns + * credentials for that - if existing. + * @param event + * @private + */ private getVideoCredential(event: CalendarEvent): Credential | undefined { const integrationName = event.location.replace("integrations:", ""); return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName)); From a97862d4b84dc18f03bea6120e0cc5a16921572f Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 14:19:49 +0200 Subject: [PATCH 10/75] Process event location in EventManager --- lib/events/EventManager.ts | 58 ++++++++++++++++++++++++++++++++++++++ pages/api/book/[user].ts | 56 ++---------------------------------- 2 files changed, 60 insertions(+), 54 deletions(-) diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 28a7876a..050f870d 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -3,6 +3,9 @@ import { Credential } from "@prisma/client"; import async from "async"; import { createMeeting, updateMeeting } from "@lib/videoClient"; import prisma from "@lib/prisma"; +import { LocationType } from "@lib/location"; +import { v5 as uuidv5 } from "uuid"; +import merge from "lodash.merge"; export interface EventResult { type: string; @@ -29,6 +32,10 @@ export interface PartialReference { uid: string; } +interface GetLocationRequestFromIntegrationRequest { + location: string; +} + export default class EventManager { calendarCredentials: Array; videoCredentials: Array; @@ -51,6 +58,7 @@ export default class EventManager { * @param event */ public async create(event: CalendarEvent): Promise { + event = EventManager.processLocation(event); const isVideo = EventManager.isIntegration(event.location); // First, create all calendar events. If this is a video event, don't send a mail right here. @@ -82,6 +90,8 @@ export default class EventManager { * @param rescheduleUid */ public async update(event: CalendarEvent, rescheduleUid: string): Promise { + event = EventManager.processLocation(event); + // Get details of existing booking. const booking = await prisma.booking.findFirst({ where: { @@ -227,4 +237,52 @@ export default class EventManager { private static isIntegration(location: string): boolean { return location.includes("integrations:"); } + + /** + * Helper function for processLocation: Returns the conferenceData object to be merged + * with the CalendarEvent. + * + * @param locationObj + * @private + */ + private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) { + const location = locationObj.location; + + if (location === LocationType.GoogleMeet.valueOf() || location === LocationType.Zoom.valueOf()) { + const requestId = uuidv5(location, uuidv5.URL); + + return { + conferenceData: { + createRequest: { + requestId: requestId, + }, + }, + location, + }; + } + + return null; + } + + /** + * Takes a CalendarEvent and adds a ConferenceData object to the event + * if the event has an integration-related location. + * + * @param event + * @private + */ + private static processLocation(event: CalendarEvent): CalendarEvent { + // If location is set to an integration location + // Build proper transforms for evt object + // Extend evt object with those transformations + if (event.location?.includes("integration")) { + const maybeLocationRequestObject = EventManager.getLocationRequestFromIntegration({ + location: event.location, + }); + + event = merge(event, maybeLocationRequestObject); + } + + return event; + } } diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index f086bf3f..df756da3 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -6,8 +6,6 @@ import short from "short-uuid"; import { getBusyVideoTimes } from "@lib/videoClient"; import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail"; import { getEventName } from "@lib/event"; -import { LocationType } from "@lib/location"; -import merge from "lodash.merge"; import dayjs from "dayjs"; import logger from "../../../lib/logger"; import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager"; @@ -86,38 +84,6 @@ function isOutOfBounds( } } -interface GetLocationRequestFromIntegrationRequest { - location: string; -} - -const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => { - if (location === LocationType.GoogleMeet.valueOf()) { - const requestId = uuidv5(location, uuidv5.URL); - - return { - conferenceData: { - createRequest: { - requestId: requestId, - }, - }, - location, - }; - } else if (location === LocationType.Zoom.valueOf()) { - const requestId = uuidv5(location, uuidv5.URL); - - return { - conferenceData: { - createRequest: { - requestId: requestId, - }, - }, - location, - }; - } - - return null; -}; - export async function handleLegacyConfirmationMail( results: Array, selectedEventType: { requiresConfirmation: boolean }, @@ -235,9 +201,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - const rawLocation = req.body.location; - - let evt: CalendarEvent = { + const evt: CalendarEvent = { type: selectedEventType.title, title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName), description: req.body.notes, @@ -245,25 +209,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) endTime: req.body.end, organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, attendees: [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }], + location: req.body.location, // Will be processed by the EventManager later. }; - // If phone or inPerson use raw location - // set evt.location to req.body.location - if (!rawLocation?.includes("integration")) { - evt.location = rawLocation; - } - - // If location is set to an integration location - // Build proper transforms for evt object - // Extend evt object with those transformations - if (rawLocation?.includes("integration")) { - const maybeLocationRequestObject = getLocationRequestFromIntegration({ - location: rawLocation, - }); - - evt = merge(evt, maybeLocationRequestObject); - } - const eventType = await prisma.eventType.findFirst({ where: { userId: currentUser.id, From df161d549822b691ef0f5912efae064ff9959afe Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 14:37:22 +0200 Subject: [PATCH 11/75] Added location to bookings table --- pages/api/book/[user].ts | 1 + pages/api/book/confirm.ts | 1 + .../20210725123357_add_location_to_booking/migration.sql | 2 ++ prisma/schema.prisma | 1 + 4 files changed, 5 insertions(+) create mode 100644 prisma/migrations/20210725123357_add_location_to_booking/migration.sql diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index df756da3..a7f10b15 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -336,6 +336,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) attendees: { create: evt.attendees, }, + location: evt.location, // This is the raw location that can be processed by the EventManager. confirmed: !selectedEventType.requiresConfirmation, }, }); diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index fb8b961a..36cfb95e 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -42,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) endTime: true, confirmed: true, attendees: true, + location: true, userId: true, id: true, uid: true, diff --git a/prisma/migrations/20210725123357_add_location_to_booking/migration.sql b/prisma/migrations/20210725123357_add_location_to_booking/migration.sql new file mode 100644 index 00000000..f2874f5e --- /dev/null +++ b/prisma/migrations/20210725123357_add_location_to_booking/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "location" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83aed724..d08335ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -132,6 +132,7 @@ model Booking { endTime DateTime attendees Attendee[] + location String? createdAt DateTime @default(now()) updatedAt DateTime? From 4dd8359a151ac5f73f0f9d388f739b74dac0cbf9 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 14:40:02 +0200 Subject: [PATCH 12/75] Made location optional --- lib/events/EventManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 050f870d..be68f58b 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -235,7 +235,7 @@ export default class EventManager { * @private */ private static isIntegration(location: string): boolean { - return location.includes("integrations:"); + return location?.includes("integrations:"); } /** From 4fb8e8285e8e86cee4233c162bf3058df9a3bd94 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 16:29:06 +0200 Subject: [PATCH 13/75] Added location to event --- pages/api/book/confirm.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 36cfb95e..65c60b91 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -64,6 +64,7 @@ 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, }; if (req.body.confirmed) { From b0ac65b0f6b64456e98048d837d99fa2eb37e6c8 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 17:05:18 +0200 Subject: [PATCH 14/75] Added maybeUid to createEvent and createMeeting --- lib/calendarClient.ts | 5 +++-- lib/events/EventManager.ts | 26 +++++++++++++++++++------- lib/videoClient.ts | 8 ++++++-- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index baa47349..6d59ff7a 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -512,10 +512,11 @@ const listCalendars = (withCredentials) => const createEvent = async ( credential: Credential, calEvent: CalendarEvent, - noMail = false + noMail = false, + maybeUid: string = null ): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); - const uid: string = parser.getUid(); + const uid: string = maybeUid ?? parser.getUid(); const richEvent: CalendarEvent = parser.asRichEvent(); let success = true; diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index be68f58b..98987022 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -54,19 +54,21 @@ export default class EventManager { * Takes a CalendarEvent and creates all necessary integration entries for it. * When a video integration is chosen as the event's location, a video integration * event will be scheduled for it as well. + * An optional uid can be set to override the auto-generated uid. * * @param event + * @param maybeUid */ - public async create(event: CalendarEvent): Promise { + public async create(event: CalendarEvent, maybeUid: string = null): Promise { event = EventManager.processLocation(event); const isVideo = EventManager.isIntegration(event.location); // First, create all calendar events. If this is a video event, don't send a mail right here. - const results: Array = await this.createAllCalendarEvents(event, isVideo); + const results: Array = await this.createAllCalendarEvents(event, isVideo, maybeUid); // If and only if event type is a video meeting, create a video meeting as well. if (isVideo) { - results.push(await this.createVideoEvent(event)); + results.push(await this.createVideoEvent(event, maybeUid)); } const referencesToCreate: Array = results.map((result) => { @@ -151,13 +153,20 @@ export default class EventManager { * a video meeting because then the mail containing the video credentials will be * more important than the mails created for these bare calendar events. * + * When the optional uid is set, it will be used instead of the auto generated uid. + * * @param event * @param noMail + * @param maybeUid * @private */ - private createAllCalendarEvents(event: CalendarEvent, noMail: boolean): Promise> { + private createAllCalendarEvents( + event: CalendarEvent, + noMail: boolean, + maybeUid: string = null + ): Promise> { return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { - return createEvent(credential, event, noMail); + return createEvent(credential, event, noMail, maybeUid); }); } @@ -175,14 +184,17 @@ export default class EventManager { /** * Creates a video event entry for the selected integration location. * + * When optional uid is set, it will be used instead of the auto generated uid. + * * @param event + * @param maybeUid * @private */ - private createVideoEvent(event: CalendarEvent): Promise { + private createVideoEvent(event: CalendarEvent, maybeUid: string = null): Promise { const credential = this.getVideoCredential(event); if (credential) { - return createMeeting(credential, event); + return createMeeting(credential, event, maybeUid); } else { return Promise.reject("No suitable credentials given for the requested integration name."); } diff --git a/lib/videoClient.ts b/lib/videoClient.ts index faea7d0b..1f4e3d3d 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -201,8 +201,12 @@ const getBusyVideoTimes = (withCredentials) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { - const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); +const createMeeting = async ( + credential, + calEvent: CalendarEvent, + maybeUid: string = null +): Promise => { + const uid: string = maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); if (!credential) { throw new Error( From 65fd73375145ad4622f8e94a3d9a9a3a4c2cf51b Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 17:08:11 +0200 Subject: [PATCH 15/75] Use optional udi --- pages/api/book/confirm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 65c60b91..54bc7552 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -69,7 +69,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.body.confirmed) { const eventManager = new EventManager(currentUser.credentials); - const scheduleResult = await eventManager.create(evt); + const scheduleResult = await eventManager.create(evt, booking.uid); await handleLegacyConfirmationMail( scheduleResult.results, From 2b38638d8499f8a356c878f915d25b41cd0c6d0d Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 19:15:31 +0200 Subject: [PATCH 16/75] Added maybeUid to CalEventParser --- lib/CalEventParser.ts | 8 +++++--- lib/calendarClient.ts | 4 ++-- lib/emails/EventMail.ts | 2 +- lib/videoClient.ts | 4 +++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index 69724ccc..78292804 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -6,10 +6,12 @@ import { stripHtml } from "./emails/helpers"; const translator = short(); export default class CalEventParser { - calEvent: CalendarEvent; + protected calEvent: CalendarEvent; + protected maybeUid: string; - constructor(calEvent: CalendarEvent) { + constructor(calEvent: CalendarEvent, maybeUid: string = null) { this.calEvent = calEvent; + this.maybeUid = maybeUid; } /** @@ -30,7 +32,7 @@ export default class CalEventParser { * Returns a unique identifier for the given calendar event. */ public getUid(): string { - return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL)); + return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL)); } /** diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 6d59ff7a..7102a535 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -515,8 +515,8 @@ const createEvent = async ( noMail = false, maybeUid: string = null ): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent); - const uid: string = maybeUid ?? parser.getUid(); + const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); + const uid: string = parser.getUid(); const richEvent: CalendarEvent = parser.asRichEvent(); let success = true; diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index 1c9253fc..b3728872 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -39,7 +39,7 @@ export default abstract class EventMail { constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) { this.calEvent = calEvent; this.uid = uid; - this.parser = new CalEventParser(calEvent); + this.parser = new CalEventParser(calEvent, uid); this.additionInformation = additionInformation; } diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 1f4e3d3d..755c0cda 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -10,6 +10,7 @@ import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail"; import { getIntegrationName } from "@lib/emails/helpers"; +import CalEventParser from "@lib/CalEventParser"; const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] }); @@ -206,7 +207,8 @@ const createMeeting = async ( calEvent: CalendarEvent, maybeUid: string = null ): Promise => { - const uid: string = maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); + const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); + const uid: string = parser.getUid(); if (!credential) { throw new Error( From 47ee0334db780c11468fb768438e4b89220afa7d Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 25 Jul 2021 23:22:34 +0200 Subject: [PATCH 17/75] Use better Regex to strip down html --- lib/emails/helpers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/emails/helpers.ts b/lib/emails/helpers.ts index e5218a0a..5c07dbbe 100644 --- a/lib/emails/helpers.ts +++ b/lib/emails/helpers.ts @@ -25,5 +25,11 @@ export function getFormattedMeetingId(videoCallData: VideoCallData): string { } export function stripHtml(html: string): string { - return html.replace("
", "\n").replace(/<[^>]+>/g, ""); + const aMailToRegExp = /"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g; + const aLinkRegExp = /"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g; + return html + .replace(//g, "\n") + .replace(aMailToRegExp, "$1") + .replace(aLinkRegExp, "$2: $1") + .replace(/<[^>]+>/g, ""); } From f948370bef6aa326a1c30fc04e2bb555ec50916d Mon Sep 17 00:00:00 2001 From: nicolas Date: Wed, 28 Jul 2021 22:05:37 +0200 Subject: [PATCH 18/75] Fixed codacy issues --- lib/videoClient.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 755c0cda..e4a5e686 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -11,6 +11,7 @@ import logger from "@lib/logger"; import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail"; import { getIntegrationName } from "@lib/emails/helpers"; import CalEventParser from "@lib/CalEventParser"; +import { Credential } from "@prisma/client"; const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] }); @@ -86,7 +87,7 @@ interface VideoApiAdapter { updateMeeting(uid: string, event: CalendarEvent); - deleteMeeting(uid: string); + deleteMeeting(uid: string): Promise; getAvailability(dateFrom, dateTo): Promise; } @@ -197,13 +198,13 @@ const videoIntegrations = (withCredentials): VideoApiAdapter[] => }) .filter(Boolean); -const getBusyVideoTimes = (withCredentials) => +const getBusyVideoTimes: (withCredentials) => Promise = (withCredentials) => Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) => results.reduce((acc, availability) => acc.concat(availability), []) ); const createMeeting = async ( - credential, + credential: Credential, calEvent: CalendarEvent, maybeUid: string = null ): Promise => { @@ -317,7 +318,7 @@ const updateMeeting = async ( }; }; -const deleteMeeting = (credential, uid: string): Promise => { +const deleteMeeting = (credential: Credential, uid: string): Promise => { if (credential) { return videoIntegrations([credential])[0].deleteMeeting(uid); } From 082281bdd04dc84c9e912e9e83e9fb282290d609 Mon Sep 17 00:00:00 2001 From: nicolas Date: Wed, 28 Jul 2021 22:44:52 +0200 Subject: [PATCH 19/75] Added type for credential --- lib/videoClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/videoClient.ts b/lib/videoClient.ts index e4a5e686..aa4b6c5d 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -270,7 +270,7 @@ const createMeeting = async ( }; const updateMeeting = async ( - credential, + credential: Credential, uidToUpdate: string, calEvent: CalendarEvent ): Promise => { From 04d7a280ef74a7f7fcdc80833a6f33428ebf311c Mon Sep 17 00:00:00 2001 From: Jan Vereecken Date: Sat, 31 Jul 2021 15:00:27 +0200 Subject: [PATCH 20/75] Use calendarView instead of events When calling Microsoft Graph use calendars/calendarView instead of calendars/events to allow occurences to be returned. --- lib/calendarClient.ts | 111 ++++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 9d3964b0..09668fa4 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -10,8 +10,14 @@ import CalEventParser from "./CalEventParser"; const { google } = require("googleapis"); const googleAuth = (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]); + 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 isExpired = () => myGoogleAuth.isTokenExpiring(); @@ -43,7 +49,8 @@ const googleAuth = (credential) => { }); return { - getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), + getToken: () => + !isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(), }; }; @@ -81,7 +88,9 @@ 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); + credential.key.expiry_date = Math.round( + +new Date() / 1000 + responseBody.expires_in + ); return prisma.credential .update({ where: { @@ -139,7 +148,11 @@ export interface CalendarApiAdapter { deleteEvent(uid: string); - getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; + getAvailability( + dateFrom, + dateTo, + selectedCalendars: IntegrationCalendar[] + ): Promise; listCalendars(): Promise; } @@ -206,7 +219,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { return { getAvailability: (dateFrom, dateTo, selectedCalendars) => { - const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'"; + const filter = "?startdatetime=" + dateFrom + "&enddatetime=" + dateTo; return auth .getToken() .then((accessToken) => { @@ -229,7 +242,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { headers: { Prefer: 'outlook.timezone="Etc/GMT"', }, - url: `/me/calendars/${calendarId}/events${filter}`, + url: `/me/calendars/${calendarId}/calendarView${filter}`, })); return fetch("https://graph.microsoft.com/v1.0/$batch", { @@ -309,7 +322,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === integrationType) .map((e) => e.externalId); @@ -320,7 +336,9 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { } (selectedCalendarIds.length == 0 - ? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id)) + ? calendar.calendarList + .list() + .then((cals) => cals.data.items.map((cal) => cal.id)) : Promise.resolve(selectedCalendarIds) ) .then((calsIds) => { @@ -336,12 +354,19 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { if (err) { reject(err); } - resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); + resolve( + Object.values(apires.data.calendars).flatMap( + (item) => item["busy"] + ) + ); } ); }) .catch((err) => { - console.error("There was an error contacting google calendar service: ", err); + console.error( + "There was an error contacting google calendar service: ", + err + ); reject(err); }); }) @@ -375,7 +400,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { payload["conferenceData"] = event.conferenceData; } - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); calendar.events.insert( { auth: myGoogleAuth, @@ -385,7 +413,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { }, function (err, event) { if (err) { - console.error("There was an error contacting google calendar service: ", err); + console.error( + "There was an error contacting google calendar service: ", + err + ); return reject(err); } return resolve(event.data); @@ -418,7 +449,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { payload["location"] = event.location; } - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); calendar.events.update( { auth: myGoogleAuth, @@ -430,7 +464,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { }, function (err, event) { if (err) { - console.error("There was an error contacting google calendar service: ", err); + console.error( + "There was an error contacting google calendar service: ", + err + ); return reject(err); } return resolve(event.data); @@ -441,7 +478,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { deleteEvent: (uid: string) => new Promise((resolve, reject) => auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); calendar.events.delete( { auth: myGoogleAuth, @@ -452,7 +492,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { }, function (err, event) { if (err) { - console.error("There was an error contacting google calendar service: ", err); + console.error( + "There was an error contacting google calendar service: ", + err + ); return reject(err); } return resolve(event.data); @@ -463,7 +506,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { listCalendars: () => new Promise((resolve, reject) => auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); calendar.calendarList .list() .then((cals) => { @@ -480,7 +526,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { ); }) .catch((err) => { - console.error("There was an error contacting google calendar service: ", err); + console.error( + "There was an error contacting google calendar service: ", + err + ); reject(err); }); }) @@ -503,19 +552,29 @@ const calendars = (withCredentials): CalendarApiAdapter[] => }) .filter(Boolean); -const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => +const getBusyCalendarTimes = ( + withCredentials, + dateFrom, + dateTo, + selectedCalendars +) => Promise.all( - calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) + calendars(withCredentials).map((c) => + c.getAvailability(dateFrom, dateTo, selectedCalendars) + ) ).then((results) => { return results.reduce((acc, availability) => acc.concat(availability), []); }); const listCalendars = (withCredentials) => - Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => - results.reduce((acc, calendars) => acc.concat(calendars), []) + Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then( + (results) => results.reduce((acc, calendars) => acc.concat(calendars), []) ); -const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { +const createEvent = async ( + credential: Credential, + calEvent: CalendarEvent +): Promise => { const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); /* @@ -525,7 +584,9 @@ const createEvent = async (credential: Credential, calEvent: CalendarEvent): Pro */ const richEvent: CalendarEvent = parser.asRichEventPlain(); - const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null; + const creationResult = credential + ? await calendars([credential])[0].createEvent(richEvent) + : null; const maybeHangoutLink = creationResult?.hangoutLink; const maybeEntryPoints = creationResult?.entryPoints; From 19374d38f71f0eda1fd5b5b6ac5408dda21d99f7 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 1 Aug 2021 23:29:15 +0200 Subject: [PATCH 21/75] Renamed isVideo to isDedicated; hard-coded logic for zoom meetings for now --- lib/events/EventManager.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 98987022..cc3143a5 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -61,13 +61,13 @@ export default class EventManager { */ public async create(event: CalendarEvent, maybeUid: string = null): Promise { event = EventManager.processLocation(event); - const isVideo = EventManager.isIntegration(event.location); + const isDedicated = EventManager.isDedicatedIntegration(event.location); - // First, create all calendar events. If this is a video event, don't send a mail right here. - const results: Array = await this.createAllCalendarEvents(event, isVideo, maybeUid); + // First, create all calendar events. If this is a dedicated integration event, don't send a mail right here. + const results: Array = await this.createAllCalendarEvents(event, isDedicated, maybeUid); - // If and only if event type is a video meeting, create a video meeting as well. - if (isVideo) { + // If and only if event type is a dedicated meeting, create a dedicated video meeting as well. + if (isDedicated) { results.push(await this.createVideoEvent(event, maybeUid)); } @@ -111,7 +111,7 @@ export default class EventManager { }, }); - const isVideo = EventManager.isIntegration(event.location); + const isVideo = EventManager.isDedicatedIntegration(event.location); // First, update all calendar events. If this is a video event, don't send a mail right here. const results: Array = await this.updateAllCalendarEvents(event, booking, isVideo); @@ -241,13 +241,19 @@ export default class EventManager { } /** - * Returns true if the given location describes an integration that delivers meeting credentials. + * Returns true if the given location describes a dedicated integration that + * delivers meeting credentials. Zoom, for example, is dedicated, because it + * needs to be called independently from any calendar APIs to receive meeting + * credentials. Google Meetings, in contrast, are not dedicated, because they + * are created while scheduling a regular calendar event by simply adding some + * attributes to the payload JSON. * * @param location * @private */ - private static isIntegration(location: string): boolean { - return location?.includes("integrations:"); + 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"; } /** From d2bc02e6fc5e3c471b114b522bfd313dd8c28697 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 1 Aug 2021 23:38:38 +0200 Subject: [PATCH 22/75] Further renaming --- lib/events/EventManager.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index cc3143a5..f40fb49b 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -111,13 +111,13 @@ export default class EventManager { }, }); - const isVideo = EventManager.isDedicatedIntegration(event.location); + const isDedicated = EventManager.isDedicatedIntegration(event.location); - // First, update all calendar events. If this is a video event, don't send a mail right here. - const results: Array = await this.updateAllCalendarEvents(event, booking, isVideo); + // First, update all calendar events. If this is a dedicated event, don't send a mail right here. + const results: Array = await this.updateAllCalendarEvents(event, booking, isDedicated); - // If and only if event type is a video meeting, update the video meeting as well. - if (isVideo) { + // If and only if event type is a dedicated meeting, update the dedicated video meeting as well. + if (isDedicated) { results.push(await this.updateVideoEvent(event, booking)); } From 00069fa9a7ee8ec1ff35fc8c1b6307857253bf9a Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz Date: Wed, 4 Aug 2021 16:09:43 +0530 Subject: [PATCH 23/75] Added -Add guest- button at confirm booking step, and minor code improvement --- package.json | 1 + pages/[user]/book.tsx | 54 ++++++++++++++++++++++++++++++++++++---- pages/api/book/[user].ts | 13 +++++++++- styles/globals.css | 4 +++ yarn.lock | 5 ++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2875cf53..a9b3ff71 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react": "17.0.1", "react-dates": "^21.8.0", "react-dom": "17.0.1", + "react-multi-email": "^0.5.3", "react-phone-number-input": "^3.1.21", "react-select": "^4.3.0", "react-timezone-select": "^1.0.2", diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index c1e0aade..56845233 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -15,6 +15,8 @@ import Avatar from "../../components/Avatar"; import Button from "../../components/ui/Button"; import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; import Theme from "@components/Theme"; +import { ReactMultiEmail, isEmail } from 'react-multi-email'; +import 'react-multi-email/style.css'; dayjs.extend(utc); dayjs.extend(timezone); @@ -27,7 +29,8 @@ export default function Book(props: any): JSX.Element { const [preferredTimeZone, setPreferredTimeZone] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); - + const [guestToggle, setGuestToggle] = useState(false); + const [guestEmails, setGuestEmails] = useState([]); const locations = props.eventType.locations || []; const [selectedLocation, setSelectedLocation] = useState( @@ -44,6 +47,10 @@ export default function Book(props: any): JSX.Element { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); }); + function toggleGuestEmailInput() { + setGuestToggle(!guestToggle); + } + const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); // TODO: Move to translations @@ -85,6 +92,7 @@ export default function Book(props: any): JSX.Element { name: event.target.name.value, email: event.target.email.value, notes: notes, + guests: guestEmails, timeZone: preferredTimeZone, eventTypeId: props.eventType.id, rescheduleUid: rescheduleUid, @@ -106,6 +114,7 @@ export default function Book(props: any): JSX.Element { } } + // console.log(payload); telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()) ); @@ -316,6 +325,42 @@ export default function Book(props: any): JSX.Element { )} ))} +
+ {!guestToggle && + + + } + { + guestToggle && + { + setGuestEmails(_emails); + }} + getLabel={( + email: string, + index: number, + removeEmail: (index: number) => void + ) => { + return ( +
+ {email} + removeEmail(index)}> + × + +
+ ); + }} + /> + } + +
)} - {!isFullyBooked && slots.length === 0 && !hasErrors &&
} + {!isFullyBooked && slots.length === 0 && !hasErrors && } {hasErrors && (
diff --git a/styles/globals.css b/styles/globals.css index 30beee13..59a997fc 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -144,7 +144,8 @@ height: 30px; margin: 60px auto; position: relative; - border: 4px solid #000; + border-width: 4px; + border-style: solid; animation: loader 2s infinite ease; } @@ -152,7 +153,6 @@ vertical-align: top; display: inline-block; width: 100%; - background-color: #000; animation: loader-inner 2s infinite ease-in; } From 8bc209f6d02ac3bc264a9007e6d3ebbfd5fe3b0e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 4 Aug 2021 20:28:35 +0000 Subject: [PATCH 26/75] Attempts to fix conflict with zoom --- pages/api/availability/[user].ts | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index f0c64068..2d1c87ca 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import prisma from "../../../lib/prisma"; -import { getBusyCalendarTimes } from "../../../lib/calendarClient"; -import { getBusyVideoTimes } from "../../../lib/videoClient"; +import prisma from "@lib/prisma"; +import { getBusyCalendarTimes } from "@lib/calendarClient"; +import { getBusyVideoTimes } from "@lib/videoClient"; import dayjs from "dayjs"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -25,39 +25,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - const hasCalendarIntegrations = - currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0; - const hasVideoIntegrations = - currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0; - - const calendarAvailability = await getBusyCalendarTimes( + const calendarBusyTimes = await getBusyCalendarTimes( currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars ); - const videoAvailability = await getBusyVideoTimes( + const videoBusyTimes = await getBusyVideoTimes( currentUser.credentials, req.query.dateFrom, req.query.dateTo ); + calendarBusyTimes.push(...videoBusyTimes); - let commonAvailability = []; - - if (hasCalendarIntegrations && hasVideoIntegrations) { - commonAvailability = calendarAvailability.filter((availability) => - videoAvailability.includes(availability) - ); - } else if (hasVideoIntegrations) { - commonAvailability = videoAvailability; - } else if (hasCalendarIntegrations) { - commonAvailability = calendarAvailability; - } - - commonAvailability = commonAvailability.map((a) => ({ + const bufferedBusyTimes = calendarBusyTimes.map((a) => ({ start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(), end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(), })); - res.status(200).json(commonAvailability); + res.status(200).json(bufferedBusyTimes); } From 6102feec1f659e75740a1fffd97e2fbb3e2b09b0 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz Date: Thu, 5 Aug 2021 10:03:08 +0530 Subject: [PATCH 27/75] improved styling and dark mode for add-guest button --- pages/[user]/book.tsx | 59 ++++++++++++++++++++++------------------ pages/api/book/[user].ts | 2 +- styles/globals.css | 59 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 29 deletions(-) diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index c96c2439..bce1c3ac 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -16,7 +16,7 @@ import Button from "../../components/ui/Button"; import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; import Theme from "@components/Theme"; import { ReactMultiEmail, isEmail } from 'react-multi-email'; -import 'react-multi-email/style.css'; +// import 'react-multi-email/style.css'; dayjs.extend(utc); dayjs.extend(timezone); @@ -332,33 +332,38 @@ export default function Book(props: any): JSX.Element { className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer"> + Additional Guests - } - { - guestToggle && - { - setGuestEmails(_emails); - }} - getLabel={( - email: string, - index: number, - removeEmail: (index: number) => void - ) => { - return ( -
- {email} - removeEmail(index)}> - × - -
- ); - }} - /> - } - + {guestToggle && ( +
+ + { + setGuestEmails(_emails); + }} + getLabel={( + email: string, + index: number, + removeEmail: (index: number) => void + ) => { + return ( +
+ {email} + removeEmail(index)}> + × + +
+ ); + }} + /> +
+ )} +
-
+
@@ -135,10 +135,10 @@ export default function Shell(props) {
-
+
-
-

{props.heading}

+
+

{props.heading}

{props.subtitle}

{props.CTA}
diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx index 8b73886c..f807f9ce 100644 --- a/pages/event-types/index.tsx +++ b/pages/event-types/index.tsx @@ -176,7 +176,7 @@ export default function Availability({ user, types }) { heading="Event Types" subtitle="Create events to share for people to book on your calendar." CTA={types.length !== 0 && }> -
+