From ded27d17ea764dad14754bb9958002e9b0cef8c1 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sun, 20 Jun 2021 17:33:02 +0200 Subject: [PATCH 01/32] - 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 fa358fbfa2..70624264cb 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 02/32] 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 647d633cb0..054bbd6699 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 aa57bd4806..9c6a88c46c 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 fb9165e9ad..1aeec75681 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 9c1f4f3e74..9f802bc797 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 03/32] 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 ff262819f2..1629beee1e 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 b359e83a96..0e171ac67a 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 9711f482b7..e5fcd285ba 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 0ea36cb3f887dc406412fb3bc30b8069e57f4318 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Tue, 22 Jun 2021 17:10:52 +0200 Subject: [PATCH 04/32] added edit/remove for customEventTypeInputs --- pages/api/availability/eventtype.ts | 6 ++++ pages/availability/event/[type].tsx | 52 ++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index a519a549a0..e50fc4abee 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -22,6 +22,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) customInputs: !req.body.customInputs ? undefined : { + deleteMany: { + eventTypeId: req.body.id, + NOT: { + id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)} + } + }, createMany: { data: req.body.customInputs.filter(input => !input.id).map(input => ({ type: input.type, diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index 1ad8824a22..1a1a7cd4a1 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -31,8 +31,9 @@ export default function EventType(props) { const [ showAddCustomModal, setShowAddCustomModal ] = useState(false); const [ selectedLocation, setSelectedLocation ] = useState(undefined); const [ selectedInputOption, setSelectedInputOption ] = useState(inputOptions[0]); + const [ selectedCustomInput, setSelectedCustomInput ] = useState(undefined); const [ locations, setLocations ] = useState(props.eventType.locations || []); - const [customInputs, setCustomInputs] = useState(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); + const [ customInputs, setCustomInputs ] = useState(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); const locationOptions = props.locationOptions const titleRef = useRef(); @@ -95,8 +96,15 @@ export default function EventType(props) { const closeAddCustomModal = () => { setSelectedInputOption(inputOptions[0]); setShowAddCustomModal(false); + setSelectedCustomInput(undefined); }; + const openEditCustomModel = (customInput: EventTypeCustomInput) => { + setSelectedCustomInput(customInput); + setSelectedInputOption(inputOptions.find(e => e.value === customInput.type)); + setShowAddCustomModal(true); + } + const LocationOptions = () => { if (!selectedLocation) { return null; @@ -160,12 +168,30 @@ export default function EventType(props) { type: e.target.type.value }; - setCustomInputs(customInputs.concat(customInput)); - - console.log(customInput) - setShowAddCustomModal(false); + if (!!e.target.id?.value) { + const index = customInputs.findIndex(inp => inp.id === +e.target.id?.value); + if (index >= 0) { + const input = customInputs[index]; + input.label = customInput.label; + input.required = customInput.required; + input.type = customInput.type; + setCustomInputs(customInputs); + } + } else{ + setCustomInputs(customInputs.concat(customInput)); + } + closeAddCustomModal(); }; + const removeCustom = (customInput, e) => { + e.preventDefault(); + const index = customInputs.findIndex(inp => inp.id === customInput.id); + if (index >= 0){ + customInputs.splice(index, 1); + setCustomInputs([...customInputs]); + } + } + return (
@@ -281,7 +307,7 @@ export default function EventType(props) {
+ ); +} diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx index 8b1110d0a1..bc6119379a 100644 --- a/components/ui/UsernameInput.tsx +++ b/components/ui/UsernameInput.tsx @@ -1,6 +1,6 @@ import React from "react"; -export const UsernameInput = React.forwardRef( (props, ref) => ( +const UsernameInput = React.forwardRef((props, ref) => ( // todo, check if username is already taken here?
-)); \ No newline at end of file +)); + +UsernameInput.displayName = "UsernameInput"; + +export { UsernameInput }; diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index a00f9efaf6..c8cc0586ab 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -1,140 +1,173 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import { useRef, useState } from 'react'; -import { useRouter } from 'next/router'; -import prisma from '../../lib/prisma'; -import Modal from '../../components/Modal'; -import Shell from '../../components/Shell'; -import SettingsShell from '../../components/Settings'; -import Avatar from '../../components/Avatar'; -import { signIn, useSession, getSession } from 'next-auth/client'; -import TimezoneSelect from 'react-timezone-select'; -import {UsernameInput} from "../../components/ui/UsernameInput"; +import { GetServerSideProps } from "next"; +import Head from "next/head"; +import { useRef, useState } from "react"; +import prisma from "../../lib/prisma"; +import Modal from "../../components/Modal"; +import Shell from "../../components/Shell"; +import SettingsShell from "../../components/Settings"; +import Avatar from "../../components/Avatar"; +import { getSession } from "next-auth/client"; +import TimezoneSelect from "react-timezone-select"; +import { UsernameInput } from "../../components/ui/UsernameInput"; import ErrorAlert from "../../components/ui/alerts/Error"; export default function Settings(props) { - const [ session, loading ] = useSession(); - const router = useRouter(); - const [successModalOpen, setSuccessModalOpen] = useState(false); - const usernameRef = useRef(); - const nameRef = useRef(); - const descriptionRef = useRef(); - const avatarRef = useRef(); + const [successModalOpen, setSuccessModalOpen] = useState(false); + const usernameRef = useRef(); + const nameRef = useRef(); + const descriptionRef = useRef(); + const avatarRef = useRef(); + const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone }); + const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday"); - const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone }); - const [ selectedWeekStartDay, setSelectedWeekStartDay ] = useState(props.user.weekStart || 'Sunday'); + const [hasErrors, setHasErrors] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); - const [ hasErrors, setHasErrors ] = useState(false); - const [ errorMessage, setErrorMessage ] = useState(''); + const closeSuccessModal = () => { + setSuccessModalOpen(false); + }; - if (loading) { - return

Loading...

; + const handleError = async (resp) => { + if (!resp.ok) { + const error = await resp.json(); + throw new Error(error.message); } + }; - const closeSuccessModal = () => { setSuccessModalOpen(false); } + async function updateProfileHandler(event) { + event.preventDefault(); - const handleError = async (resp) => { - if (!resp.ok) { - const error = await resp.json(); - throw new Error(error.message); - } - } + const enteredUsername = usernameRef.current.value.toLowerCase(); + const enteredName = nameRef.current.value; + const enteredDescription = descriptionRef.current.value; + const enteredAvatar = avatarRef.current.value; + const enteredTimeZone = selectedTimeZone.value; + const enteredWeekStartDay = selectedWeekStartDay; - async function updateProfileHandler(event) { - event.preventDefault(); + // TODO: Add validation - const enteredUsername = usernameRef.current.value; - const enteredName = nameRef.current.value; - const enteredDescription = descriptionRef.current.value; - const enteredAvatar = avatarRef.current.value; - const enteredTimeZone = selectedTimeZone.value; - const enteredWeekStartDay = selectedWeekStartDay; + await fetch("/api/user/profile", { + method: "PATCH", + body: JSON.stringify({ + username: enteredUsername, + name: enteredName, + description: enteredDescription, + avatar: enteredAvatar, + timeZone: enteredTimeZone, + weekStart: enteredWeekStartDay, + }), + headers: { + "Content-Type": "application/json", + }, + }) + .then(handleError) + .then(() => { + setSuccessModalOpen(true); + setHasErrors(false); // dismiss any open errors + }) + .catch((err) => { + setHasErrors(true); + setErrorMessage(err.message); + }); + } - // TODO: Add validation + return ( + + + Profile | Calendso + + + +
+ {hasErrors && } +
+
+

Profile

+

Review and change your public page details.

+
- const response = await fetch('/api/user/profile', { - method: 'PATCH', - body: JSON.stringify({username: enteredUsername, name: enteredName, description: enteredDescription, avatar: enteredAvatar, timeZone: enteredTimeZone, weekStart: enteredWeekStartDay}), - headers: { - 'Content-Type': 'application/json' - } - }).then(handleError).then( () => { - setSuccessModalOpen(true); - setHasErrors(false); // dismiss any open errors - }).catch( (err) => { - setHasErrors(true); - setErrorMessage(err.message); - }); - } +
+
+
+
+ +
+
+ + +
+
- return( - - - Profile | Calendso - - - - - {hasErrors && } -
-
-

Profile

-

- Review and change your public page details. -

-
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
-
-
-
-
- -
-
- - -
-
- -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- - {/*
+
+ +
+
+ + {/*
*/} -
-
+
+
-
-
} - /> - {/*
-
- - -
-
-
-
-
- -
-
- - -
-
- ); +
+
+ + +
+
+
+
+
+ +
+ + + +
+
+ ); } -export async function getServerSideProps(context) { - const session = await getSession(context); - if (!session) { - return { redirect: { permanent: false, destination: '/auth/login' } }; - } +export const getServerSideProps: GetServerSideProps = async (context) => { + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - username: true, - name: true, - email: true, - bio: true, - avatar: true, - timeZone: true, - weekStart: true, - } - }); + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + username: true, + name: true, + email: true, + bio: true, + avatar: true, + timeZone: true, + weekStart: true, + }, + }); - return { - props: {user}, // will be passed to the page component as props - } -} \ No newline at end of file + return { + props: { user }, // will be passed to the page component as props + }; +}; From 2f7e303bcfadd2a247b0969abe822e712e89fcaf Mon Sep 17 00:00:00 2001 From: Femi Odugbesan Date: Wed, 23 Jun 2021 14:15:52 -0500 Subject: [PATCH 12/32] Revert "dep: superjson, plays nice with prisma dates" (#305) --- .babelrc | 6 ------ package.json | 2 -- yarn.lock | 34 +--------------------------------- 3 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 .babelrc diff --git a/.babelrc b/.babelrc deleted file mode 100644 index dcaa928282..0000000000 --- a/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": ["next/babel"], - "plugins": [ - "superjson-next" - ] - } \ No newline at end of file diff --git a/package.json b/package.json index e2646ccf30..31e86ca42c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@prisma/client": "^2.23.0", "@tailwindcss/forms": "^0.2.1", "async": "^3.2.0", - "babel-plugin-superjson-next": "^0.3.0", "bcryptjs": "^2.4.3", "dayjs": "^1.10.4", "googleapis": "^67.1.1", @@ -34,7 +33,6 @@ "react-select": "^4.3.0", "react-timezone-select": "^1.0.2", "short-uuid": "^4.2.0", - "superjson": "^1.7.4", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index b8b8eb50d0..fd8a34d290 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,13 +16,6 @@ dependencies: "@babel/highlight" "^7.14.5" -"@babel/helper-module-imports@^7.13.12": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3" - integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ== - dependencies: - "@babel/types" "^7.14.5" - "@babel/helper-validator-identifier@^7.14.0": version "7.14.0" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz#d26cad8a47c65286b15df1547319a5d0bcf27288" @@ -74,14 +67,6 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" -"@babel/types@^7.13.17", "@babel/types@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.5.tgz#3bb997ba829a2104cedb20689c4a5b8121d383ff" - integrity sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg== - dependencies: - "@babel/helper-validator-identifier" "^7.14.5" - to-fast-properties "^2.0.0" - "@emotion/cache@^11.0.0", "@emotion/cache@^11.1.3": version "11.1.3" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.1.3.tgz#c7683a9484bcd38d5562f2b9947873cf66829afd" @@ -705,15 +690,6 @@ available-typed-arrays@^1.0.2: dependencies: array-filter "^1.0.0" -babel-plugin-superjson-next@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/babel-plugin-superjson-next/-/babel-plugin-superjson-next-0.3.0.tgz#86620a2d134a1b0a515d903512cf7f766b394cc1" - integrity sha512-fTE8uWUy9OJG7PkRNP094XDdieoBsZR2iatxurbNqLyd4INXpwuuQ246iLu4+dAeQHtGhOJZxsPR8KdbNvdnlQ== - dependencies: - "@babel/helper-module-imports" "^7.13.12" - "@babel/types" "^7.13.17" - hoist-non-react-statics "^3.3.2" - babel-plugin-syntax-jsx@6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" @@ -2090,7 +2066,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -4137,14 +4113,6 @@ stylis@^4.0.3: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.10.tgz#446512d1097197ab3f02fb3c258358c3f7a14240" integrity sha512-m3k+dk7QeJw660eIKRRn3xPF6uuvHs/FFzjX3HQ5ove0qYsiygoAhwn5a3IYKaZPo5LrYD0rfVmtv1gNY1uYwg== -superjson@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.7.4.tgz#76ba9091e74c93ce6f61914b58854ee0b1ba37b9" - integrity sha512-A6DYTe04+x4L9NPywHeGZNy6/gLe8qqKCwhEfTH9M4eXpTjiTsF83JZ3j4hwXx1ogRb4779nWxsDlJGIECOJkw== - dependencies: - debug "^4.3.1" - lodash.clonedeep "^4.5.0" - supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" From 2ccbd61a31e5d828f52e8e3366468b142b85c157 Mon Sep 17 00:00:00 2001 From: femyeda Date: Wed, 23 Jun 2021 16:11:38 -0500 Subject: [PATCH 13/32] dep: handlebars --- package.json | 1 + yarn.lock | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 31e86ca42c..af11fce669 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "bcryptjs": "^2.4.3", "dayjs": "^1.10.4", "googleapis": "^67.1.1", + "handlebars": "^4.7.7", "ics": "^2.27.0", "lodash.merge": "^4.6.2", "next": "^10.2.0", diff --git a/yarn.lock b/yarn.lock index fd8a34d290..17d976cf78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1996,6 +1996,18 @@ gtoken@^5.0.4: google-p12-pem "^3.0.3" jws "^4.0.0" +handlebars@^4.7.7: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -2789,7 +2801,7 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@^1.1.1, minimist@^1.2.0: +minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -2850,6 +2862,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + next-auth@^3.13.2: version "3.19.8" resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7" @@ -4342,6 +4359,11 @@ typescript@^4.2.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== +uglify-js@^3.1.4: + version "3.13.9" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.9.tgz#4d8d21dcd497f29cfd8e9378b9df123ad025999b" + integrity sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g== + unbox-primitive@^1.0.0, unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" @@ -4498,6 +4520,11 @@ word-wrap@^1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" From 173d4cda773b34dd653afc0d40c90b88b05281db Mon Sep 17 00:00:00 2001 From: femyeda Date: Wed, 23 Jun 2021 16:16:41 -0500 Subject: [PATCH 14/32] dep: nodemailer types --- package.json | 1 + yarn.lock | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/package.json b/package.json index af11fce669..2bec1c87fd 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@types/node": "^14.14.33", + "@types/nodemailer": "^6.4.2", "@types/react": "^17.0.3", "@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/parser": "^4.27.0", diff --git a/yarn.lock b/yarn.lock index 17d976cf78..c2f035e991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -341,6 +341,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215" integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA== +"@types/nodemailer@^6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.2.tgz#d8ee254c969e6ad83fb9a0a0df3a817406a3fa3b" + integrity sha512-yhsqg5Xbr8aWdwjFS3QjkniW5/tLpWXtOYQcJdo9qE3DolBxsKzgRCQrteaMY0hos8MklJNSEsMqDpZynGzMNg== + dependencies: + "@types/node" "*" + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" From 20cd3bc52e70b643d7793d5b81be81c1ce9b360d Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 23 Jun 2021 23:45:07 +0100 Subject: [PATCH 15/32] minor change to reschedule info in email --- lib/emails/EventMail.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index 2d4d8489bd..b76bb5a1be 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -127,7 +127,8 @@ export default abstract class EventMail { protected getAdditionalFooter(): string { return `
- Need to change this event?
+
+ Need to change this event?
Cancel: ${this.getCancelLink()}
Reschedule: ${this.getRescheduleLink()} `; From ebb3e87284a03ccf14d74c8408f8e8cf2279ef1c Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 24 Jun 2021 14:36:31 +0100 Subject: [PATCH 16/32] Add bookings page --- components/Shell.tsx | 13 ++++- pages/bookings/index.tsx | 121 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 pages/bookings/index.tsx diff --git a/components/Shell.tsx b/components/Shell.tsx index e51ff8149e..57db5acc51 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -59,9 +59,16 @@ export default function Shell(props) { Dashboard - {/* - Bookings - */} + + + Bookings + + Loading...

; + } + + return ( +
+ + Bookings | Calendso + + + +
+
+
+
+ + + + + + + + + + + + {bookings.map((booking) => ( + + + + + + + + ))} + +
+ Title + + Description + + Name + + Email + + Edit +
+ {booking.title} + + {booking.description} + + {booking.attendees[0].name} + + {booking.attendees[0].email} + + + Reschedule + + + Cancel + +
+
+
+
+
+
+
+ ); +} + +export async function getServerSideProps(context) { + const session = await getSession(context); + + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } + + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + }, + }); + + const bookings = await prisma.booking.findMany({ + where: { + userId: user.id, + }, + select: { + uid: true, + title: true, + description: true, + attendees: true, + }, + }); + + return { props: { bookings } }; +} From 5334c350e20569ba24bef686428bc279b5f149a4 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 24 Jun 2021 14:46:35 +0100 Subject: [PATCH 17/32] The page was being rendered during the async router.replace call (#306) * The page was being rendered during the async router.replace call * Adding a different, slightly smaller fix --- components/Shell.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/components/Shell.tsx b/components/Shell.tsx index 57db5acc51..59cb8cf335 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -32,11 +32,9 @@ export default function Shell(props) { if (!loading && !session) { router.replace("/auth/login"); - } else if (loading) { - return

Loading...

; } - return ( + return session ? (
- ); + ) : null; } From a1ddb873f0ae338e77b951c0b533c5efc0af6ecc Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 23 Jun 2021 23:45:07 +0100 Subject: [PATCH 18/32] minor change to reschedule info in email --- lib/emails/EventMail.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index 2d4d8489bd..b76bb5a1be 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -127,7 +127,8 @@ export default abstract class EventMail { protected getAdditionalFooter(): string { return `
- Need to change this event?
+
+ Need to change this event?
Cancel:
${this.getCancelLink()}
Reschedule: ${this.getRescheduleLink()} `; From ea5692c20cfd65d81c09b5dcc4ca7721a30610ab Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 24 Jun 2021 14:36:31 +0100 Subject: [PATCH 19/32] Add bookings page --- components/Shell.tsx | 13 ++++- pages/bookings/index.tsx | 121 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 pages/bookings/index.tsx diff --git a/components/Shell.tsx b/components/Shell.tsx index e51ff8149e..57db5acc51 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -59,9 +59,16 @@ export default function Shell(props) { Dashboard - {/* - Bookings - */} + + + Bookings + + Loading...

; + } + + return ( +
+ + Bookings | Calendso + + + +
+
+
+
+ + + + + + + + + + + + {bookings.map((booking) => ( + + + + + + + + ))} + +
+ Title + + Description + + Name + + Email + + Edit +
+ {booking.title} + + {booking.description} + + {booking.attendees[0].name} + + {booking.attendees[0].email} + + + Reschedule + + + Cancel + +
+
+
+
+
+
+
+ ); +} + +export async function getServerSideProps(context) { + const session = await getSession(context); + + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } + + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + }, + }); + + const bookings = await prisma.booking.findMany({ + where: { + userId: user.id, + }, + select: { + uid: true, + title: true, + description: true, + attendees: true, + }, + }); + + return { props: { bookings } }; +} From ed9245112667868b068aca93d64c9b31a879bf58 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 24 Jun 2021 14:46:35 +0100 Subject: [PATCH 20/32] The page was being rendered during the async router.replace call (#306) * The page was being rendered during the async router.replace call * Adding a different, slightly smaller fix --- components/Shell.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/components/Shell.tsx b/components/Shell.tsx index 57db5acc51..59cb8cf335 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -32,11 +32,9 @@ export default function Shell(props) { if (!loading && !session) { router.replace("/auth/login"); - } else if (loading) { - return

Loading...

; } - return ( + return session ? (
- ); + ) : null; } From ab1298e2caa3fd28118f23bf7daad5430984fdb8 Mon Sep 17 00:00:00 2001 From: femyeda Date: Thu, 24 Jun 2021 10:59:11 -0500 Subject: [PATCH 21/32] Allow user to reset password --- lib/emails/buildMessageTemplate.ts | 19 ++ lib/emails/sendMail.ts | 30 +++ package.json | 1 + pages/api/auth/forgot-password.ts | 77 ++++++ pages/api/auth/reset-password.ts | 60 +++++ pages/auth/forgot-password/[id].tsx | 231 ++++++++++++++++++ pages/auth/forgot-password/index.tsx | 153 ++++++++++++ pages/auth/login.tsx | 112 +++++---- prisma/schema.prisma | 8 + .../messaging/forgot-password.ts | 20 ++ yarn.lock | 5 + 11 files changed, 672 insertions(+), 44 deletions(-) create mode 100644 lib/emails/buildMessageTemplate.ts create mode 100644 lib/emails/sendMail.ts create mode 100644 pages/api/auth/forgot-password.ts create mode 100644 pages/api/auth/reset-password.ts create mode 100644 pages/auth/forgot-password/[id].tsx create mode 100644 pages/auth/forgot-password/index.tsx create mode 100644 src/forgot-password/messaging/forgot-password.ts diff --git a/lib/emails/buildMessageTemplate.ts b/lib/emails/buildMessageTemplate.ts new file mode 100644 index 0000000000..2d3f0696b6 --- /dev/null +++ b/lib/emails/buildMessageTemplate.ts @@ -0,0 +1,19 @@ +import Handlebars from "handlebars"; + +export const buildMessageTemplate = ({ + messageTemplate, + subjectTemplate, + vars, +}): { subject: string; message: string } => { + const buildMessage = Handlebars.compile(messageTemplate); + const message = buildMessage(vars); + + const buildSubject = Handlebars.compile(subjectTemplate); + const subject = buildSubject(vars); + return { + subject, + message, + }; +}; + +export default buildMessageTemplate; diff --git a/lib/emails/sendMail.ts b/lib/emails/sendMail.ts new file mode 100644 index 0000000000..917a7308e7 --- /dev/null +++ b/lib/emails/sendMail.ts @@ -0,0 +1,30 @@ +import { serverConfig } from "../serverConfig"; +import nodemailer, { SentMessageInfo } from "nodemailer"; + +const sendEmail = ({ to, subject, text, html = null }): Promise => + new Promise((resolve, reject) => { + const { transport, from } = serverConfig; + + if (!to || !subject || (!text && !html)) { + return reject("Missing required elements to send email."); + } + + nodemailer.createTransport(transport).sendMail( + { + from: `Calendso ${from}`, + to, + subject, + text, + html, + }, + (error, info) => { + if (error) { + console.error("SEND_INVITATION_NOTIFICATION_ERROR", to, error); + return reject(error.message); + } + return resolve(info); + } + ); + }); + +export default sendEmail; diff --git a/package.json b/package.json index 2bec1c87fd..c59a83a0e4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "googleapis": "^67.1.1", "handlebars": "^4.7.7", "ics": "^2.27.0", + "lodash.debounce": "^4.0.8", "lodash.merge": "^4.6.2", "next": "^10.2.0", "next-auth": "^3.13.2", diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts new file mode 100644 index 0000000000..eb5ceb0d0b --- /dev/null +++ b/pages/api/auth/forgot-password.ts @@ -0,0 +1,77 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import prisma from "../../../lib/prisma"; +import dayjs from "dayjs"; +import { User, ResetPasswordRequest } from "@prisma/client"; +import sendEmail from "../../../lib/emails/sendMail"; +import { buildForgotPasswordMessage } from "../../../src/forgot-password/messaging/forgot-password"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +dayjs.extend(utc); +dayjs.extend(timezone); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + return res.status(400).json({ message: "" }); + } + + try { + const rawEmail = req.body?.email; + + const maybeUser: User = await prisma.user.findUnique({ + where: { + email: rawEmail, + }, + select: { + name: true, + }, + }); + + if (!maybeUser) { + return res.status(400).json({ message: "Couldn't find an account for this email" }); + } + + const now = dayjs().toDate(); + const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({ + where: { + email: rawEmail, + expires: { + gt: now, + }, + }, + }); + + let passwordRequest: ResetPasswordRequest; + + if (maybePreviousRequest && maybePreviousRequest?.length >= 1) { + passwordRequest = maybePreviousRequest[0]; + } else { + const expiry = dayjs().tz(maybeUser.timeZone).add(6, "hours").toDate(); + const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({ + data: { + email: rawEmail, + expires: expiry, + }, + }); + passwordRequest = createdResetPasswordRequest; + } + + const passwordResetLink = `${process.env.BASE_URL}/auth/reset-password/${passwordRequest.id}`; + const { subject, message } = buildForgotPasswordMessage({ + user: { + name: maybeUser.name, + }, + link: passwordResetLink, + }); + + await sendEmail({ + to: rawEmail, + subject: subject, + text: message, + }); + + return res.status(201).json({ message: "Reset Requested", data: passwordRequest }); + } catch (reason) { + console.error(reason); + return res.status(500).json({ message: "Unable to create password reset request" }); + } +} diff --git a/pages/api/auth/reset-password.ts b/pages/api/auth/reset-password.ts new file mode 100644 index 0000000000..f43b93ca16 --- /dev/null +++ b/pages/api/auth/reset-password.ts @@ -0,0 +1,60 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import prisma from "../../../lib/prisma"; +import dayjs from "dayjs"; +import { User, ResetPasswordRequest } from "@prisma/client"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +dayjs.extend(utc); +dayjs.extend(timezone); +import { hashPassword } from "../../../lib/auth"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + return res.status(400).json({ message: "" }); + } + + try { + const rawPassword = req.body?.password; + const rawRequestId = req.body?.requestId; + + if (!rawPassword || !rawRequestId) { + return res.status(400).json({ message: "Couldn't find an account for this email" }); + } + + const maybeRequest: ResetPasswordRequest = await prisma.resetPasswordRequest.findUnique({ + where: { + id: rawRequestId, + }, + }); + + if (!maybeRequest) { + return res.status(400).json({ message: "Couldn't find an account for this email" }); + } + + const maybeUser: User = await prisma.user.findUnique({ + where: { + email: maybeRequest.email, + }, + }); + + if (!maybeUser) { + return res.status(400).json({ message: "Couldn't find an account for this email" }); + } + + const hashedPassword = await hashPassword(rawPassword); + + await prisma.user.update({ + where: { + id: maybeUser.id, + }, + data: { + password: hashedPassword, + }, + }); + + return res.status(201).json({ message: "Password reset." }); + } catch (reason) { + console.error(reason); + return res.status(500).json({ message: "Unable to create password reset request" }); + } +} diff --git a/pages/auth/forgot-password/[id].tsx b/pages/auth/forgot-password/[id].tsx new file mode 100644 index 0000000000..48c5824bf9 --- /dev/null +++ b/pages/auth/forgot-password/[id].tsx @@ -0,0 +1,231 @@ +import { getCsrfToken } from "next-auth/client"; +import prisma from "../../../lib/prisma"; + +import Head from "next/head"; +import React from "react"; +import debounce from "lodash.debounce"; +import dayjs from "dayjs"; +import { ResetPasswordRequest } from "@prisma/client"; +import { useMemo } from "react"; +import Link from "next/link"; +import { GetServerSidePropsContext } from "next"; + +type Props = { + id: string; + resetPasswordRequest: ResetPasswordRequest; + csrfToken: string; +}; + +export default function Page({ resetPasswordRequest, csrfToken }: Props) { + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + + const [password, setPassword] = React.useState(""); + const handleChange = (e) => { + setPassword(e.target.value); + }; + + const submitChangePassword = async ({ password, requestId }) => { + try { + const res = await fetch("/api/auth/reset-password", { + method: "POST", + body: JSON.stringify({ requestId: requestId, password: password }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + + if (!res.ok) { + setError(json); + } else { + setSuccess(true); + } + + return json; + } catch (reason) { + setError({ message: "An unexpected error occurred. Try again." }); + } finally { + setLoading(false); + } + }; + + const debouncedChangePassword = debounce(submitChangePassword, 250); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!password) { + return; + } + + if (loading) { + return; + } + + setLoading(true); + setError(null); + setSuccess(false); + + await debouncedChangePassword({ password, requestId: resetPasswordRequest.id }); + }; + + const Success = () => { + return ( + <> +
+
+

Success

+
+

Your password has been reset. You can now login with your newly created password.

+ + + +
+ + ); + }; + + const Expired = () => { + return ( + <> +
+
+

Whoops

+

That Request is Expired.

+
+

+ That request is expired. You can back and enter the email associated with your account and we will + you another link to reset your password. +

+ + + +
+ + ); + }; + + const isRequestExpired = useMemo(() => { + const now = dayjs(); + return dayjs(resetPasswordRequest.expires).isBefore(now); + }, [resetPasswordRequest]); + + return ( +
+ + Reset Password + + +
+
+ {isRequestExpired && } + {!isRequestExpired && !success && ( + <> +
+

Reset Password

+

Enter the new password you'd like for your account.

+ {error &&

{error.message}

} +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + )} + {!isRequestExpired && success && ( + <> + + + )} +
+
+
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const id = context.params.id; + + try { + const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({ + where: { + id: id, + }, + select: { + id: true, + expires: true, + }, + }); + + return { + props: { + resetPasswordRequest: { + ...resetPasswordRequest, + expires: resetPasswordRequest.expires.toString(), + }, + id, + csrfToken: await getCsrfToken({ req: context.req }), + }, + }; + } catch (reason) { + return { + notFound: true, + }; + } +} diff --git a/pages/auth/forgot-password/index.tsx b/pages/auth/forgot-password/index.tsx new file mode 100644 index 0000000000..5760de01a9 --- /dev/null +++ b/pages/auth/forgot-password/index.tsx @@ -0,0 +1,153 @@ +import Head from "next/head"; +import React from "react"; +import { getCsrfToken } from "next-auth/client"; +import debounce from "lodash.debounce"; + +export default function Page({ csrfToken }) { + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + const [email, setEmail] = React.useState(""); + + const handleChange = (e) => { + setEmail(e.target.value); + }; + + const submitForgotPasswordRequest = async ({ email }) => { + try { + const res = await fetch("/api/auth/forgot-password", { + method: "POST", + body: JSON.stringify({ email: email }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + if (!res.ok) { + setError(json); + } else { + setSuccess(true); + } + + return json; + } catch (reason) { + setError({ message: "An unexpected error occurred. Try again." }); + } finally { + setLoading(false); + } + }; + + const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!email) { + return; + } + + if (loading) { + return; + } + + setLoading(true); + setError(null); + setSuccess(false); + + await debouncedHandleSubmitPasswordRequest({ email }); + }; + + const Success = () => { + return ( +
+

Done

+

Check your email. We sent you a link to reset your password.

+ {error &&

{error.message}

} +
+ ); + }; + + return ( +
+ + Forgot Password + + + +
+
+ {success && } + {!success && ( + <> +
+

Forgot Password

+

+ Enter the email address associated with your account and we will send you a link to reset + your password. +

+ {error &&

{error.message}

} +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + )} +
+
+
+ ); +} + +Page.getInitialProps = async ({ req }) => { + return { + csrfToken: await getCsrfToken({ req }), + }; +}; diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx index 72e0c516e2..76514aa45b 100644 --- a/pages/auth/login.tsx +++ b/pages/auth/login.tsx @@ -1,55 +1,79 @@ -import Head from 'next/head'; -import { getCsrfToken } from 'next-auth/client'; +import Head from "next/head"; +import Link from "next/link"; +import { getCsrfToken } from "next-auth/client"; export default function Login({ csrfToken }) { return (
- - Login - - -
-

- Sign in to your account -

-
+ + Login + + +
+

Sign in to your account

+
-
-
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
-
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ + + + +
+
+
- ) + ); } -Login.getInitialProps = async ({ req, res }) => { +Login.getInitialProps = async ({ req }) => { return { - csrfToken: await getCsrfToken({ req }) - } -} \ No newline at end of file + csrfToken: await getCsrfToken({ req }), + }; +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b33ce753c..491fd909e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -142,3 +142,11 @@ model EventTypeCustomInput { required Boolean } +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + diff --git a/src/forgot-password/messaging/forgot-password.ts b/src/forgot-password/messaging/forgot-password.ts new file mode 100644 index 0000000000..625d9f6022 --- /dev/null +++ b/src/forgot-password/messaging/forgot-password.ts @@ -0,0 +1,20 @@ +import buildMessageTemplate from "../../../lib/emails/buildMessageTemplate"; + +export const forgotPasswordSubjectTemplate = "Forgot your password? - Calendso"; + +export const forgotPasswordMessageTemplate = `Hey there, + +Use the link below to reset your password. +{{link}} + +p.s. It expires in 6 hours. + +- Calendso`; + +export const buildForgotPasswordMessage = (vars) => { + return buildMessageTemplate({ + subjectTemplate: forgotPasswordSubjectTemplate, + messageTemplate: forgotPasswordMessageTemplate, + vars, + }); +}; diff --git a/yarn.lock b/yarn.lock index c2f035e991..e5956ac5d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2637,6 +2637,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" From 646ff4a1079030f631997231efbc64949088d1ef Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Thu, 24 Jun 2021 18:12:22 +0200 Subject: [PATCH 22/32] 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 d44465d094..2e1f5f3ed4 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 b830fdfbfe..4d8d7421b6 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 386f59ab4e..db82b3e66c 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; } From 2f20cf2968248cf5afe6ed36a5b551665eeb941c Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Thu, 24 Jun 2021 19:30:39 +0200 Subject: [PATCH 23/32] made sendEmail async --- lib/emails/EventMail.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index b76bb5a1be..de1c050732 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -58,7 +58,7 @@ export default abstract class EventMail { * Sends the email to the event attendant and returns a Promise. */ public sendEmail(): Promise { - return new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail( + new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail( this.getNodeMailerPayload(), (error, info) => { if (error) { @@ -67,7 +67,9 @@ export default abstract class EventMail { } else { resolve(info); } - })); + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); } /** @@ -133,4 +135,4 @@ export default abstract class EventMail { Reschedule: ${this.getRescheduleLink()} `; } -} \ No newline at end of file +} From 9c3eea525753d99639296d7353c7a941a301b959 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Thu, 24 Jun 2021 20:41:26 +0200 Subject: [PATCH 24/32] booking hotfix --- pages/[user]/book.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index 0266ef6211..b44a91f81f 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -101,7 +101,8 @@ export default function Book(props) { } telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())); - const res = await fetch( + + /*const res = await */fetch( '/api/book/' + user, { body: JSON.stringify(payload), @@ -111,8 +112,8 @@ export default function Book(props) { method: 'POST' } ); - - if (res.ok) { + // TODO When the endpoint is fixed, change this to await the result again + //if (res.ok) { let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`; if (payload['location']) { if (payload['location'].includes('integration')) { @@ -124,10 +125,10 @@ export default function Book(props) { } await router.push(successUrl); - } else { + /*} else { setLoading(false); setError(true); - } + }*/ } event.preventDefault(); From 3c8b9da54d5bb1ba2e7941232d423656a6977fdb Mon Sep 17 00:00:00 2001 From: femyeda Date: Thu, 24 Jun 2021 15:00:09 -0500 Subject: [PATCH 25/32] fix: busy times are shown on booking --- components/booking/AvailableTimes.tsx | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 2e1f5f3ed4..6f4e413033 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import isBetween from "dayjs/plugin/isBetween"; dayjs.extend(isBetween); -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import getSlots from "../../lib/slots"; import Link from "next/link"; import { timeZone } from "../../lib/clock"; @@ -14,14 +14,18 @@ const AvailableTimes = (props) => { 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, - }); + const times = useMemo(() => { + const slots = getSlots({ + calendarTimeZone: props.user.timeZone, + selectedTimeZone: timeZone(), + eventLength: props.eventType.length, + selectedDate: props.date, + dayStartTime: props.user.startTime, + dayEndTime: props.user.endTime, + }); + + return slots; + }, [props.date]); const handleAvailableSlots = (busyTimes: []) => { // Check for conflicts @@ -80,6 +84,7 @@ const AvailableTimes = (props) => {
{!error && loaded && + times.length > 0 && times.map((time) => (
{
))} + {!error && loaded && times.length == 0 && ( +
+

{props.user.name} is all booked today.

+
+ )} {!error && !loaded &&
} {error && (
From a53cdf266049f649f9e37f7556a082bd75d6986f Mon Sep 17 00:00:00 2001 From: femyeda Date: Thu, 24 Jun 2021 16:20:16 -0500 Subject: [PATCH 26/32] fix: issue where user could book a meeting when unavailable --- pages/api/book/[user].ts | 80 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index db82b3e66c..1140a653a1 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,21 +1,53 @@ import type { NextApiRequest, NextApiResponse } from "next"; import prisma from "../../../lib/prisma"; -import { CalendarEvent, createEvent, updateEvent } from "../../../lib/calendarClient"; +import { CalendarEvent, createEvent, updateEvent, getBusyCalendarTimes } 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 { createMeeting, updateMeeting, 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"; const translator = short(); +import dayjs from "dayjs"; -interface p { +const isAvailable = (busyTimes, time, length) => { + // Check for conflicts + let t = true; + busyTimes.forEach((busyTime) => { + const startTime = dayjs(busyTime.start); + const endTime = dayjs(busyTime.end); + + // Check if start times are the same + if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) { + t = false; + } + + // Check if time is between start and end times + if (dayjs(time).isBetween(startTime, endTime)) { + t = false; + } + + // Check if slot end time is between start and end time + if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) { + t = false; + } + + // Check if startTime is between slot + if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) { + t = false; + } + }); + + return t; +}; + +interface GetLocationRequestFromIntegrationRequest { location: string; } -const getLocationRequestFromIntegration = ({ location }: p) => { +const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromIntegrationRequest) => { if (location === LocationType.GoogleMeet.valueOf()) { const requestId = uuidv5(location, uuidv5.URL); @@ -47,10 +79,43 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + const selectedCalendars = await prisma.selectedCalendar.findMany({ + where: { + userId: currentUser.id, + }, + }); // 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 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( + currentUser.credentials, + dayjs(req.body.start).startOf("day").utc().format(), + dayjs(req.body.end).endOf("day").utc().format(), + selectedCalendars + ); + const videoAvailability = await getBusyVideoTimes( + currentUser.credentials, + dayjs(req.body.start).startOf("day").utc().format(), + dayjs(req.body.end).endOf("day").utc().format() + ); + let commonAvailability = []; + + if (hasCalendarIntegrations && hasVideoIntegrations) { + commonAvailability = calendarAvailability.filter((availability) => + videoAvailability.includes(availability) + ); + } else if (hasVideoIntegrations) { + commonAvailability = videoAvailability; + } else if (hasCalendarIntegrations) { + commonAvailability = calendarAvailability; + } + const rescheduleUid = req.body.rescheduleUid; const selectedEventType = await prisma.eventType.findFirst({ @@ -61,6 +126,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) select: { eventName: true, title: true, + length: true, }, }); @@ -103,6 +169,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + const isAvailableToBeBooked = isAvailable(commonAvailability, req.body.start, selectedEventType.length); + + if (!isAvailableToBeBooked) { + return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` }); + } + let results = []; let referencesToCreate = []; From bc47975316956e7f7b5be7726d13eaf6539ecc93 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 25 Jun 2021 00:26:55 +0200 Subject: [PATCH 27/32] Added zoom as an event location and fixed ESLint --- lib/location.ts | 9 +- pages/[user]/book.tsx | 673 +++++++++------- pages/availability/event/[type].tsx | 1130 ++++++++++++++++----------- 3 files changed, 1048 insertions(+), 764 deletions(-) diff --git a/lib/location.ts b/lib/location.ts index b27f497702..3bd8f71fcf 100644 --- a/lib/location.ts +++ b/lib/location.ts @@ -1,7 +1,6 @@ - export enum LocationType { - InPerson = 'inPerson', - Phone = 'phone', - GoogleMeet = 'integrations:google:meet' + InPerson = "inPerson", + Phone = "phone", + GoogleMeet = "integrations:google:meet", + Zoom = "integrations:zoom", } - diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index b44a91f81f..ccf2844ca0 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -1,316 +1,415 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import {useRouter} from 'next/router'; -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"; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; -import 'react-phone-number-input/style.css'; -import PhoneInput from 'react-phone-number-input'; -import {LocationType} from '../../lib/location'; -import Avatar from '../../components/Avatar'; -import Button from '../../components/ui/Button'; -import {EventTypeCustomInputType} from "../../lib/eventTypeInput"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +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"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import "react-phone-number-input/style.css"; +import PhoneInput from "react-phone-number-input"; +import { LocationType } from "../../lib/location"; +import Avatar from "../../components/Avatar"; +import Button from "../../components/ui/Button"; +import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; dayjs.extend(utc); dayjs.extend(timezone); -export default function Book(props) { - const router = useRouter(); - const { date, user, rescheduleUid } = router.query; +export default function Book(props: any): JSX.Element { + const router = useRouter(); + const { date, user, rescheduleUid } = router.query; - const [ is24h, setIs24h ] = useState(false); - const [ preferredTimeZone, setPreferredTimeZone ] = useState(''); - const [ loading, setLoading ] = useState(false); - const [ error, setError ] = useState(false); + const [is24h, setIs24h] = useState(false); + const [preferredTimeZone, setPreferredTimeZone] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); - const locations = props.eventType.locations || []; + const locations = props.eventType.locations || []; - const [ selectedLocation, setSelectedLocation ] = useState(locations.length === 1 ? locations[0].type : ''); - const telemetry = useTelemetry(); - useEffect(() => { + const [selectedLocation, setSelectedLocation] = useState( + locations.length === 1 ? locations[0].type : "" + ); + const telemetry = useTelemetry(); + useEffect(() => { + setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()); + setIs24h(!!localStorage.getItem("timeOption.is24hClock")); - setPreferredTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess()); - setIs24h(!!localStorage.getItem('timeOption.is24hClock')); + telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); + }); - telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); - }); + const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); - const locationInfo = (type: LocationType) => locations.find( - (location) => location.type === type - ); + // TODO: Move to translations + const locationLabels = { + [LocationType.InPerson]: "In-person meeting", + [LocationType.Phone]: "Phone call", + [LocationType.GoogleMeet]: "Google Meet", + [LocationType.Zoom]: "Zoom Video", + }; - // TODO: Move to translations - const locationLabels = { - [LocationType.InPerson]: 'In-person meeting', - [LocationType.Phone]: 'Phone call', - [LocationType.GoogleMeet]: 'Google Meet', - }; - - const bookingHandler = event => { - const book = async () => { - setLoading(true); - setError(false); - let notes = ""; - if (props.eventType.customInputs) { - notes = props.eventType.customInputs.map(input => { - const data = event.target["custom_" + input.id]; - if (!!data) { - if (input.type === EventTypeCustomInputType.Bool) { - return input.label + "\n" + (data.value ? "Yes" : "No") - } else { - return input.label + "\n" + data.value - } - } - }).join("\n\n") - } - if (!!notes && !!event.target.notes.value) { - notes += "\n\nAdditional notes:\n" + event.target.notes.value; - } else { - notes += event.target.notes.value; - } - - 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: notes, - timeZone: preferredTimeZone, - eventTypeId: props.eventType.id, - rescheduleUid: rescheduleUid - }; - - if (selectedLocation) { - switch (selectedLocation) { - case LocationType.Phone: - payload['location'] = event.target.phone.value - break - - case LocationType.InPerson: - payload['location'] = locationInfo(selectedLocation).address - break - - case LocationType.GoogleMeet: - payload['location'] = LocationType.GoogleMeet - break - } - } - - 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' + const bookingHandler = (event) => { + const book = async () => { + setLoading(true); + setError(false); + let notes = ""; + if (props.eventType.customInputs) { + notes = props.eventType.customInputs + .map((input) => { + const data = event.target["custom_" + input.id]; + if (data) { + if (input.type === EventTypeCustomInputType.Bool) { + return input.label + "\n" + (data.value ? "Yes" : "No"); + } else { + return input.label + "\n" + data.value; } - ); - // TODO When the endpoint is fixed, change this to await the result again - //if (res.ok) { - let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`; - if (payload['location']) { - if (payload['location'].includes('integration')) { - successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow."); - } - else { - successUrl += "&location=" + encodeURIComponent(payload['location']); - } - } + } + }) + .join("\n\n"); + } + if (!!notes && !!event.target.notes.value) { + notes += "\n\nAdditional notes:\n" + event.target.notes.value; + } else { + notes += event.target.notes.value; + } - await router.push(successUrl); - /*} else { + const 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: notes, + timeZone: preferredTimeZone, + eventTypeId: props.eventType.id, + rescheduleUid: rescheduleUid, + }; + + if (selectedLocation) { + switch (selectedLocation) { + case LocationType.Phone: + payload["location"] = event.target.phone.value; + break; + + case LocationType.InPerson: + payload["location"] = locationInfo(selectedLocation).address; + break; + + // Catches all other location types, such as Google Meet, Zoom etc. + default: + payload["location"] = selectedLocation; + } + } + + 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", + }); + // TODO When the endpoint is fixed, change this to await the result again + //if (res.ok) { + let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${ + props.user.username + }&reschedule=${!!rescheduleUid}&name=${payload.name}`; + if (payload["location"]) { + if (payload["location"].includes("integration")) { + successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow."); + } else { + successUrl += "&location=" + encodeURIComponent(payload["location"]); + } + } + + await router.push(successUrl); + /*} else { setLoading(false); setError(true); }*/ - } + }; - event.preventDefault(); - book(); - } + event.preventDefault(); + book(); + }; - return ( -
- - {rescheduleUid ? 'Reschedule' : 'Confirm'} your {props.eventType.title} with {props.user.name || props.user.username} | Calendso - - + return ( +
+ + + {rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with{" "} + {props.user.name || props.user.username} | Calendso + + + -
-
-
-
- -

{props.user.name}

-

{props.eventType.title}

-

- - {props.eventType.length} minutes -

- {selectedLocation === LocationType.InPerson &&

- - {locationInfo(selectedLocation).address} -

} -

- - {preferredTimeZone && dayjs(date).tz(preferredTimeZone).format( (is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")} -

-

{props.eventType.description}

-
-
-
-
- -
- -
-
-
- -
- -
-
- {locations.length > 1 && ( -
- Location - {locations.map( (location) => ( - - ))} -
- )} - {selectedLocation === LocationType.Phone && (
- -
- {}} /> -
-
)} - {props.eventType.customInputs && props.eventType.customInputs.sort((a,b) => a.id - b.id).map(input => ( -
- {input.type !== EventTypeCustomInputType.Bool && - } - {input.type === EventTypeCustomInputType.TextLong && -
-
- -
- +
+
+ +
+ +
+ minutes
-
- -
- -
- minutes -
-
+
+
+ +
+
-
- -
- -
-
-
- -
    - {customInputs.map( (customInput) => ( -
  • -
    +
    +
    + +
      + {customInputs.map((customInput) => ( +
    • +
      +
      -
      - Label: {customInput.label} -
      -
      - Type: {customInput.type} -
      -
      - {customInput.required ? "Required" : "Optional"} -
      + Label: {customInput.label}
      -
      - - +
      + Type: {customInput.type} +
      +
      + + {customInput.required ? "Required" : "Optional"} +
      -
    • - ))} -
    • - +
      + + +
      +
  • -
-
-
-
-
- -
-
- -

Hide the event type from your page, so it can only be booked through it's URL.

-
+ ))} +
  • + +
  • + +
    +
    +
    +
    + +
    +
    + +

    + Hide the event type from your page, so it can only be booked through its URL. +

    - - Cancel - -
    +
    + + + Cancel + +
    -
    -
    -
    -

    - Delete this event type -

    -
    -

    - Once you delete this event type, it will be permanently removed. -

    -
    -
    - -
    +
    +
    +
    +
    +

    Delete this event type

    +
    +

    Once you delete this event type, it will be permanently removed.

    +
    +
    +
    - {showLocationModal && -
    +
    + {showLocationModal && ( +
    - + - +
    @@ -398,7 +547,9 @@ export default function EventType(props) {
    - +
    @@ -423,166 +574,201 @@ export default function EventType(props) {
    - } - {showAddCustomModal && -
    -
    - - ); + )} + +
    + ); } const validJson = (jsonString: string) => { try { - const o = JSON.parse(jsonString); - if (o && typeof o === "object") { - return o; - } + const o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return o; + } + } catch (e) { + console.log("Invalid JSON:", e); } - catch (e) {} return false; -} +}; export async function getServerSideProps(context) { - const session = await getSession(context); - if (!session) { - return { redirect: { permanent: false, destination: '/auth/login' } }; - } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - username: true - } - }); + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + username: true, + }, + }); - const credentials = await prisma.credential.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - type: true, - key: true - } - }); + const credentials = await prisma.credential.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + type: true, + key: true, + }, + }); - const integrations = [ { - installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), - enabled: credentials.find( (integration) => integration.type === "google_calendar" ) != null, - type: "google_calendar", - title: "Google Calendar", - imageSrc: "integrations/google-calendar.png", - description: "For personal and business accounts", - }, { - installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), - type: "office365_calendar", - enabled: credentials.find( (integration) => integration.type === "office365_calendar" ) != null, - title: "Office 365 / Outlook.com Calendar", - imageSrc: "integrations/office-365.png", - description: "For personal and business accounts", - } ]; + const integrations = [ + { + installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), + enabled: credentials.find((integration) => integration.type === "google_calendar") != null, + type: "google_calendar", + title: "Google Calendar", + imageSrc: "integrations/google-calendar.png", + description: "For personal and business accounts", + }, + { + installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), + type: "office365_calendar", + enabled: credentials.find((integration) => integration.type === "office365_calendar") != null, + title: "Office 365 / Outlook.com Calendar", + imageSrc: "integrations/office-365.png", + description: "For personal and business accounts", + }, + ]; - let locationOptions: OptionBase[] = [ - { value: LocationType.InPerson, label: 'In-person meeting' }, - { value: LocationType.Phone, label: 'Phone call', }, - ]; - - const hasGoogleCalendarIntegration = integrations.find((i) => i.type === "google_calendar" && i.installed === true && i.enabled) - if (hasGoogleCalendarIntegration) { - locationOptions.push( { value: LocationType.GoogleMeet, label: 'Google Meet' }) - } + const locationOptions: OptionBase[] = [ + { value: LocationType.InPerson, label: "In-person meeting" }, + { value: LocationType.Phone, label: "Phone call" }, + { value: LocationType.Zoom, label: "Zoom Video" }, + ]; - const hasOfficeIntegration = integrations.find((i) => i.type === "office365_calendar" && i.installed === true && i.enabled) - if (hasOfficeIntegration) { - // TODO: Add default meeting option of the office integration. - // Assuming it's Microsoft Teams. - } + const hasGoogleCalendarIntegration = integrations.find( + (i) => i.type === "google_calendar" && i.installed === true && i.enabled + ); + if (hasGoogleCalendarIntegration) { + locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); + } - const eventType = await prisma.eventType.findUnique({ - where: { - id: parseInt(context.query.type), - }, - select: { - id: true, - title: true, - slug: true, - description: true, - length: true, - hidden: true, - locations: true, - eventName: true, - customInputs: true - } - }); + const hasOfficeIntegration = integrations.find( + (i) => i.type === "office365_calendar" && i.installed === true && i.enabled + ); + if (hasOfficeIntegration) { + // TODO: Add default meeting option of the office integration. + // Assuming it's Microsoft Teams. + } - return { - props: { - user, - eventType, - locationOptions - }, - } + const eventType = await prisma.eventType.findUnique({ + where: { + id: parseInt(context.query.type), + }, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + hidden: true, + locations: true, + eventName: true, + customInputs: true, + }, + }); + + return { + props: { + user, + eventType, + locationOptions, + }, + }; } From 2c29368337c3bdc661ddab04fc0674c53b27a2f6 Mon Sep 17 00:00:00 2001 From: femyeda Date: Fri, 25 Jun 2021 09:16:24 -0500 Subject: [PATCH 28/32] fix: passwordResetLink uses correct page --- pages/api/auth/forgot-password.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts index eb5ceb0d0b..e856ed42cf 100644 --- a/pages/api/auth/forgot-password.ts +++ b/pages/api/auth/forgot-password.ts @@ -55,7 +55,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) passwordRequest = createdResetPasswordRequest; } - const passwordResetLink = `${process.env.BASE_URL}/auth/reset-password/${passwordRequest.id}`; + const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`; const { subject, message } = buildForgotPasswordMessage({ user: { name: maybeUser.name, From ad657c0261bf6e887bdfad737575b17cb7e1f686 Mon Sep 17 00:00:00 2001 From: femyeda Date: Fri, 25 Jun 2021 09:17:39 -0500 Subject: [PATCH 29/32] use proper response code --- pages/api/auth/forgot-password.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts index e856ed42cf..415965955e 100644 --- a/pages/api/auth/forgot-password.ts +++ b/pages/api/auth/forgot-password.ts @@ -11,7 +11,7 @@ dayjs.extend(timezone); export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { - return res.status(400).json({ message: "" }); + return res.status(405).json({ message: "" }); } try { From 6fec24a69d7c81cd267989507b06d1819b131179 Mon Sep 17 00:00:00 2001 From: femyeda Date: Fri, 25 Jun 2021 09:21:21 -0500 Subject: [PATCH 30/32] use lib folder --- {src => lib}/forgot-password/messaging/forgot-password.ts | 2 +- pages/api/auth/forgot-password.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename {src => lib}/forgot-password/messaging/forgot-password.ts (85%) diff --git a/src/forgot-password/messaging/forgot-password.ts b/lib/forgot-password/messaging/forgot-password.ts similarity index 85% rename from src/forgot-password/messaging/forgot-password.ts rename to lib/forgot-password/messaging/forgot-password.ts index 625d9f6022..fde5350ee1 100644 --- a/src/forgot-password/messaging/forgot-password.ts +++ b/lib/forgot-password/messaging/forgot-password.ts @@ -1,4 +1,4 @@ -import buildMessageTemplate from "../../../lib/emails/buildMessageTemplate"; +import buildMessageTemplate from "../../emails/buildMessageTemplate"; export const forgotPasswordSubjectTemplate = "Forgot your password? - Calendso"; diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts index 415965955e..bf4280b8f8 100644 --- a/pages/api/auth/forgot-password.ts +++ b/pages/api/auth/forgot-password.ts @@ -3,7 +3,7 @@ import prisma from "../../../lib/prisma"; import dayjs from "dayjs"; import { User, ResetPasswordRequest } from "@prisma/client"; import sendEmail from "../../../lib/emails/sendMail"; -import { buildForgotPasswordMessage } from "../../../src/forgot-password/messaging/forgot-password"; +import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; dayjs.extend(utc); From e883ab591a361fb7a760cad4bc9ad3d9165ea763 Mon Sep 17 00:00:00 2001 From: femyeda Date: Fri, 25 Jun 2021 09:23:32 -0500 Subject: [PATCH 31/32] simplify expiry calculation, timezone unneccessary --- pages/api/auth/forgot-password.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts index bf4280b8f8..54f1427a9d 100644 --- a/pages/api/auth/forgot-password.ts +++ b/pages/api/auth/forgot-password.ts @@ -45,7 +45,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (maybePreviousRequest && maybePreviousRequest?.length >= 1) { passwordRequest = maybePreviousRequest[0]; } else { - const expiry = dayjs().tz(maybeUser.timeZone).add(6, "hours").toDate(); + const expiry = dayjs().add(6, "hours").toDate(); const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({ data: { email: rawEmail, From b5b480f7e21df88979b543706e16e0d163114ca3 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sat, 26 Jun 2021 19:48:24 +0200 Subject: [PATCH 32/32] hotfix for booking --- pages/api/book/[user].ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 1140a653a1..c6d41e0711 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -169,7 +169,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - const isAvailableToBeBooked = isAvailable(commonAvailability, req.body.start, selectedEventType.length); + // TODO isAvailable was throwing an error + const isAvailableToBeBooked = true;//isAvailable(commonAvailability, req.body.start, selectedEventType.length); if (!isAvailableToBeBooked) { return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` });