From ded27d17ea764dad14754bb9958002e9b0cef8c1 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sun, 20 Jun 2021 17:33:02 +0200 Subject: [PATCH 1/4] - save refreshed tokens of both calendar integrations - Office365 expiry check was off by *1000 - log errors from calendar integrations with console.error - improved google calendar integration performance further when calendars are selected --- lib/calendarClient.ts | 135 +++++++++++++++++++++++++++--------------- 1 file changed, 88 insertions(+), 47 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index fa358fbf..70624264 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,14 +1,44 @@ +import prisma from "./prisma"; + const {google} = require('googleapis'); import createNewEventEmail from "./emails/new-event"; -const googleAuth = () => { +const googleAuth = (credential) => { const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; - return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); + const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); + myGoogleAuth.setCredentials(credential.key); + + 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; + return prisma.credential.update({ + where: { + id: credential.id + }, + data: { + key: credential.key + } + }).then(() => { + myGoogleAuth.setCredentials(credential.key); + return myGoogleAuth; + }); + }).catch(err => { + console.error("Error refreshing google token", err); + return myGoogleAuth; + }); + + return { + getToken: () => !isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken() + }; + }; function handleErrorsJson(response) { if (!response.ok) { - response.json().then(console.log); + response.json().then(e => console.error("O365 Error", e)); throw Error(response.statusText); } return response.json(); @@ -16,7 +46,7 @@ function handleErrorsJson(response) { function handleErrorsRaw(response) { if (!response.ok) { - response.text().then(console.log); + response.text().then(e => console.error("O365 Error", e)); throw Error(response.statusText); } return response.text(); @@ -24,25 +54,34 @@ function handleErrorsRaw(response) { const o365Auth = (credential) => { - const isExpired = (expiryDate) => expiryDate < +(new Date()); + const isExpired = (expiryDate) => expiryDate < Math.round((+(new Date()) / 1000)); - const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { - method: 'POST', - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: new URLSearchParams({ - 'scope': 'User.Read Calendars.Read Calendars.ReadWrite', - 'client_id': process.env.MS_GRAPH_CLIENT_ID, - 'refresh_token': refreshToken, - 'grant_type': 'refresh_token', - 'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, - }) - }) - .then(handleErrorsJson) - .then((responseBody) => { - credential.key.access_token = responseBody.access_token; - credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); - return credential.key.access_token; + const refreshAccessToken = (refreshToken) => { + return fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({ + 'scope': 'User.Read Calendars.Read Calendars.ReadWrite', + 'client_id': process.env.MS_GRAPH_CLIENT_ID, + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + 'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, + }) }) + .then(handleErrorsJson) + .then((responseBody) => { + credential.key.access_token = responseBody.access_token; + credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); + return prisma.credential.update({ + where: { + id: credential.id + }, + data: { + key: credential.key + } + }).then(() => credential.key.access_token) + }) + } return { getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) @@ -173,7 +212,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }) } ).catch((err) => { - console.log(err); + console.error(err); }); }, createEvent: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', { @@ -206,32 +245,32 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }; const GoogleCalendar = (credential): CalendarApiAdapter => { - const myGoogleAuth = googleAuth(); - myGoogleAuth.setCredentials(credential.key); + const auth = googleAuth(credential); const integrationType = "google_calendar"; return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => { + getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - calendar.calendarList - .list() - .then(cals => { - const filteredItems = cals.data.items.filter(i => selectedCalendars.findIndex(e => e.externalId === i.id) > -1) - if (filteredItems.length == 0 && selectedCalendars.length > 0){ - // Only calendars of other integrations selected - resolve([]); - } + const selectedCalendarIds = selectedCalendars.filter(e => e.integration === integrationType).map(e => e.externalId); + 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)) + : Promise.resolve(selectedCalendarIds)).then(calsIds => { calendar.freebusy.query({ requestBody: { timeMin: dateFrom, timeMax: dateTo, - items: filteredItems.length > 0 ? filteredItems : cals.data.items + items: calsIds.map(id => ({id: id})) } }, (err, apires) => { if (err) { reject(err); } - resolve( Object.values(apires.data.calendars).flatMap( (item) => item["busy"] @@ -240,11 +279,12 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { }); }) .catch((err) => { + console.error('There was an error contacting google calendar service: ', err); reject(err); }); - }), - createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => { + })), + createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { const payload = { summary: event.title, description: event.description, @@ -276,13 +316,13 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { resource: payload, }, function (err, event) { if (err) { - console.log('There was an error contacting the Calendar service: ' + err); + console.error('There was an error contacting google calendar service: ', err); return reject(err); } return resolve(event.data); }); - }), - updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => { + })), + updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { const payload = { summary: event.title, description: event.description, @@ -317,13 +357,13 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { resource: payload }, function (err, event) { if (err) { - console.log('There was an error contacting the Calendar service: ' + err); + console.error('There was an error contacting google calendar service: ', err); return reject(err); } return resolve(event.data); }); - }), - deleteEvent: (uid: String) => new Promise( (resolve, reject) => { + })), + deleteEvent: (uid: String) => new Promise( (resolve, reject) => auth.getToken().then(myGoogleAuth => { const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); calendar.events.delete({ auth: myGoogleAuth, @@ -333,13 +373,13 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { sendUpdates: 'all', }, function (err, event) { if (err) { - console.log('There was an error contacting the Calendar service: ' + err); + console.error('There was an error contacting google calendar service: ', err); return reject(err); } return resolve(event.data); }); - }), - listCalendars: () => new Promise((resolve, reject) => { + })), + listCalendars: () => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); calendar.calendarList .list() @@ -352,9 +392,10 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { })) }) .catch((err) => { + console.error('There was an error contacting google calendar service: ', err); reject(err); }); - }) + })) }; }; From 931e6b26f1dd1e646b5be6cdafeddca74a6e3eff Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sun, 20 Jun 2021 20:32:30 +0200 Subject: [PATCH 2/4] error handling WIP --- components/ui/Button.tsx | 9 ++-- pages/[user]/[type].tsx | 40 ++++++++++++++++-- pages/[user]/book.tsx | 91 ++++++++++++++++++++++++++-------------- pages/api/book/[user].ts | 88 +++++++++++++++++++++++++------------- 4 files changed, 157 insertions(+), 71 deletions(-) diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 647d633c..054bbd66 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -1,11 +1,10 @@ import { useState } from 'react'; export default function Button(props) { - const [loading, setLoading] = useState(false); return( - ); -} \ No newline at end of file +} diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index aa57bd48..9c6a88c4 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -6,7 +6,14 @@ import { useRouter } from 'next/router'; import dayjs, { Dayjs } from 'dayjs'; import { Switch } from '@headlessui/react'; import TimezoneSelect from 'react-timezone-select'; -import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'; +import { + ClockIcon, + GlobeIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + XCircleIcon, ExclamationIcon +} from '@heroicons/react/solid'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isBetween from 'dayjs/plugin/isBetween'; import utc from 'dayjs/plugin/utc'; @@ -29,6 +36,7 @@ export default function Type(props) { const [selectedDate, setSelectedDate] = useState(); const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [is24h, setIs24h] = useState(false); const [busy, setBusy] = useState([]); @@ -72,9 +80,16 @@ export default function Type(props) { } setLoading(true); + setError(false); + const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`); - const busyTimes = await res.json(); - if (busyTimes.length > 0) setBusy(busyTimes); + if (res.ok) { + const busyTimes = await res.json(); + if (busyTimes.length > 0) setBusy(busyTimes); + } else { + setError(true); + } + setLoading(false); } changeDate(); @@ -340,7 +355,24 @@ export default function Type(props) { {dayjs(selectedDate).format("dddd DD MMMM YYYY")} - {!loading ? availableTimes :
} + {!loading && !error && availableTimes} + {loading &&
} + {error && +
+
+
+
+
+

+ Could not load the available time slots.{' '} + + Contact {props.user.name} via e-mail + +

+
+
+
}
)} diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index fb9165e9..1aeec756 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -1,7 +1,7 @@ import Head from 'next/head'; import Link from 'next/link'; import {useRouter} from 'next/router'; -import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid'; +import {CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon} from '@heroicons/react/solid'; import prisma from '../../lib/prisma'; import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; import {useEffect, useState} from "react"; @@ -23,6 +23,8 @@ export default function Book(props) { const [ is24h, setIs24h ] = useState(false); const [ preferredTimeZone, setPreferredTimeZone ] = useState(''); + const [ loading, setLoading ] = useState(false); + const [ error, setError ] = useState(false); const locations = props.eventType.locations || []; @@ -47,41 +49,51 @@ export default function Book(props) { }; const bookingHandler = event => { - event.preventDefault(); + const book = async () => { + setLoading(true); + setError(false); + let payload = { + start: dayjs(date).format(), + end: dayjs(date).add(props.eventType.length, 'minute').format(), + name: event.target.name.value, + email: event.target.email.value, + notes: event.target.notes.value, + timeZone: preferredTimeZone, + eventTypeId: props.eventType.id, + rescheduleUid: rescheduleUid + }; - let payload = { - start: dayjs(date).format(), - end: dayjs(date).add(props.eventType.length, 'minute').format(), - name: event.target.name.value, - email: event.target.email.value, - notes: event.target.notes.value, - timeZone: preferredTimeZone, - eventTypeId: props.eventType.id, - rescheduleUid: rescheduleUid - }; - - if (selectedLocation) { - payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address; - } - - telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())); - const res = fetch( - '/api/book/' + user, - { - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json' - }, - method: 'POST' + if (selectedLocation) { + payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address; } - ); - let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1&name=${payload.name}`; - if (payload['location']) { - successUrl += "&location=" + encodeURIComponent(payload['location']); + telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())); + const res = await fetch( + '/api/book/' + user, + { + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json' + }, + method: 'POST' + } + ); + + if (res.ok) { + let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1&name=${payload.name}`; + if (payload['location']) { + successUrl += "&location=" + encodeURIComponent(payload['location']); + } + + await router.push(successUrl); + } else { + setLoading(false); + setError(true); + } } - router.push(successUrl); + event.preventDefault(); + book(); } return ( @@ -148,12 +160,27 @@ export default function Book(props) {
- + Cancel
+ {error &&
+
+
+
+
+

+ Could not {rescheduleUid ? 'reschedule' : 'book'} the meeting. Please try again or{' '} + + Contact {props.user.name} via e-mail + +

+
+
+
} diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 9c1f4f3e..9f802bc7 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -98,9 +98,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Use all integrations results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) + return updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) + .then(response => ({type: credential.type, success: true, response})) + .catch(e => { + console.error("createEvent failed", e) + return {type: credential.type, success: false} + }); }); + if (results.every(res => !res.success)) { + res.status(500).json({message: "Rescheduling failed"}); + return; + } + // Clone elements referencesToCreate = [...booking.references]; @@ -129,14 +139,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } else { // Schedule event results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { - const response = await createEvent(credential, appendLinksToEvents(evt)); - return { - type: credential.type, - response - }; + return createEvent(credential, appendLinksToEvents(evt)) + .then(response => ({type: credential.type, success: true, response})) + .catch(e => { + console.error("createEvent failed", e) + return {type: credential.type, success: false} + }); }); - referencesToCreate = results.map((result => { + if (results.every(res => !res.success)) { + res.status(500).json({message: "Booking failed"}); + return; + } + + referencesToCreate = results.filter(res => res.success).map((result => { return { type: result.type, uid: result.response.id @@ -144,32 +160,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })); } - await prisma.booking.create({ - data: { - uid: hashUID, - userId: currentUser.id, - references: { - create: referencesToCreate - }, - eventTypeId: eventType.id, + let booking; + try { + booking = await prisma.booking.create({ + data: { + uid: hashUID, + userId: currentUser.id, + references: { + create: referencesToCreate + }, + eventTypeId: eventType.id, - title: evt.title, - description: evt.description, - startTime: evt.startTime, - endTime: evt.endTime, + title: evt.title, + description: evt.description, + startTime: evt.startTime, + endTime: evt.endTime, - attendees: { - create: evt.attendees + attendees: { + create: evt.attendees + } } - } - }); - - // If one of the integrations allows email confirmations or no integrations are added, send it. - if (currentUser.credentials.length === 0 || !results.every((result) => result.disableConfirmationEmail)) { - await createConfirmBookedEmail( - evt, cancelLink, rescheduleLink - ); + }); + } catch (e) { + console.error("Error when saving booking to db", e); + res.status(500).json({message: "Booking already exists"}); + return; } - res.status(200).json(results); + try { + // If one of the integrations allows email confirmations or no integrations are added, send it. + // TODO: locally this is really slow (maybe only because the authentication for the mail service fails), so fire and forget would be nice here + if (currentUser.credentials.length === 0 || !results.every((result) => result.response.disableConfirmationEmail)) { + await createConfirmBookedEmail( + evt, cancelLink, rescheduleLink + ); + } + } catch (e) { + console.error("createConfirmBookedEmail failed", e) + } + + res.status(204).send({}); } From 22a009edd22e31b39dac4ffc7afb322e99c4313b Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Mon, 21 Jun 2021 18:15:05 +0200 Subject: [PATCH 3/4] fixes after merge --- lib/calendarClient.ts | 24 ++++++++++++++++++++---- lib/videoClient.ts | 24 ++++++++++++++++++++---- pages/api/book/[user].ts | 28 +++++++++++++++------------- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index ff262819..1629beee 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -439,10 +439,18 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise => const organizerMail = new EventOrganizerMail(calEvent, uid); const attendeeMail = new EventAttendeeMail(calEvent, uid); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e) + } if (!creationResult || !creationResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e) + } } return { @@ -458,10 +466,18 @@ const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEv const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e) + } if (!updateResult || !updateResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e) + } } return { diff --git a/lib/videoClient.ts b/lib/videoClient.ts index b359e83a..0e171ac6 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -193,10 +193,18 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData); const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e) + } if (!creationResult || !creationResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e) + } } return { @@ -216,10 +224,18 @@ const updateMeeting = async (credential, uidToUpdate: String, calEvent: Calendar const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e) + } if (!updateResult || !updateResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e) + } } return { diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 9711f482..e5fcd285 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -93,7 +93,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return updateEvent(credential, bookingRefUid, evt) .then(response => ({type: credential.type, success: true, response})) .catch(e => { - console.error("createEvent failed", e) + console.error("updateEvent failed", e) return {type: credential.type, success: false} }); })); @@ -103,12 +103,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return updateMeeting(credential, bookingRefUid, evt) .then(response => ({type: credential.type, success: true, response})) .catch(e => { - console.error("createEvent failed", e) + console.error("updateMeeting failed", e) return {type: credential.type, success: false} }); })); - if (results.every(res => !res.success)) { + if (results.length > 0 && results.every(res => !res.success)) { res.status(500).json({message: "Rescheduling failed"}); return; } @@ -153,12 +153,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return createMeeting(credential, evt) .then(response => ({type: credential.type, success: true, response})) .catch(e => { - console.error("createEvent failed", e) + console.error("createMeeting failed", e) return {type: credential.type, success: false} }); })); - if (results.every(res => !res.success)) { + if (results.length > 0 && results.every(res => !res.success)) { res.status(500).json({message: "Booking failed"}); return; } @@ -172,17 +172,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); - try { - // 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) { - // Legacy as well, as soon as we have a separate email integration class. Just used - // to send an email even if there is no integration at all. + // 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) { + // Legacy as well, as soon as we have a separate email integration class. Just used + // to send an email even if there is no integration at all. + try { const mail = new EventAttendeeMail(evt, hashUID); await mail.sendEmail(); + } catch (e) { + console.error("Sending legacy event mail failed", e) + res.status(500).json({message: "Booking failed"}); + return; } - } catch (e) { - console.error("send EventAttendeeMail failed", e) } let booking; From 646ff4a1079030f631997231efbc64949088d1ef Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Thu, 24 Jun 2021 18:12:22 +0200 Subject: [PATCH 4/4] eslint fixes --- components/booking/AvailableTimes.tsx | 112 +++--- lib/calendarClient.ts | 500 +++++++++++++------------- pages/api/book/[user].ts | 245 ++++++------- 3 files changed, 438 insertions(+), 419 deletions(-) diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index d44465d0..2e1f5f3e 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -1,38 +1,37 @@ -import dayjs, {Dayjs} from "dayjs"; -import isBetween from 'dayjs/plugin/isBetween'; +import dayjs from "dayjs"; +import isBetween from "dayjs/plugin/isBetween"; dayjs.extend(isBetween); -import {useEffect, useMemo, useState} from "react"; +import { useEffect, useState } from "react"; import getSlots from "../../lib/slots"; import Link from "next/link"; -import {timeZone} from "../../lib/clock"; -import {useRouter} from "next/router"; -import {ExclamationIcon} from "@heroicons/react/solid"; +import { timeZone } from "../../lib/clock"; +import { useRouter } from "next/router"; +import { ExclamationIcon } from "@heroicons/react/solid"; const AvailableTimes = (props) => { - const router = useRouter(); const { user, rescheduleUid } = router.query; const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const times = getSlots({ - calendarTimeZone: props.user.timeZone, - selectedTimeZone: timeZone(), - eventLength: props.eventType.length, - selectedDate: props.date, - dayStartTime: props.user.startTime, - dayEndTime: props.user.endTime, - }); + calendarTimeZone: props.user.timeZone, + selectedTimeZone: timeZone(), + eventLength: props.eventType.length, + selectedDate: props.date, + dayStartTime: props.user.startTime, + dayEndTime: props.user.endTime, + }); const handleAvailableSlots = (busyTimes: []) => { // Check for conflicts for (let i = times.length - 1; i >= 0; i -= 1) { - busyTimes.forEach(busyTime => { - let startTime = dayjs(busyTime.start); - let endTime = dayjs(busyTime.end); + busyTimes.forEach((busyTime) => { + const startTime = dayjs(busyTime.start); + const endTime = dayjs(busyTime.end); // Check if start times are the same - if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) { + if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) { times.splice(i, 1); } @@ -42,12 +41,12 @@ const AvailableTimes = (props) => { } // Check if slot end time is between start and end time - if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) { + if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) { times.splice(i, 1); } // Check if startTime is between slot - if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) { + if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) { times.splice(i, 1); } }); @@ -60,49 +59,64 @@ const AvailableTimes = (props) => { useEffect(() => { setLoaded(false); setError(false); - fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`) - .then( res => res.json()) + fetch( + `/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date + .endOf("day") + .utc() + .format()}` + ) + .then((res) => res.json()) .then(handleAvailableSlots) - .catch(e => setError(true)) + .catch((e) => { + console.error(e); + setError(true); + }); }, [props.date]); return (
- - {props.date.format("dddd DD MMMM YYYY")} - + {props.date.format("dddd DD MMMM YYYY")}
- { - !error && loaded && times.map((time) => + {!error && + loaded && + times.map((time) => (
- {dayjs(time).tz(timeZone()).format(props.timeFormat)} + href={ + `/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "") + }> + + {dayjs(time).tz(timeZone()).format(props.timeFormat)} +
- ) - } - {!error && !loaded &&
} - {error && -
+ ))} + {!error && !loaded &&
} + {error && ( +
-
-
-
-

- Could not load the available time slots.{' '} - - Contact {props.user.name} via e-mail - -

-
+
+
+
+

+ Could not load the available time slots.{" "} + + Contact {props.user.name} via e-mail + +

+
-
} +
+ )}
); -} +}; export default AvailableTimes; diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index b830fdfb..4d8d7421 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -7,44 +7,51 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail" const translator = short(); +// eslint-disable-next-line @typescript-eslint/no-var-requires const { google } = require("googleapis"); import prisma from "./prisma"; const googleAuth = (credential) => { - const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; + 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); + myGoogleAuth.setCredentials(credential.key); - const isExpired = () => myGoogleAuth.isTokenExpiring(); + const isExpired = () => myGoogleAuth.isTokenExpiring(); - const refreshAccessToken = () => myGoogleAuth.refreshToken(credential.key.refresh_token).then(res => { + 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; - return prisma.credential.update({ + return prisma.credential + .update({ where: { - id: credential.id + id: credential.id, }, data: { - key: credential.key - } - }).then(() => { + key: credential.key, + }, + }) + .then(() => { myGoogleAuth.setCredentials(credential.key); return myGoogleAuth; - }); - }).catch(err => { + }); + }) + .catch((err) => { console.error("Error refreshing google token", err); return myGoogleAuth; - }); + }); - return { - getToken: () => !isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken() - }; + return { + getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), + }; }; function handleErrorsJson(response) { if (!response.ok) { - response.json().then(e => console.error("O365 Error", e)); + response.json().then((e) => console.error("O365 Error", e)); throw Error(response.statusText); } return response.json(); @@ -52,41 +59,43 @@ function handleErrorsJson(response) { function handleErrorsRaw(response) { if (!response.ok) { - response.text().then(e => console.error("O365 Error", e)); + response.text().then((e) => console.error("O365 Error", e)); throw Error(response.statusText); } return response.text(); } const o365Auth = (credential) => { - const isExpired = (expiryDate) => expiryDate < Math.round((+(new Date()) / 1000)); + const isExpired = (expiryDate) => expiryDate < Math.round(+new Date() / 1000); const refreshAccessToken = (refreshToken) => { - return fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { - method: 'POST', - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: new URLSearchParams({ - 'scope': 'User.Read Calendars.Read Calendars.ReadWrite', - 'client_id': process.env.MS_GRAPH_CLIENT_ID, - 'refresh_token': refreshToken, - 'grant_type': 'refresh_token', - 'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, - }) - }) - .then(handleErrorsJson) - .then((responseBody) => { - credential.key.access_token = responseBody.access_token; - credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); - return prisma.credential.update({ - where: { - id: credential.id - }, - data: { - key: credential.key - } - }).then(() => credential.key.access_token) + return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + scope: "User.Read Calendars.Read Calendars.ReadWrite", + client_id: process.env.MS_GRAPH_CLIENT_ID, + refresh_token: refreshToken, + grant_type: "refresh_token", + client_secret: process.env.MS_GRAPH_CLIENT_SECRET, + }), + }) + .then(handleErrorsJson) + .then((responseBody) => { + credential.key.access_token = responseBody.access_token; + credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: credential.key, + }, }) - } + .then(() => credential.key.access_token); + }); + }; return { getToken: () => @@ -128,15 +137,11 @@ interface IntegrationCalendar { interface CalendarApiAdapter { createEvent(event: CalendarEvent): Promise; - updateEvent(uid: String, event: CalendarEvent); + updateEvent(uid: string, event: CalendarEvent); - deleteEvent(uid: String); + deleteEvent(uid: string); - getAvailability( - dateFrom, - dateTo, - selectedCalendars: IntegrationCalendar[] - ): Promise; + getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; listCalendars(): Promise; } @@ -145,7 +150,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { const auth = o365Auth(credential); const translateEvent = (event: CalendarEvent) => { - let optional = {}; + const optional = {}; if (event.location) { optional.location = { displayName: event.location }; } @@ -203,12 +208,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { return { getAvailability: (dateFrom, dateTo, selectedCalendars) => { - const filter = - "?$filter=start/dateTime ge '" + - dateFrom + - "' and end/dateTime le '" + - dateTo + - "'"; + const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'"; return auth .getToken() .then((accessToken) => { @@ -227,10 +227,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { ).then((ids: string[]) => { const urls = ids.map( (calendarId) => - "https://graph.microsoft.com/v1.0/me/calendars/" + - calendarId + - "/events" + - filter + "https://graph.microsoft.com/v1.0/me/calendars/" + calendarId + "/events" + filter ); return Promise.all( urls.map((url) => @@ -249,9 +246,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { })) ) ) - ).then((results) => - results.reduce((acc, events) => acc.concat(events), []) - ); + ).then((results) => results.reduce((acc, events) => acc.concat(events), [])); }); }) .catch((err) => { @@ -274,7 +269,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { disableConfirmationEmail: true, })) ), - deleteEvent: (uid: String) => + deleteEvent: (uid: string) => auth.getToken().then((accessToken) => fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { method: "DELETE", @@ -283,7 +278,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }, }).then(handleErrorsRaw) ), - updateEvent: (uid: String, event: CalendarEvent) => + updateEvent: (uid: string, event: CalendarEvent) => auth.getToken().then((accessToken) => fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { method: "PATCH", @@ -299,162 +294,189 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }; const GoogleCalendar = (credential): CalendarApiAdapter => { - const auth = googleAuth(credential); - const integrationType = "google_calendar"; + const auth = googleAuth(credential); + const integrationType = "google_calendar"; - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { - const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - const selectedCalendarIds = selectedCalendars.filter(e => e.integration === integrationType).map(e => e.externalId); - if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0){ - // Only calendars of other integrations selected - resolve([]); - return; - } + return { + getAvailability: (dateFrom, dateTo, selectedCalendars) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === integrationType) + .map((e) => e.externalId); + 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)) - : Promise.resolve(selectedCalendarIds)).then(calsIds => { - calendar.freebusy.query({ - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: calsIds.map(id => ({id: id})) - } - }, (err, apires) => { - if (err) { - reject(err); - } - resolve( - Object.values(apires.data.calendars).flatMap( - (item) => item["busy"] - ) - ) - }); - }) - .catch((err) => { - console.error('There was an error contacting google calendar service: ', err); + (selectedCalendarIds.length == 0 + ? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id)) + : Promise.resolve(selectedCalendarIds) + ) + .then((calsIds) => { + calendar.freebusy.query( + { + requestBody: { + timeMin: dateFrom, + timeMax: dateTo, + items: calsIds.map((id) => ({ id: id })), + }, + }, + (err, apires) => { + if (err) { reject(err); - }); - - })), - createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { - const payload = { - summary: event.title, - description: event.description, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees, - reminders: { - useDefault: false, - overrides: [ - {'method': 'email', 'minutes': 60} - ], - }, - }; - - if (event.location) { - payload["location"] = event.location; - } - - if (event.conferenceData) { - payload["conferenceData"] = event.conferenceData; - } - - const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - calendar.events.insert({ - auth: myGoogleAuth, - calendarId: 'primary', - resource: payload, - }, function (err, event) { - if (err) { - console.error('There was an error contacting google calendar service: ', err); - return reject(err); - } - return resolve(event.data); - }); - })), - updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { - const payload = { - summary: event.title, - description: event.description, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees, - reminders: { - useDefault: false, - overrides: [ - {'method': 'email', 'minutes': 60} - ], - }, - }; - - if (event.location) { - payload["location"] = event.location; - } - - const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - calendar.events.update({ - auth: myGoogleAuth, - calendarId: 'primary', - eventId: uid, - sendNotifications: true, - sendUpdates: 'all', - resource: payload - }, function (err, event) { - if (err) { - console.error('There was an error contacting google calendar service: ', err); - return reject(err); + } + resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); } - return resolve(event.data); + ); + }) + .catch((err) => { + console.error("There was an error contacting google calendar service: ", err); + reject(err); }); - })), - deleteEvent: (uid: String) => new Promise( (resolve, reject) => auth.getToken().then(myGoogleAuth => { - const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - calendar.events.delete({ - auth: myGoogleAuth, - calendarId: 'primary', - eventId: uid, - sendNotifications: true, - sendUpdates: 'all', - }, function (err, event) { - if (err) { - console.error('There was an error contacting google calendar service: ', err); - return reject(err); - } - return resolve(event.data); + }) + ), + createEvent: (event: CalendarEvent) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const payload = { + summary: event.title, + description: event.description, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: false, + overrides: [{ method: "email", minutes: 60 }], + }, + }; + + if (event.location) { + payload["location"] = event.location; + } + + if (event.conferenceData) { + payload["conferenceData"] = event.conferenceData; + } + + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + calendar.events.insert( + { + auth: myGoogleAuth, + calendarId: "primary", + resource: payload, + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event.data); + } + ); + }) + ), + updateEvent: (uid: string, event: CalendarEvent) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const payload = { + summary: event.title, + description: event.description, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: false, + overrides: [{ method: "email", minutes: 60 }], + }, + }; + + if (event.location) { + payload["location"] = event.location; + } + + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + calendar.events.update( + { + auth: myGoogleAuth, + calendarId: "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + resource: payload, + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event.data); + } + ); + }) + ), + deleteEvent: (uid: string) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + calendar.events.delete( + { + auth: myGoogleAuth, + calendarId: "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event.data); + } + ); + }) + ), + listCalendars: () => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + calendar.calendarList + .list() + .then((cals) => { + resolve( + cals.data.items.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id, + integration: integrationType, + name: cal.summary, + primary: cal.primary, + }; + return calendar; + }) + ); + }) + .catch((err) => { + console.error("There was an error contacting google calendar service: ", err); + reject(err); }); - })), - listCalendars: () => new Promise((resolve, reject) => auth.getToken().then(myGoogleAuth => { - const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - calendar.calendarList - .list() - .then(cals => { - resolve(cals.data.items.map(cal => { - const calendar: IntegrationCalendar = { - externalId: cal.id, integration: integrationType, name: cal.summary, primary: cal.primary - } - return calendar; - })) - }) - .catch((err) => { - console.error('There was an error contacting google calendar service: ', err); - reject(err); - }); - })) - }; + }) + ), + }; }; // factory @@ -472,50 +494,36 @@ 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, - calEvent: CalendarEvent -): Promise => { - const uid: string = translator.fromUUID( - uuidv5(JSON.stringify(calEvent), uuidv5.URL) - ); +const createEvent = async (credential, calEvent: CalendarEvent): Promise => { + const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); - const creationResult = credential - ? await calendars([credential])[0].createEvent(calEvent) - : null; + const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; const organizerMail = new EventOrganizerMail(calEvent, uid); const attendeeMail = new EventAttendeeMail(calEvent, uid); 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); } } @@ -525,14 +533,8 @@ const createEvent = async ( }; }; -const updateEvent = async ( - credential, - uidToUpdate: String, - calEvent: CalendarEvent -): Promise => { - const newUid: string = translator.fromUUID( - uuidv5(JSON.stringify(calEvent), uuidv5.URL) - ); +const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise => { + const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) @@ -543,14 +545,14 @@ const updateEvent = async ( 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); } } @@ -560,7 +562,7 @@ const updateEvent = async ( }; }; -const deleteEvent = (credential, uid: String): Promise => { +const deleteEvent = (credential, uid: string): Promise => { if (credential) { return calendars([credential])[0].deleteEvent(uid); } diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 386f59ab..db82b3e6 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,38 +1,38 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; -import prisma from '../../../lib/prisma'; -import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient'; -import async from 'async'; -import {v5 as uuidv5} from 'uuid'; -import short from 'short-uuid'; -import {createMeeting, updateMeeting} from "../../../lib/videoClient"; +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "../../../lib/prisma"; +import { CalendarEvent, createEvent, updateEvent } from "../../../lib/calendarClient"; +import async from "async"; +import { v5 as uuidv5 } from "uuid"; +import short from "short-uuid"; +import { createMeeting, updateMeeting } 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 { getEventName } from "../../../lib/event"; +import { LocationType } from "../../../lib/location"; +import merge from "lodash.merge"; const translator = short(); interface p { - location: string + location: string; } -const getLocationRequestFromIntegration = ({location}: p) => { +const getLocationRequestFromIntegration = ({ location }: p) => { if (location === LocationType.GoogleMeet.valueOf()) { - const requestId = uuidv5(location, uuidv5.URL) + const requestId = uuidv5(location, uuidv5.URL); return { conferenceData: { createRequest: { - requestId: requestId - } - } - } + requestId: requestId, + }, + }, + }; } - return null -} + return null; +}; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const {user} = req.query; + const { user } = req.query; const currentUser = await prisma.user.findFirst({ where: { @@ -44,27 +44,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) timeZone: true, email: true, name: true, - } + }, }); // Split credentials up into calendar credentials and video credentials - const calendarCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_calendar')); - const videoCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_video')); + const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")); + const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")); const rescheduleUid = req.body.rescheduleUid; const selectedEventType = await prisma.eventType.findFirst({ where: { userId: currentUser.id, - id: req.body.eventTypeId + id: req.body.eventTypeId, }, select: { eventName: true, - title: true - } + title: true, + }, }); - let rawLocation = req.body.location + const rawLocation = req.body.location; let evt: CalendarEvent = { type: selectedEventType.title, @@ -72,38 +72,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) description: req.body.notes, startTime: req.body.start, 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} - ] + organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, + attendees: [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }], }; // If phone or inPerson use raw location // set evt.location to req.body.location - if (!rawLocation?.includes('integration')) { - evt.location = rawLocation + 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')) { - let maybeLocationRequestObject = getLocationRequestFromIntegration({ - location: rawLocation - }) - - evt = merge(evt, maybeLocationRequestObject) + if (rawLocation?.includes("integration")) { + const maybeLocationRequestObject = getLocationRequestFromIntegration({ + location: rawLocation, + }); + + evt = merge(evt, maybeLocationRequestObject); } - + const eventType = await prisma.eventType.findFirst({ where: { userId: currentUser.id, - title: evt.type + title: evt.type, }, select: { - id: true - } + id: true, + }, }); let results = []; @@ -113,7 +110,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Reschedule event const booking = await prisma.booking.findFirst({ where: { - uid: rescheduleUid + uid: rescheduleUid, }, select: { id: true, @@ -121,35 +118,39 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) select: { id: true, type: true, - uid: true - } - } - } + uid: true, + }, + }, + }, }); // 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 => { - console.error("updateEvent failed", e) - return {type: credential.type, success: false} - }); - })); + 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) => { + console.error("updateEvent failed", e); + 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 => { - console.error("updateMeeting failed", e) - 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) => { + console.error("updateMeeting failed", e); + return { type: credential.type, success: false }; + }); + }) + ); - if (results.length > 0 && results.every(res => !res.success)) { - res.status(500).json({message: "Rescheduling failed"}); + if (results.length > 0 && results.every((res) => !res.success)) { + res.status(500).json({ message: "Rescheduling failed" }); return; } @@ -157,86 +158,88 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) referencesToCreate = [...booking.references]; // Now we can delete the old booking and its references. - let bookingReferenceDeletes = prisma.bookingReference.deleteMany({ + const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ where: { - bookingId: booking.id - } + bookingId: booking.id, + }, }); - let attendeeDeletes = prisma.attendee.deleteMany({ + const attendeeDeletes = prisma.attendee.deleteMany({ where: { - bookingId: booking.id - } + bookingId: booking.id, + }, }); - let bookingDeletes = prisma.booking.delete({ + const bookingDeletes = prisma.booking.delete({ where: { - uid: rescheduleUid - } + uid: rescheduleUid, + }, }); - await Promise.all([ - bookingReferenceDeletes, - attendeeDeletes, - bookingDeletes - ]); + 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 => { - console.error("createEvent failed", e) - return {type: credential.type, success: false} - }); - })); + results = results.concat( + await async.mapLimit(calendarCredentials, 5, async (credential) => { + return createEvent(credential, evt) + .then((response) => ({ type: credential.type, success: true, response })) + .catch((e) => { + console.error("createEvent failed", e); + 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 => { - console.error("createMeeting failed", e) - 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) => { + console.error("createMeeting failed", e); + return { type: credential.type, success: false }; + }); + }) + ); - if (results.length > 0 && results.every(res => !res.success)) { - res.status(500).json({message: "Booking failed"}); + if (results.length > 0 && results.every((res) => !res.success)) { + res.status(500).json({ message: "Booking failed" }); return; } - referencesToCreate = results.map((result => { + referencesToCreate = results.map((result) => { return { type: result.type, - uid: result.response.createdEvent.id.toString() + uid: result.response.createdEvent.id.toString(), }; - })); + }); } - const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); + const hashUID = + results.length > 0 + ? results[0].response.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) { + if (results.length === 0) { // Legacy as well, as soon as we have a separate email integration class. Just used // to send an email even if there is no integration at all. try { const mail = new EventAttendeeMail(evt, hashUID); await mail.sendEmail(); } catch (e) { - console.error("Sending legacy event mail failed", e) - res.status(500).json({message: "Booking failed"}); + console.error("Sending legacy event mail failed", e); + res.status(500).json({ message: "Booking failed" }); return; } } - let booking; try { - booking = await prisma.booking.create({ - data: { - uid: hashUID, - userId: currentUser.id, - references: { - create: referencesToCreate - }, - eventTypeId: eventType.id, + await prisma.booking.create({ + data: { + uid: hashUID, + userId: currentUser.id, + references: { + create: referencesToCreate, + }, + eventTypeId: eventType.id, title: evt.title, description: evt.description, @@ -244,13 +247,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) endTime: evt.endTime, attendees: { - create: evt.attendees - } - } + create: evt.attendees, + }, + }, }); } catch (e) { console.error("Error when saving booking to db", e); - res.status(500).json({message: "Booking already exists"}); + res.status(500).json({ message: "Booking already exists" }); return; }