From a463fded8f621e57ddb8f7973367210be5544eec Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Sun, 20 Jun 2021 14:19:41 +0000 Subject: [PATCH 1/9] Added and components --- components/booking/AvailableTimes.tsx | 89 +++++ components/booking/TimeOptions.tsx | 73 ++++ lib/clock.ts | 43 ++ pages/[user]/[type].tsx | 538 ++++++++++---------------- 4 files changed, 400 insertions(+), 343 deletions(-) create mode 100644 components/booking/AvailableTimes.tsx create mode 100644 components/booking/TimeOptions.tsx create mode 100644 lib/clock.ts diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx new file mode 100644 index 00000000..3acc07a2 --- /dev/null +++ b/components/booking/AvailableTimes.tsx @@ -0,0 +1,89 @@ +import dayjs, {Dayjs} from "dayjs"; +import isBetween from 'dayjs/plugin/isBetween'; +dayjs.extend(isBetween); +import {useEffect, useMemo, useState} from "react"; +import getSlots from "../../lib/slots"; +import Link from "next/link"; +import {timeZone} from "../../lib/clock"; +import {useRouter} from "next/router"; + +const AvailableTimes = (props) => { + + const router = useRouter(); + const { user, rescheduleUid } = router.query; + const [loaded, setLoaded] = useState(false); + + const times = useMemo(() => + getSlots({ + 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); + + // Check if start times are the same + if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) { + times.splice(i, 1); + } + + // Check if time is between start and end times + if (dayjs(times[i]).isBetween(startTime, endTime)) { + times.splice(i, 1); + } + + // Check if slot end time is between start and end time + 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'))) { + times.splice(i, 1); + } + }); + } + // Display available times + setLoaded(true); + }; + + // Re-render only when invitee changes date + useEffect(() => { + setLoaded(false); + fetch(`/api/availability/${user}?dateFrom=${props.date.startOf('day').utc().format()}&dateTo=${props.date.endOf('day').utc().format()}`) + .then( res => res.json()) + .then(handleAvailableSlots); + }, [props.date]); + + return ( +
+
+ + {props.date.format("dddd DD MMMM YYYY")} + +
+ { + loaded ? times.map((time) => +
+ + {dayjs(time).tz(timeZone()).format(props.timeFormat)} + +
+ ) :
+ } +
+ ); +} + +export default AvailableTimes; \ No newline at end of file diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx new file mode 100644 index 00000000..38aafd8b --- /dev/null +++ b/components/booking/TimeOptions.tsx @@ -0,0 +1,73 @@ +import {Switch} from "@headlessui/react"; +import TimezoneSelect from "react-timezone-select"; +import {useEffect, useState} from "react"; +import {timeZone, is24h} from '../../lib/clock'; + +function classNames(...classes) { + return classes.filter(Boolean).join(' ') +} + +const TimeOptions = (props) => { + + const [selectedTimeZone, setSelectedTimeZone] = useState(''); + const [is24hClock, setIs24hClock] = useState(false); + + useEffect( () => { + setIs24hClock(is24h()); + setSelectedTimeZone(timeZone()); + }, []); + + useEffect( () => { + props.onSelectTimeZone(timeZone(selectedTimeZone)); + }, [selectedTimeZone]); + + useEffect( () => { + props.onToggle24hClock(is24h(is24hClock)); + }, [is24hClock]); + + return selectedTimeZone !== "" && ( +
+
+
Time Options
+
+ + + am/pm + + + Use setting + + + 24h + + +
+
+ setSelectedTimeZone(tz.value)} + className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + /> +
+ ); +} + +export default TimeOptions; \ No newline at end of file diff --git a/lib/clock.ts b/lib/clock.ts new file mode 100644 index 00000000..2b065cf4 --- /dev/null +++ b/lib/clock.ts @@ -0,0 +1,43 @@ +// handles logic related to user clock display using 24h display / timeZone options. +import dayjs, {Dayjs} from 'dayjs'; + +interface TimeOptions { is24hClock: boolean, inviteeTimeZone: string }; + +const timeOptions: TimeOptions = { + is24hClock: false, + inviteeTimeZone: '', +} + +const isInitialized: boolean = false; + +const initClock = () => { + if (typeof localStorage === "undefined" || isInitialized) { + return; + } + timeOptions.is24hClock = localStorage.getItem('timeOption.is24hClock') === "true"; + timeOptions.inviteeTimeZone = localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess(); +} + +const is24h = (is24hClock?: boolean) => { + initClock(); + if(typeof is24hClock !== "undefined") set24hClock(is24hClock); + return timeOptions.is24hClock; +} + +const set24hClock = (is24hClock: boolean) => { + localStorage.setItem('timeOption.is24hClock', is24hClock.toString()); + timeOptions.is24hClock = is24hClock; +} + +function setTimeZone(selectedTimeZone: string) { + localStorage.setItem('timeOption.preferredTimeZone', selectedTimeZone); + timeOptions.inviteeTimeZone = selectedTimeZone; +} + +const timeZone = (selectedTimeZone?: string) => { + initClock(); + if (selectedTimeZone) setTimeZone(selectedTimeZone) + return timeOptions.inviteeTimeZone; +} + +export {is24h, timeZone}; \ No newline at end of file diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index aa57bd48..1a9d7c47 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -4,369 +4,221 @@ import Link from 'next/link'; import prisma from '../../lib/prisma'; 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 isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; -import isBetween from 'dayjs/plugin/isBetween'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; -import Avatar from '../../components/Avatar'; dayjs.extend(isSameOrBefore); -dayjs.extend(isBetween); dayjs.extend(utc); dayjs.extend(timezone); -import getSlots from '../../lib/slots'; import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; - -function classNames(...classes) { - return classes.filter(Boolean).join(' ') -} +import AvailableTimes from "../../components/booking/AvailableTimes"; +import TimeOptions from "../../components/booking/TimeOptions" +import Avatar from '../../components/Avatar'; +import {timeZone} from "../../lib/clock"; export default function Type(props) { - // Initialise state - const [selectedDate, setSelectedDate] = useState(); - const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); - const [loading, setLoading] = useState(false); - const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); - const [is24h, setIs24h] = useState(false); - const [busy, setBusy] = useState([]); - const telemetry = useTelemetry(); - const [selectedTimeZone, setSelectedTimeZone] = useState(''); + // Get router variables + const router = useRouter(); + const { rescheduleUid } = router.query; - function toggleTimeOptions() { - setIsTimeOptionsOpen(!isTimeOptionsOpen); - } - - function toggleClockSticky() { - localStorage.setItem('timeOption.is24hClock', (!is24h).toString()); - setIs24h(!is24h); - } - - function setPreferredTimeZoneSticky({ value }: string) { - localStorage.setItem('timeOption.preferredTimeZone', value); - setSelectedTimeZone(value); - } - - function initializeTimeOptions() { - setSelectedTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess()); - setIs24h(!!localStorage.getItem('timeOption.is24hClock')); - } + // Initialise state + const [selectedDate, setSelectedDate] = useState(); + const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); + const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); + const [timeFormat, setTimeFormat] = useState('hh:mm'); + const telemetry = useTelemetry(); useEffect(() => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) }, []); - // Handle date change and timezone change - useEffect(() => { + // Handle month changes + const incrementMonth = () => { + setSelectedMonth(selectedMonth + 1); + } - if ( ! selectedTimeZone ) { - initializeTimeOptions(); - } + const decrementMonth = () => { + setSelectedMonth(selectedMonth - 1); + } - const changeDate = async () => { - if (!selectedDate) { - return - } + // Set up calendar + var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); + var days = []; + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } - setLoading(true); - 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); - setLoading(false); - } - changeDate(); - }, [selectedDate, selectedTimeZone]); - - // Get router variables - const router = useRouter(); - const { user, rescheduleUid } = router.query; - - // Handle month changes - const incrementMonth = () => { - setSelectedMonth(selectedMonth + 1); - } - - const decrementMonth = () => { - setSelectedMonth(selectedMonth - 1); - } - - // Need to define the bounds of the 24-hour window - const lowerBound = useMemo(() => { - if(!selectedDate) { - return - } - - return selectedDate.startOf('day') - }, [selectedDate]) - - const upperBound = useMemo(() => { - if(!selectedDate) return - - return selectedDate.endOf('day') - }, [selectedDate]) - - // Set up calendar - var daysInMonth = dayjs().month(selectedMonth).daysInMonth(); - var days = []; - for (let i = 1; i <= daysInMonth; i++) { - days.push(i); - } - - // Create placeholder elements for empty days in first week - let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); - if (props.user.weekStart === 'Monday') { - weekdayOfFirst -= 1; - if (weekdayOfFirst < 0) - weekdayOfFirst = 6; - } - const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => -
- {null} -
- ); - - // Combine placeholder days with actual days - const calendar = [...emptyDays, ...days.map((day) => - - )]; - - const times = useMemo(() => - getSlots({ - calendarTimeZone: props.user.timeZone, - selectedTimeZone: selectedTimeZone, - eventLength: props.eventType.length, - selectedDate: selectedDate, - dayStartTime: props.user.startTime, - dayEndTime: props.user.endTime, - }) - , [selectedDate, selectedTimeZone]) - - // Check for conflicts - for(let i = times.length - 1; i >= 0; i -= 1) { - busy.forEach(busyTime => { - let startTime = dayjs(busyTime.start); - let endTime = dayjs(busyTime.end); - - // Check if start times are the same - if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) { - times.splice(i, 1); - } - - // Check if time is between start and end times - if (dayjs(times[i]).isBetween(startTime, endTime)) { - times.splice(i, 1); - } - - // Check if slot end time is between start and end time - 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'))) { - times.splice(i, 1); - } - }); - } - - // Display available times - const availableTimes = times.map((time) => - - ); - - return ( -
- - - {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | - Calendso - - - -
-
-
-
- -

{props.user.name}

-

- {props.eventType.title} -

-

- - {props.eventType.length} minutes -

- - {isTimeOptionsOpen && ( -
-
-
Time Options
-
- - - am/pm - - - Use setting - - - 24h - - -
-
- -
- )} -

- {props.eventType.description} -

-
-
-
- - {dayjs().month(selectedMonth).format("MMMM YYYY")} - -
- - -
-
-
- {props.user.weekStart !== 'Monday' ? ( -
- Sun -
- ) : null} -
- Mon -
-
- Tue -
-
- Wed -
-
- Thu -
-
- Fri -
-
- Sat -
- {props.user.weekStart === 'Monday' ? ( -
- Sun -
- ) : null} - {calendar} -
-
- {selectedDate && ( -
-
- - {dayjs(selectedDate).format("dddd DD MMMM YYYY")} - -
- {!loading ? availableTimes :
} -
- )} -
-
- {/* note(peer): - you can remove calendso branding here, but we'd also appreciate it, if you don't <3 - */} - -
+ // Create placeholder elements for empty days in first week + let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); + if (props.user.weekStart === 'Monday') { + weekdayOfFirst -= 1; + if (weekdayOfFirst < 0) + weekdayOfFirst = 6; + } + const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) => +
+ {null}
- ); + ); + + const changeDate = (day) => { + telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())) + setSelectedDate(dayjs().month(selectedMonth).date(day)) + }; + + // Combine placeholder days with actual days + const calendar = [...emptyDays, ...days.map((day) => + + )]; + + const handleSelectTimeZone = (selectedTimeZone: string) => { + if (selectedDate) { + setSelectedDate(selectedDate.tz(selectedTimeZone)) + } + }; + + const handleToggle24hClock = (is24hClock: boolean) => { + if (selectedDate) { + setTimeFormat(is24hClock ? 'HH:mm' : 'h:mma'); + } + } + + return ( +
+ + + {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | + Calendso + + + +
+
+
+
+ +

{props.user.name}

+

+ {props.eventType.title} +

+

+ + {props.eventType.length} minutes +

+ + { isTimeOptionsOpen && } +

+ {props.eventType.description} +

+
+
+
+ + {dayjs().month(selectedMonth).format("MMMM YYYY")} + +
+ + +
+
+
+ {props.user.weekStart !== 'Monday' ? ( +
+ Sun +
+ ) : null} +
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+ {props.user.weekStart === 'Monday' ? ( +
+ Sun +
+ ) : null} + {calendar} +
+
+ {selectedDate && } +
+
+ {/* note(peer): + you can remove calendso branding here, but we'd also appreciate it, if you don't <3 + */} + +
+
+ ); } export async function getServerSideProps(context) { @@ -389,7 +241,7 @@ export async function getServerSideProps(context) { } }); - if (!user ) { + if (!user) { return { notFound: true, } @@ -412,7 +264,7 @@ export async function getServerSideProps(context) { if (!eventType) { return { - notFound: true + notFound: true, } } From d407ba0fe7cfa3be828073dd46be7f993b6f0c57 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Sun, 20 Jun 2021 14:37:33 +0000 Subject: [PATCH 2/9] Make sure all dayjs() plugins are there --- lib/clock.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/clock.ts b/lib/clock.ts index 2b065cf4..f082d9db 100644 --- a/lib/clock.ts +++ b/lib/clock.ts @@ -1,5 +1,10 @@ // handles logic related to user clock display using 24h display / timeZone options. import dayjs, {Dayjs} from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; + +dayjs.extend(utc) +dayjs.extend(timezone) interface TimeOptions { is24hClock: boolean, inviteeTimeZone: string }; From 881ba671d5bec239390d76d1d206244137608d63 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Sun, 20 Jun 2021 21:01:41 +0000 Subject: [PATCH 3/9] Change to run getSlots() every time so it updates when selectedDate changes --- components/booking/AvailableTimes.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 3acc07a2..f6f10574 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -13,16 +13,14 @@ const AvailableTimes = (props) => { const { user, rescheduleUid } = router.query; const [loaded, setLoaded] = useState(false); - const times = useMemo(() => - getSlots({ - calendarTimeZone: props.user.timeZone, - selectedTimeZone: timeZone(), - eventLength: props.eventType.length, - selectedDate: props.date, - dayStartTime: props.user.startTime, - dayEndTime: props.user.endTime, - }) - , []) + 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 handleAvailableSlots = (busyTimes: []) => { // Check for conflicts From f2265fdbd7833da7537b03a1fd4c8a671f0fa60f Mon Sep 17 00:00:00 2001 From: femyeda Date: Mon, 21 Jun 2021 07:31:39 -0500 Subject: [PATCH 4/9] Revert "Revert "Fixed cancellation bug: "cannot read property 'length' of null""" This reverts commit de4c8f75e0ae0af9967ec800769a4927d5d87bbb. --- pages/cancel/[uid].tsx | 46 ++++++++++-------------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/pages/cancel/[uid].tsx b/pages/cancel/[uid].tsx index a4637cc4..a415e470 100644 --- a/pages/cancel/[uid].tsx +++ b/pages/cancel/[uid].tsx @@ -134,38 +134,6 @@ export default function Type(props) { } export async function getServerSideProps(context) { - const user = await prisma.user.findFirst({ - where: { - username: context.query.user, - }, - select: { - id: true, - username: true, - name: true, - } - }); - - if (!user) { - return { - notFound: true, - } - } - - const eventType = await prisma.eventType.findFirst({ - where: { - userId: user.id, - slug: { - equals: context.query.type, - }, - }, - select: { - id: true, - title: true, - description: true, - length: true - } - }); - const booking = await prisma.booking.findFirst({ where: { uid: context.query.uid, @@ -176,7 +144,15 @@ export async function getServerSideProps(context) { description: true, startTime: true, endTime: true, - attendees: true + attendees: true, + eventType: true, + user: { + select: { + id: true, + username: true, + name: true, + } + } } }); @@ -188,8 +164,8 @@ export async function getServerSideProps(context) { return { props: { - user, - eventType, + user: booking.user, + eventType: booking.eventType, booking: bookingObj }, } From 4c62c7c97f5b85aea520f82e4f52c61e95a825d1 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 21 Jun 2021 19:30:00 +0000 Subject: [PATCH 5/9] Included missing dayjs plugins --- lib/emails/EventAttendeeMail.ts | 7 +++++++ lib/emails/EventOrganizerMail.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts index b8bef1ba..55f231f9 100644 --- a/lib/emails/EventAttendeeMail.ts +++ b/lib/emails/EventAttendeeMail.ts @@ -1,6 +1,13 @@ import dayjs, {Dayjs} from "dayjs"; import EventMail from "./EventMail"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); + export default class EventAttendeeMail extends EventMail { /** * Returns the email text as HTML representation. diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts index 86c23e42..6d3060b6 100644 --- a/lib/emails/EventOrganizerMail.ts +++ b/lib/emails/EventOrganizerMail.ts @@ -2,6 +2,15 @@ import {createEvent} from "ics"; import dayjs, {Dayjs} from "dayjs"; import EventMail from "./EventMail"; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import toArray from 'dayjs/plugin/toArray'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(toArray); +dayjs.extend(localizedFormat); + export default class EventOrganizerMail extends EventMail { /** * Returns the instance's event as an iCal event in string representation. From 7690350124ac0a442f4bcfcadf5ad519222cb0df Mon Sep 17 00:00:00 2001 From: Femi Odugbesan Date: Mon, 21 Jun 2021 18:15:29 -0500 Subject: [PATCH 6/9] feat: allow users to set google meet as a location for events (#287) * feat: allow users to set google meet as a location for events - add google meet location with support for other integrations * return location types from server also avoids potential leaks of user credentials * chore: remove unused variable * fix: return minimal required data from server --- lib/calendarClient.ts | 712 ++++++++++++++++------------ lib/location.ts | 1 + package.json | 1 + pages/[user]/book.tsx | 24 +- pages/api/book/[user].ts | 46 +- pages/availability/event/[type].tsx | 80 +++- 6 files changed, 550 insertions(+), 314 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 648a4a7b..791235c5 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,16 +1,18 @@ import EventOrganizerMail from "./emails/EventOrganizerMail"; import EventAttendeeMail from "./emails/EventAttendeeMail"; -import {v5 as uuidv5} from 'uuid'; -import short from 'short-uuid'; +import { v5 as uuidv5 } from "uuid"; +import short from "short-uuid"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; const translator = short(); -const {google} = require('googleapis'); +const { google } = require("googleapis"); const googleAuth = () => { - 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; return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); }; @@ -31,36 +33,41 @@ function handleErrorsRaw(response) { } const o365Auth = (credential) => { + const isExpired = (expiryDate) => expiryDate < +new Date(); - const isExpired = (expiryDate) => expiryDate < +(new Date()); - - 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) => + 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; + }); return { - getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) + getToken: () => + !isExpired(credential.key.expiry_date) + ? Promise.resolve(credential.key.access_token) + : refreshAccessToken(credential.key.refresh_token), }; }; interface Person { - name?: string, - email: string, - timeZone: string + name?: string; + email: string; + timeZone: string; } interface CalendarEvent { @@ -72,13 +79,18 @@ interface CalendarEvent { location?: string; organizer: Person; attendees: Person[]; -}; + conferenceData?: ConferenceData; +} + +interface ConferenceData { + createRequest: any; +} interface IntegrationCalendar { - integration: string; - primary: boolean; - externalId: string; - name: string; + integration: string; + primary: boolean; + externalId: string; + name: string; } interface CalendarApiAdapter { @@ -88,26 +100,28 @@ interface CalendarApiAdapter { deleteEvent(uid: String); - getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; + getAvailability( + dateFrom, + dateTo, + selectedCalendars: IntegrationCalendar[] + ): Promise; - listCalendars(): Promise; + listCalendars(): Promise; } const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { - const auth = o365Auth(credential); const translateEvent = (event: CalendarEvent) => { - let optional = {}; if (event.location) { - optional.location = {displayName: event.location}; + optional.location = { displayName: event.location }; } return { subject: event.title, body: { - contentType: 'HTML', + contentType: "HTML", content: event.description, }, start: { @@ -118,284 +132,370 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { dateTime: event.endTime, timeZone: event.organizer.timeZone, }, - attendees: event.attendees.map(attendee => ({ + attendees: event.attendees.map((attendee) => ({ emailAddress: { address: attendee.email, - name: attendee.name + name: attendee.name, }, - type: "required" + type: "required", })), - ...optional - } + ...optional, + }; }; - const integrationType = "office365_calendar"; + const integrationType = "office365_calendar"; - function listCalendars(): Promise { - return auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendars', { - method: 'get', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' - }, - }).then(handleErrorsJson) - .then(responseBody => { - return responseBody.value.map(cal => { - const calendar: IntegrationCalendar = { - externalId: cal.id, integration: integrationType, name: cal.name, primary: cal.isDefaultCalendar - } - return calendar; - }); - }) - ) - } - - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => { - const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'" - return auth.getToken().then( - (accessToken) => { - 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 - return Promise.resolve([]); - } - - return (selectedCalendarIds.length == 0 - ? listCalendars().then(cals => cals.map(e => e.externalId)) - : Promise.resolve(selectedCalendarIds).then(x => x)).then((ids: string[]) => { - const urls = ids.map(calendarId => 'https://graph.microsoft.com/v1.0/me/calendars/' + calendarId + '/events' + filter) - return Promise.all(urls.map(url => fetch(url, { - method: 'get', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Prefer': 'outlook.timezone="Etc/GMT"' - } - }) - .then(handleErrorsJson) - .then(responseBody => responseBody.value.map((evt) => ({ - start: evt.start.dateTime + 'Z', - end: evt.end.dateTime + 'Z' - })) - ))).then(results => results.reduce((acc, events) => acc.concat(events), [])) - }) - } - ).catch((err) => { - console.log(err); - }); + function listCalendars(): Promise { + return auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendars", { + method: "get", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", }, - createEvent: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', { - method: 'POST', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(translateEvent(event)) - }).then(handleErrorsJson).then((responseBody) => ({ + }) + .then(handleErrorsJson) + .then((responseBody) => { + return responseBody.value.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id, + integration: integrationType, + name: cal.name, + primary: cal.isDefaultCalendar, + }; + return calendar; + }); + }) + ); + } + + return { + getAvailability: (dateFrom, dateTo, selectedCalendars) => { + const filter = + "?$filter=start/dateTime ge '" + + dateFrom + + "' and end/dateTime le '" + + dateTo + + "'"; + return auth + .getToken() + .then((accessToken) => { + 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 + return Promise.resolve([]); + } + + return ( + selectedCalendarIds.length == 0 + ? listCalendars().then((cals) => cals.map((e) => e.externalId)) + : Promise.resolve(selectedCalendarIds).then((x) => x) + ).then((ids: string[]) => { + const urls = ids.map( + (calendarId) => + "https://graph.microsoft.com/v1.0/me/calendars/" + + calendarId + + "/events" + + filter + ); + return Promise.all( + urls.map((url) => + fetch(url, { + method: "get", + headers: { + Authorization: "Bearer " + accessToken, + Prefer: 'outlook.timezone="Etc/GMT"', + }, + }) + .then(handleErrorsJson) + .then((responseBody) => + responseBody.value.map((evt) => ({ + start: evt.start.dateTime + "Z", + end: evt.end.dateTime + "Z", + })) + ) + ) + ).then((results) => + results.reduce((acc, events) => acc.concat(events), []) + ); + }); + }) + .catch((err) => { + console.log(err); + }); + }, + createEvent: (event: CalendarEvent) => + auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendar/events", { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }) + .then(handleErrorsJson) + .then((responseBody) => ({ ...responseBody, disableConfirmationEmail: true, - }))), - deleteEvent: (uid: String) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, { - method: 'DELETE', - headers: { - 'Authorization': 'Bearer ' + accessToken - } - }).then(handleErrorsRaw)), - updateEvent: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, { - method: 'PATCH', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(translateEvent(event)) - }).then(handleErrorsRaw)), - listCalendars - } + })) + ), + deleteEvent: (uid: String) => + auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + accessToken, + }, + }).then(handleErrorsRaw) + ), + updateEvent: (uid: String, event: CalendarEvent) => + auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + method: "PATCH", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(translateEvent(event)), + }).then(handleErrorsRaw) + ), + listCalendars, + }; }; const GoogleCalendar = (credential): CalendarApiAdapter => { - const myGoogleAuth = googleAuth(); - myGoogleAuth.setCredentials(credential.key); - const integrationType = "google_calendar"; + const myGoogleAuth = googleAuth(); + myGoogleAuth.setCredentials(credential.key); + const integrationType = "google_calendar"; - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => { - 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([]); - } - calendar.freebusy.query({ - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: filteredItems.length > 0 ? filteredItems : cals.data.items - } - }, (err, apires) => { - if (err) { - reject(err); - } - - resolve( - Object.values(apires.data.calendars).flatMap( - (item) => item["busy"] - ) - ) - }); - }) - .catch((err) => { - reject(err); - }); - - }), - createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => { - 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.insert({ - auth: myGoogleAuth, - calendarId: 'primary', - resource: payload, - }, function (err, event) { - if (err) { - console.log('There was an error contacting the Calendar service: ' + err); - return reject(err); - } - return resolve(event.data); - }); - }), - updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => { - 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) { + return { + getAvailability: (dateFrom, dateTo, selectedCalendars) => + new Promise((resolve, reject) => { + 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([]); + } + calendar.freebusy.query( + { + requestBody: { + timeMin: dateFrom, + timeMax: dateTo, + items: + filteredItems.length > 0 ? filteredItems : cals.data.items, + }, + }, + (err, apires) => { if (err) { - console.log('There was an error contacting the Calendar service: ' + err); - return reject(err); - } - return resolve(event.data); - }); - }), - deleteEvent: (uid: String) => new Promise( (resolve, reject) => { - 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.log('There was an error contacting the Calendar service: ' + err); - return reject(err); - } - return resolve(event.data); - }); - }), - listCalendars: () => new Promise((resolve, reject) => { - 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) => { reject(err); - }); - }) - }; + } + + resolve( + Object.values(apires.data.calendars).flatMap( + (item) => item["busy"] + ) + ); + } + ); + }) + .catch((err) => { + reject(err); + }); + }), + createEvent: (event: CalendarEvent) => + new Promise((resolve, reject) => { + 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, + conferenceDataVersion: 1, + }, + function (err, event) { + if (err) { + console.log( + "There was an error contacting the Calendar service: " + err + ); + return reject(err); + } + return resolve(event.data); + } + ); + }), + updateEvent: (uid: String, event: CalendarEvent) => + new Promise((resolve, reject) => { + 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.log( + "There was an error contacting the Calendar service: " + err + ); + return reject(err); + } + return resolve(event.data); + } + ); + }), + deleteEvent: (uid: String) => + new Promise((resolve, reject) => { + 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.log( + "There was an error contacting the Calendar service: " + err + ); + return reject(err); + } + return resolve(event.data); + } + ); + }), + listCalendars: () => + new Promise((resolve, reject) => { + 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) => { + reject(err); + }); + }), + }; }; // factory -const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => { - switch (cred.type) { - case 'google_calendar': - return GoogleCalendar(cred); - case 'office365_calendar': - return MicrosoftOffice365Calendar(cred); - default: - return; // unknown credential, could be legacy? In any case, ignore - } -}).filter(Boolean); +const calendars = (withCredentials): CalendarApiAdapter[] => + withCredentials + .map((cred) => { + switch (cred.type) { + case "google_calendar": + return GoogleCalendar(cred); + case "office365_calendar": + return MicrosoftOffice365Calendar(cred); + default: + return; // unknown credential, could be legacy? In any case, ignore + } + }) + .filter(Boolean); -const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all( - calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars)) -).then( - (results) => { - return results.reduce((acc, availability) => acc.concat(availability), []) - } -); +const getBusyCalendarTimes = ( + withCredentials, + dateFrom, + dateTo, + selectedCalendars +) => + Promise.all( + 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), []) -); +const listCalendars = (withCredentials) => + 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); @@ -407,14 +507,22 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise => return { uid, - createdEvent: creationResult + createdEvent: creationResult, }; }; -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) : null; + const updateResult = credential + ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) + : null; const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); @@ -426,7 +534,7 @@ const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEv return { uid: newUid, - updatedEvent: updateResult + updatedEvent: updateResult, }; }; @@ -438,4 +546,12 @@ const deleteEvent = (credential, uid: String): Promise => { return Promise.resolve({}); }; -export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar}; +export { + getBusyCalendarTimes, + createEvent, + updateEvent, + deleteEvent, + CalendarEvent, + listCalendars, + IntegrationCalendar, +}; diff --git a/lib/location.ts b/lib/location.ts index b1ec56af..b27f4977 100644 --- a/lib/location.ts +++ b/lib/location.ts @@ -2,5 +2,6 @@ export enum LocationType { InPerson = 'inPerson', Phone = 'phone', + GoogleMeet = 'integrations:google:meet' } diff --git a/package.json b/package.json index 750bd7be..31e86ca4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dayjs": "^1.10.4", "googleapis": "^67.1.1", "ics": "^2.27.0", + "lodash.merge": "^4.6.2", "next": "^10.2.0", "next-auth": "^3.13.2", "next-transpile-modules": "^7.0.0", diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index 98b475c8..31b7f23a 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -45,9 +45,10 @@ export default function Book(props) { const locationLabels = { [LocationType.InPerson]: 'In-person meeting', [LocationType.Phone]: 'Phone call', + [LocationType.GoogleMeet]: 'Google Meet', }; - const bookingHandler = event => { + const bookingHandler = (event) => { event.preventDefault(); let notes = ""; @@ -81,7 +82,19 @@ export default function Book(props) { }; if (selectedLocation) { - payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address; + 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())); @@ -98,7 +111,12 @@ export default function Book(props) { let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`; if (payload['location']) { - successUrl += "&location=" + encodeURIComponent(payload['location']); + if (payload['location'].includes('integration')) { + successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow."); + } + else { + successUrl += "&location=" + encodeURIComponent(payload['location']); + } } router.push(successUrl); diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 0c43637f..d10358e4 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -7,9 +7,30 @@ 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" const translator = short(); +interface p { + location: string +} + +const getLocationRequestFromIntegration = ({location}: p) => { + if (location === LocationType.GoogleMeet.valueOf()) { + const requestId = uuidv5(location, uuidv5.URL) + + return { + conferenceData: { + createRequest: { + requestId: requestId + } + } + } + } + + return null +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { const {user} = req.query; @@ -43,19 +64,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - const evt: CalendarEvent = { + let rawLocation = req.body.location + + let evt: CalendarEvent = { type: selectedEventType.title, title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName), description: req.body.notes, startTime: req.body.start, endTime: req.body.end, - location: req.body.location, 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 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) + } + const eventType = await prisma.eventType.findFirst({ where: { userId: currentUser.id, diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index 9518dadc..1ad8824a 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -1,8 +1,8 @@ import Head from 'next/head'; import Link from 'next/link'; -import {useRouter} from 'next/router'; -import {useRef, useState} from 'react'; -import Select, {OptionBase} from 'react-select'; +import { useRouter } from 'next/router'; +import { useRef, useState, useEffect } from 'react'; +import Select, { OptionBase } from 'react-select'; import prisma from '../../../lib/prisma'; import {LocationType} from '../../../lib/location'; import Shell from '../../../components/Shell'; @@ -33,6 +33,7 @@ export default function EventType(props) { const [ selectedInputOption, setSelectedInputOption ] = useState(inputOptions[0]); const [ locations, setLocations ] = useState(props.eventType.locations || []); const [customInputs, setCustomInputs] = useState(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); + const locationOptions = props.locationOptions const titleRef = useRef(); const slugRef = useRef(); @@ -81,12 +82,6 @@ export default function EventType(props) { router.push('/availability'); } - // TODO: Tie into translations instead of abstracting to locations.ts - const locationOptions: OptionBase[] = [ - { value: LocationType.InPerson, label: 'In-person meeting' }, - { value: LocationType.Phone, label: 'Phone call', }, - ]; - const openLocationModal = (type: LocationType) => { setSelectedLocation(locationOptions.find( (option) => option.value === type)); setShowLocationModal(true); @@ -124,6 +119,10 @@ export default function EventType(props) { return (

Calendso will ask your invitee to enter a phone number before scheduling.

) + case LocationType.GoogleMeet: + return ( +

Calendso will provide a Google Meet location.

+ ) } return null; }; @@ -234,6 +233,12 @@ export default function EventType(props) { Phone call
)} + {location.type === LocationType.GoogleMeet && ( +
+ + Google Meet +
+ )}
))} + {props.eventTypes.length == 0 && +
+

You haven't created any event types.

+
+ }
@@ -254,6 +259,11 @@ export default function Home(props) {
))} + {props.eventTypes.length == 0 && +
+

You haven't created any event types.

+
+ } From 24a844011164aca048228bda9c21f2c740f41ea6 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Tue, 22 Jun 2021 15:08:48 +0100 Subject: [PATCH 8/9] Disable React in JSX ESLint rule --- .eslintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index 1d177203..0ea5d0ab 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,7 +17,8 @@ "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], "rules": { "prettier/prettier": ["error"], - "@typescript-eslint/no-unused-vars": "error" + "@typescript-eslint/no-unused-vars": "error", + "react/react-in-jsx-scope": "off" }, "env": { "browser": true, From 78451a98b1602bfa81b472935746614d6e4e1aa0 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Tue, 22 Jun 2021 14:25:01 +0000 Subject: [PATCH 9/9] Updated timeFormat to something more sensible --- pages/[user]/[type].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 394358eb..f2ac3452 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -28,7 +28,7 @@ export default function Type(props) { const [selectedDate, setSelectedDate] = useState(); const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); - const [timeFormat, setTimeFormat] = useState('hh:mm'); + const [timeFormat, setTimeFormat] = useState('h:mma'); const telemetry = useTelemetry(); useEffect(() => {