From b5733716decb8368b81bd7c03d5948d8c876f496 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 01:37:58 +0200 Subject: [PATCH 01/42] Very rudimentary development version of zoom auth --- .env.example | 4 +++ pages/api/integrations/zoom/add.ts | 29 +++++++++++++++++++++ pages/api/integrations/zoom/callback.ts | 34 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 pages/api/integrations/zoom/add.ts create mode 100644 pages/api/integrations/zoom/callback.ts diff --git a/.env.example b/.env.example index 60d84bc955..de845300ed 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r MS_GRAPH_CLIENT_ID= MS_GRAPH_CLIENT_SECRET= +# Used for the Zoom integration +ZOOM_CLIENT_ID= +ZOOM_CLIENT_SECRET= + # E-mail settings # Calendso uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to diff --git a/pages/api/integrations/zoom/add.ts b/pages/api/integrations/zoom/add.ts new file mode 100644 index 0000000000..f07231a999 --- /dev/null +++ b/pages/api/integrations/zoom/add.ts @@ -0,0 +1,29 @@ +import type {NextApiRequest, NextApiResponse} from 'next'; +import {getSession} from 'next-auth/client'; +import prisma from '../../../../lib/prisma'; + +const client_id = process.env.ZOOM_CLIENT_ID; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === 'GET') { + // Check that user is authenticated + const session = await getSession({req: req}); + + if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } + + // Get user + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true + } + }); + + const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoom/callback'); + const authUrl = 'https://zoom.us/oauth/authorize?response_type=code&client_id=' + client_id + '&redirect_uri=' + redirectUri; + + res.status(200).json({url: authUrl}); + } +} diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts new file mode 100644 index 0000000000..8195f60c7c --- /dev/null +++ b/pages/api/integrations/zoom/callback.ts @@ -0,0 +1,34 @@ +import type {NextApiRequest, NextApiResponse} from 'next'; + +const client_id = process.env.ZOOM_CLIENT_ID; +const client_secret = process.env.ZOOM_CLIENT_SECRET; + +const scopes = ['meeting:write:admin', 'meeting:write', 'meeting:read:admin', 'meeting:read']; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + console.log(code); + // Check that user is authenticated + /*const session = await getSession({req: req}); + + if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } + + // TODO Init some sort of oAuth client here + + // Convert to token + /*return new Promise( (resolve, reject) => oAuth2Client.getToken(code, async (err, token) => { + if (err) return console.error('Error retrieving access token', err); + + const credential = await prisma.credential.create({ + data: { + type: 'google_calendar', + key: token, + userId: session.user.id + } + }); + + res.redirect('/integrations'); + resolve(); + }));*/ + res.redirect('/integrations'); +} \ No newline at end of file From a2f1462f1089f8d78a0a96a5deb168407ab8e9b0 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:03:48 +0200 Subject: [PATCH 02/42] Very rudimentary development version of zoom auth #2 --- pages/api/integrations/zoom/callback.ts | 51 +++++++++++++++++-------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 8195f60c7c..175d294c53 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -1,4 +1,6 @@ import type {NextApiRequest, NextApiResponse} from 'next'; +import {getSession} from "next-auth/client"; +import prisma from "../../../../lib/prisma"; const client_id = process.env.ZOOM_CLIENT_ID; const client_secret = process.env.ZOOM_CLIENT_SECRET; @@ -7,28 +9,45 @@ const scopes = ['meeting:write:admin', 'meeting:write', 'meeting:read:admin', 'm export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; - console.log(code); + // Check that user is authenticated - /*const session = await getSession({req: req}); + const session = await getSession({req: req}); if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } - // TODO Init some sort of oAuth client here + const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoom/callback'); + const authUrl = 'https://zoom.us/oauth/authorize?response_type=code&client_id=' + client_id + '&redirect_uri=' + redirectUri; + const authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64'); // Convert to token - /*return new Promise( (resolve, reject) => oAuth2Client.getToken(code, async (err, token) => { - if (err) return console.error('Error retrieving access token', err); + const options = { + method: 'POST', + qs: { + grant_type: 'authorization_code', + code, + redirect_uri: authUrl + }, + headers: { + Authorization: authHeader + } + }; - const credential = await prisma.credential.create({ - data: { - type: 'google_calendar', - key: token, - userId: session.user.id - } - }); + return new Promise( (resolve, reject) => fetch('https://zoom.us/oauth/token', options) + .then((res) => res.text()) + .then((text) => { + console.log(text); + const credential = await prisma.credential.create({ + data: { + type: 'google_calendar', + key: 'lel', + userId: session.user.id + } + }); - res.redirect('/integrations'); - resolve(); - }));*/ - res.redirect('/integrations'); + res.redirect('/integrations'); + resolve(); + }) + .catch((err) => { + if (err) throw new Error(err); + })); } \ No newline at end of file From 46474c9c70f42cc2cc9e71b478c4d6712cf057d6 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:07:02 +0200 Subject: [PATCH 03/42] Very rudimentary development version of zoom auth #3 --- pages/api/integrations/zoom/callback.ts | 32 +++++++++++-------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 175d294c53..d0469c0b0e 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -1,6 +1,5 @@ import type {NextApiRequest, NextApiResponse} from 'next'; import {getSession} from "next-auth/client"; -import prisma from "../../../../lib/prisma"; const client_id = process.env.ZOOM_CLIENT_ID; const client_secret = process.env.ZOOM_CLIENT_SECRET; @@ -32,22 +31,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }; - return new Promise( (resolve, reject) => fetch('https://zoom.us/oauth/token', options) - .then((res) => res.text()) - .then((text) => { - console.log(text); - const credential = await prisma.credential.create({ - data: { - type: 'google_calendar', - key: 'lel', - userId: session.user.id - } - }); + return new Promise( async (resolve, reject) => { + const result = await fetch('https://zoom.us/oauth/token', options); + console.log(result); - res.redirect('/integrations'); - resolve(); - }) - .catch((err) => { - if (err) throw new Error(err); - })); + /*const credential = await prisma.credential.create({ + data: { + type: 'google_calendar', + key: 'lel', + userId: session.user.id + } + });*/ + + res.redirect('/integrations'); + resolve(); + }); } \ No newline at end of file From 615e59cde725ac1a006661fda92424026412ec16 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:08:47 +0200 Subject: [PATCH 04/42] Very rudimentary development version of zoom auth #4 --- pages/api/integrations/zoom/callback.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index d0469c0b0e..7cbf5f0f3f 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -21,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Convert to token const options = { method: 'POST', - qs: { + body: { grant_type: 'authorization_code', code, redirect_uri: authUrl @@ -32,7 +32,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; return new Promise( async (resolve, reject) => { - const result = await fetch('https://zoom.us/oauth/token', options); + const result = await fetch('https://zoom.us/oauth/token', options) + .then(res => res.text()); console.log(result); /*const credential = await prisma.credential.create({ From 65a029acde56b821f56d36167b63fcff2246a177 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:10:56 +0200 Subject: [PATCH 05/42] Very rudimentary development version of zoom auth #5 --- pages/api/integrations/zoom/callback.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 7cbf5f0f3f..6b7842fe57 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -18,21 +18,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const authUrl = 'https://zoom.us/oauth/authorize?response_type=code&client_id=' + client_id + '&redirect_uri=' + redirectUri; const authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64'); - // Convert to token - const options = { - method: 'POST', - body: { - grant_type: 'authorization_code', - code, - redirect_uri: authUrl - }, - headers: { - Authorization: authHeader - } - }; - return new Promise( async (resolve, reject) => { - const result = await fetch('https://zoom.us/oauth/token', options) + const result = await fetch('https://zoom.us/oauth/token', { + method: 'POST', + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: authUrl + }), + headers: { + Authorization: authHeader + } + }) .then(res => res.text()); console.log(result); From 697cae9cc9850f572dcd74764f9d4a8a1ba80a29 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:13:02 +0200 Subject: [PATCH 06/42] Very rudimentary development version of zoom auth #6 --- pages/api/integrations/zoom/callback.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 6b7842fe57..438ca252e7 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -19,13 +19,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64'); return new Promise( async (resolve, reject) => { - const result = await fetch('https://zoom.us/oauth/token', { + const result = await fetch('https://zoom.us/oauth/token?grant_type=authorization_code&code=' + code + '&redirect_uri=' + redirectUri, { method: 'POST', - body: JSON.stringify({ - grant_type: 'authorization_code', - code, - redirect_uri: authUrl - }), headers: { Authorization: authHeader } From f44c8b6326771f8244800c9a80be85fc40c70a78 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:14:36 +0200 Subject: [PATCH 07/42] Potentially first working version of token fetching for zoom --- pages/api/integrations/zoom/callback.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 438ca252e7..9f26f2deea 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -1,5 +1,6 @@ import type {NextApiRequest, NextApiResponse} from 'next'; import {getSession} from "next-auth/client"; +import prisma from "../../../../lib/prisma"; const client_id = process.env.ZOOM_CLIENT_ID; const client_secret = process.env.ZOOM_CLIENT_SECRET; @@ -25,16 +26,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) Authorization: authHeader } }) - .then(res => res.text()); - console.log(result); + .then(res => res.json()); - /*const credential = await prisma.credential.create({ + const credential = await prisma.credential.create({ data: { - type: 'google_calendar', - key: 'lel', + type: 'zoom', + key: result.access_token, userId: session.user.id } - });*/ + }); res.redirect('/integrations'); resolve(); From a9a569f876ef910e4e2f9fb87ba67b1ca2cec4ec Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:17:22 +0200 Subject: [PATCH 08/42] Removed scopes variable --- pages/api/integrations/zoom/callback.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 9f26f2deea..ddafccb5a8 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -5,8 +5,6 @@ import prisma from "../../../../lib/prisma"; const client_id = process.env.ZOOM_CLIENT_ID; const client_secret = process.env.ZOOM_CLIENT_SECRET; -const scopes = ['meeting:write:admin', 'meeting:write', 'meeting:read:admin', 'meeting:read']; - export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; @@ -16,7 +14,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoom/callback'); - const authUrl = 'https://zoom.us/oauth/authorize?response_type=code&client_id=' + client_id + '&redirect_uri=' + redirectUri; const authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64'); return new Promise( async (resolve, reject) => { @@ -28,7 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }) .then(res => res.json()); - const credential = await prisma.credential.create({ + await prisma.credential.create({ data: { type: 'zoom', key: result.access_token, From 00f19bd654697b567ceb9874c2c6214265c375b5 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:29:43 +0200 Subject: [PATCH 09/42] Added integration to UI --- pages/integrations/index.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index d732eee1cb..0bc98b09c5 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -10,7 +10,6 @@ import {InformationCircleIcon} from '@heroicons/react/outline'; export default function Home({ integrations }) { const [session, loading] = useSession(); const [showAddModal, setShowAddModal] = useState(false); - if (loading) { return

Loading...

; } @@ -39,7 +38,7 @@ export default function Home({ integrations }) { Add new integration -
+
{integrations.filter( (ig) => ig.credential ).length !== 0 ?
    {integrations.filter(ig => ig.credential).map( (ig) => (
  • @@ -220,6 +219,13 @@ export async function getServerSideProps(context) { title: "Office 365 / Outlook.com Calendar", imageSrc: "integrations/office-365.png", description: "For personal and business accounts", + }, { + installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), + type: "zoom", + credential: credentials.find( (integration) => integration.type === "zoom" ) || null, + title: "Zoom", + imageSrc: "integrations/zoom.png", + description: "For personal and business accounts", } ]; return { From 734d9ad2433345314b6268c0763b1c10d0f71c45 Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 11 Jun 2021 02:32:07 +0200 Subject: [PATCH 10/42] Save whole JSON --- pages/api/integrations/zoom/callback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index ddafccb5a8..29c6c32cda 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -28,7 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await prisma.credential.create({ data: { type: 'zoom', - key: result.access_token, + key: result, userId: session.user.id } }); From 124086d1ca76ee02065e9a9d81ef9a601c9cf38e Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 01:24:13 +0200 Subject: [PATCH 11/42] Added zoom to list --- lib/integrations.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/integrations.ts b/lib/integrations.ts index 44d9c612fb..24ae095448 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -4,6 +4,8 @@ export function getIntegrationName(name: String) { return "Google Calendar"; case "office365_calendar": return "Office 365 Calendar"; + case "zoom": + return "Zoom"; default: return "Unknown"; } From 0bb4678bf7cecc4da499654fbfa96c82d7257064 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 13:59:40 +0200 Subject: [PATCH 12/42] Added subtitle to zoom integrations --- lib/integrations.ts | 2 +- pages/api/integrations/zoom/callback.ts | 2 +- pages/index.tsx | 9 ++++++--- pages/integrations/index.tsx | 5 +++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/integrations.ts b/lib/integrations.ts index 24ae095448..3f71dd2f5b 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -4,7 +4,7 @@ export function getIntegrationName(name: String) { return "Google Calendar"; case "office365_calendar": return "Office 365 Calendar"; - case "zoom": + case "zoom_video": return "Zoom"; default: return "Unknown"; diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoom/callback.ts index 29c6c32cda..8f8b0abf2b 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoom/callback.ts @@ -27,7 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await prisma.credential.create({ data: { - type: 'zoom', + type: 'zoom_video', key: result, userId: session.user.id } diff --git a/pages/index.tsx b/pages/index.tsx index 401d2fd7b4..b83f93e33d 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -2,8 +2,8 @@ import Head from 'next/head'; import Link from 'next/link'; import prisma from '../lib/prisma'; import Shell from '../components/Shell'; -import { signIn, useSession, getSession } from 'next-auth/client'; -import { ClockIcon, CheckIcon, InformationCircleIcon } from '@heroicons/react/outline'; +import {getSession, useSession} from 'next-auth/client'; +import {CheckIcon, ClockIcon, InformationCircleIcon} from '@heroicons/react/outline'; import DonateBanner from '../components/DonateBanner'; function classNames(...classes) { @@ -206,10 +206,13 @@ export default function Home(props) {
  • {integration.type == 'google_calendar' && Google Calendar} {integration.type == 'office365_calendar' && Office 365 / Outlook.com Calendar} + {integration.type == 'zoom_video' && Zoom}
    {integration.type == 'office365_calendar' &&

    Office 365 / Outlook.com Calendar

    } {integration.type == 'google_calendar' &&

    Google Calendar

    } -

    Calendar Integration

    + {integration.type == 'zoom_video' &&

    Zoom

    } + {integration.type.endsWith('_calendar') &&

    Calendar Integration

    } + {integration.type.endsWith('_video') &&

    Video Conferencing

    }
  • )} diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 0bc98b09c5..3596cba15c 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -53,6 +53,7 @@ export default function Home({ integrations }) {

    {ig.title}

    {ig.type.endsWith('_calendar') && Calendar Integration} + {ig.type.endsWith('_video') && Video Conferencing}

@@ -221,8 +222,8 @@ export async function getServerSideProps(context) { description: "For personal and business accounts", }, { installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), - type: "zoom", - credential: credentials.find( (integration) => integration.type === "zoom" ) || null, + type: "zoom_video", + credential: credentials.find( (integration) => integration.type === "zoom_video" ) || null, title: "Zoom", imageSrc: "integrations/zoom.png", description: "For personal and business accounts", From 30f30d76691d21989ed42fc3e56a815832fb5dea Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 14:04:03 +0200 Subject: [PATCH 13/42] Renamed zoom to zoomvideo --- pages/api/integrations/{zoom => zoomvideo}/add.ts | 2 +- pages/api/integrations/{zoom => zoomvideo}/callback.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename pages/api/integrations/{zoom => zoomvideo}/add.ts (96%) rename pages/api/integrations/{zoom => zoomvideo}/callback.ts (97%) diff --git a/pages/api/integrations/zoom/add.ts b/pages/api/integrations/zoomvideo/add.ts similarity index 96% rename from pages/api/integrations/zoom/add.ts rename to pages/api/integrations/zoomvideo/add.ts index f07231a999..a516358672 100644 --- a/pages/api/integrations/zoom/add.ts +++ b/pages/api/integrations/zoomvideo/add.ts @@ -21,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoom/callback'); + const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoomvideo/callback'); const authUrl = 'https://zoom.us/oauth/authorize?response_type=code&client_id=' + client_id + '&redirect_uri=' + redirectUri; res.status(200).json({url: authUrl}); diff --git a/pages/api/integrations/zoom/callback.ts b/pages/api/integrations/zoomvideo/callback.ts similarity index 97% rename from pages/api/integrations/zoom/callback.ts rename to pages/api/integrations/zoomvideo/callback.ts index 8f8b0abf2b..3b2449c53b 100644 --- a/pages/api/integrations/zoom/callback.ts +++ b/pages/api/integrations/zoomvideo/callback.ts @@ -13,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } - const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoom/callback'); + const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoomvideo/callback'); const authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64'); return new Promise( async (resolve, reject) => { From 3cf7ffd6a7c7ba0e52cfa3248a7a372c4bb02fcf Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 15:22:17 +0200 Subject: [PATCH 14/42] First test implementation of video client --- lib/videoClient.ts | 212 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 lib/videoClient.ts diff --git a/lib/videoClient.ts b/lib/videoClient.ts new file mode 100644 index 0000000000..6d5b149b62 --- /dev/null +++ b/lib/videoClient.ts @@ -0,0 +1,212 @@ +function handleErrorsJson(response) { + if (!response.ok) { + response.json().then(console.log); + throw Error(response.statusText); + } + return response.json(); +} + +function handleErrorsRaw(response) { + if (!response.ok) { + response.text().then(console.log); + throw Error(response.statusText); + } + return response.text(); +} + +const zoomAuth = (credential) => { + + const isExpired = (expiryDate) => expiryDate < +(new Date()); + const authHeader = 'Basic ' + Buffer.from(process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET).toString('base64'); + + const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', { + method: 'POST', + headers: { + 'Authorization': authHeader, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + }) + }) + .then(handleErrorsJson) + .then((responseBody) => { + credential.key.access_token = responseBody.access_token; + credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in); + return credential.key.access_token; + }) + + return { + getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) + }; +}; + +interface Person { + name?: string, + email: string, + timeZone: string +} + +interface VideoMeeting { + title: string; + startTime: string; + endTime: string; + description?: string; + timezone: string; + organizer: Person; + attendees: Person[]; +} + +interface VideoApiAdapter { + createMeeting(meeting: VideoMeeting): Promise; + + updateMeeting(uid: String, meeting: VideoMeeting); + + deleteMeeting(uid: String); + + getAvailability(dateFrom, dateTo): Promise; +} + +const ZoomVideo = (credential): VideoApiAdapter => { + + const auth = zoomAuth(credential); + + const translateMeeting = (meeting: VideoMeeting) => { + // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate + return { + topic: meeting.title, + type: 2, // Means that this is a scheduled meeting + start_time: meeting.startTime, + duration: 60, //TODO calculate endTime - startTime (in minutes, int) + //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) + timezone: meeting.timezone, + //password: "string", TODO: Should we use a password? Maybe generate a random one? + agenda: meeting.description, + settings: { + host_video: true, + participant_video: true, + cn_meeting: false, // TODO: true if host meeting in china + in_meeting: false, // TODO: true if host meeting in india + join_before_host: true, + mute_upon_entry: false, + watermark: false, + use_pmi: false, + approval_type: 2, + audio: "both", + auto_recording: "none", + enforce_login: false, + registrants_email_notification: true + } + }; + }; + + return { + getAvailability: (dateFrom, dateTo) => { + /*const payload = { + schedules: [credential.key.email], + startTime: { + dateTime: dateFrom, + timeZone: 'UTC', + }, + endTime: { + dateTime: dateTo, + timeZone: 'UTC', + }, + availabilityViewInterval: 60 + }; + + return auth.getToken().then( + (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { + method: 'post', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(handleErrorsJson) + .then(responseBody => { + return responseBody.value[0].scheduleItems.map((evt) => ({ + start: evt.start.dateTime + 'Z', + end: evt.end.dateTime + 'Z' + })) + }) + ).catch((err) => { + console.log(err); + });*/ + }, + //TODO Also add the client user to the meeting after creation + createMeeting: (meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://zoom.us/users/me/meetings', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(translateMeeting(meeting)) + }).then(handleErrorsJson)), + deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://zoom.us/meetings/' + uid, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer ' + accessToken + } + }).then(handleErrorsRaw)), + updateMeeting: (uid: String, meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://zoom.us/meetings/' + uid, { + method: 'PATCH', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(translateMeeting(meeting)) + }).then(handleErrorsRaw)), + } +}; + +// factory +const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { + switch (cred.type) { + case 'zoom': + return ZoomVideo(cred); + default: + return; // unknown credential, could be legacy? In any case, ignore + } +}).filter(Boolean); + + +const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( + videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) +).then( + (results) => results.reduce((acc, availability) => acc.concat(availability), []) +); + +const createMeeting = (credential, meeting: VideoMeeting): Promise => { + + //TODO Implement email template + /*createNewMeetingEmail( + meeting, + );*/ + + if (credential) { + return videoIntegrations([credential])[0].createMeeting(meeting); + } + + return Promise.resolve({}); +}; + +const updateMeeting = (credential, uid: String, meeting: VideoMeeting): Promise => { + if (credential) { + return videoIntegrations([credential])[0].updateMeeting(uid, meeting); + } + + return Promise.resolve({}); +}; + +const deleteMeeting = (credential, uid: String): Promise => { + if (credential) { + return videoIntegrations([credential])[0].deleteMeeting(uid); + } + + return Promise.resolve({}); +}; + +export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting, VideoMeeting}; From 2ba98c5dac56bb0cd64506a2d378b2f471caa098 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 15:57:01 +0200 Subject: [PATCH 15/42] Test setup to try zoom meeting creation --- pages/api/book/[user].ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 669c8c203e..392dabd1dc 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -5,6 +5,7 @@ import createConfirmBookedEmail from "../../../lib/emails/confirm-booked"; import async from 'async'; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; +import {createMeeting, VideoMeeting} from "../../../lib/videoClient"; const translator = short(); @@ -24,6 +25,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); + // 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 rescheduleUid = req.body.rescheduleUid; const evt: CalendarEvent = { @@ -39,6 +44,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ] }; + //TODO Only create meeting if integration exists. + const meeting: VideoMeeting = { + attendees: [ + {email: req.body.email, name: req.body.name, timeZone: req.body.timeZone} + ], + endTime: req.body.end, + organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone}, + startTime: req.body.start, + timezone: currentUser.timeZone, + title: req.body.eventName + ' with ' + req.body.name, + }; + const hashUID = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); const eventType = await prisma.eventType.findFirst({ @@ -51,7 +68,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - let results = undefined; + let results = []; let referencesToCreate = undefined; if (rescheduleUid) { @@ -73,11 +90,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); // Use all integrations - results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { + results = await async.mapLimit(calendarCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; return await updateEvent(credential, bookingRefUid, evt) }); + //TODO: Reschedule with videoCredentials as well + // Clone elements referencesToCreate = [...booking.references]; @@ -105,13 +124,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ]); } else { // Schedule event - results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { + results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { const response = await createEvent(credential, evt); return { type: credential.type, response }; - }); + })); + + results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { + const response = await createMeeting(credential, meeting); + return { + type: credential.type, + response + }; + })); referencesToCreate = results.map((result => { return { From bc8c58e57c87c02ae69659f8272c621c06b51342 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 16:07:41 +0200 Subject: [PATCH 16/42] Fixed integration identifier --- lib/videoClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 6d5b149b62..811a67d562 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -165,7 +165,7 @@ const ZoomVideo = (credential): VideoApiAdapter => { // factory const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { switch (cred.type) { - case 'zoom': + case 'zoom_video': return ZoomVideo(cred); default: return; // unknown credential, could be legacy? In any case, ignore From 31670d37b84ef1bc45ebc5e258cecd0a4f19d8b4 Mon Sep 17 00:00:00 2001 From: nicolas Date: Sun, 13 Jun 2021 16:18:55 +0200 Subject: [PATCH 17/42] Fixed api urls and env variable names --- lib/videoClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 811a67d562..0f6efcfc1d 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -17,7 +17,7 @@ function handleErrorsRaw(response) { const zoomAuth = (credential) => { const isExpired = (expiryDate) => expiryDate < +(new Date()); - const authHeader = 'Basic ' + Buffer.from(process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET).toString('base64'); + const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64'); const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', { method: 'POST', @@ -137,7 +137,7 @@ const ZoomVideo = (credential): VideoApiAdapter => { });*/ }, //TODO Also add the client user to the meeting after creation - createMeeting: (meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://zoom.us/users/me/meetings', { + createMeeting: (meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { method: 'POST', headers: { 'Authorization': 'Bearer ' + accessToken, @@ -145,13 +145,13 @@ const ZoomVideo = (credential): VideoApiAdapter => { }, body: JSON.stringify(translateMeeting(meeting)) }).then(handleErrorsJson)), - deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://zoom.us/meetings/' + uid, { + deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + accessToken } }).then(handleErrorsRaw)), - updateMeeting: (uid: String, meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://zoom.us/meetings/' + uid, { + updateMeeting: (uid: String, meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { method: 'PATCH', headers: { 'Authorization': 'Bearer ' + accessToken, From 3cf00043b19e54d109c4809f48363b3efd3c339a Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Jun 2021 18:13:54 +0200 Subject: [PATCH 18/42] Persist new refresh token --- lib/videoClient.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 0f6efcfc1d..e48ef404bd 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -1,3 +1,5 @@ +import prisma from "./prisma"; + function handleErrorsJson(response) { if (!response.ok) { response.json().then(console.log); @@ -31,7 +33,16 @@ const zoomAuth = (credential) => { }) }) .then(handleErrorsJson) - .then((responseBody) => { + .then(async (responseBody) => { + // Store new tokens in database. + await prisma.credential.update({ + where: { + id: credential.id + }, + data: { + key: responseBody + } + }); credential.key.access_token = responseBody.access_token; credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in); return credential.key.access_token; From 8e9868db30ffee47ff6931ad63f75d1db33987ca Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Jun 2021 18:47:05 +0200 Subject: [PATCH 19/42] Properly concat results and properly handle zoom meeting uuids --- pages/api/book/[user].ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 68479e24ad..31182d3e05 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -80,7 +80,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); let results = []; - let referencesToCreate = undefined; + let referencesToCreate = []; if (rescheduleUid) { // Reschedule event @@ -135,7 +135,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ]); } else { // Schedule event - results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { + results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { const response = await createEvent(credential, appendLinksToEvents(evt)); return { type: credential.type, @@ -143,7 +143,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; })); - results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { + results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { const response = await createMeeting(credential, meeting); return { type: credential.type, @@ -154,7 +154,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) referencesToCreate = results.map((result => { return { type: result.type, - uid: result.response.id + uid: result.response.uuid ?? result.response.id }; })); } From c4455b74ba6ed7a7ffe59516bb79e380a4a747a1 Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Jun 2021 18:55:20 +0200 Subject: [PATCH 20/42] Rescheduling zoom works --- pages/api/book/[user].ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 31182d3e05..302bcd29c3 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -5,7 +5,7 @@ import createConfirmBookedEmail from "../../../lib/emails/confirm-booked"; import async from 'async'; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; -import {createMeeting, VideoMeeting} from "../../../lib/videoClient"; +import {createMeeting, updateMeeting, VideoMeeting} from "../../../lib/videoClient"; const translator = short(); @@ -101,12 +101,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); // Use all integrations - results = await async.mapLimit(calendarCredentials, 5, async (credential) => { + results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) - }); + })); - //TODO: Reschedule with videoCredentials as well + results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { + const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; + return await updateMeeting(credential, bookingRefUid, meeting) // TODO Maybe append links? + })); // Clone elements referencesToCreate = [...booking.references]; @@ -154,7 +157,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) referencesToCreate = results.map((result => { return { type: result.type, - uid: result.response.uuid ?? result.response.id + uid: result.response.id.toString() }; })); } From 49abf06ce6658178213516bd0fcbc10c522f8d79 Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Jun 2021 19:00:17 +0200 Subject: [PATCH 21/42] Deleting zoom also works --- pages/api/cancel.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 4c6a31f9cd..90ef2f43eb 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -1,6 +1,7 @@ import prisma from '../../lib/prisma'; import {deleteEvent} from "../../lib/calendarClient"; import async from 'async'; +import {deleteMeeting} from "../../lib/videoClient"; export default async function handler(req, res) { if (req.method == "POST") { @@ -29,7 +30,11 @@ export default async function handler(req, res) { const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid; - return await deleteEvent(credential, bookingRefUid); + if(credential.type.endsWith("_calendar")) { + return await deleteEvent(credential, bookingRefUid); + } else if(credential.type.endsWith("_video")) { + return await deleteMeeting(credential, bookingRefUid); + } }); const attendeeDeletes = prisma.attendee.deleteMany({ where: { From 9ff8e9bd003a727f49aebed1efb4342409f82e86 Mon Sep 17 00:00:00 2001 From: nicolas Date: Mon, 14 Jun 2021 19:18:53 +0200 Subject: [PATCH 22/42] Calculate proper duration --- lib/videoClient.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/videoClient.ts b/lib/videoClient.ts index e48ef404bd..62c653c638 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -85,11 +85,11 @@ const ZoomVideo = (credential): VideoApiAdapter => { const translateMeeting = (meeting: VideoMeeting) => { // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate - return { + const meet = { topic: meeting.title, type: 2, // Means that this is a scheduled meeting start_time: meeting.startTime, - duration: 60, //TODO calculate endTime - startTime (in minutes, int) + duration: ((new Date(meeting.endTime)).getTime() - (new Date(meeting.startTime)).getTime()) / 60000, //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) timezone: meeting.timezone, //password: "string", TODO: Should we use a password? Maybe generate a random one? @@ -110,6 +110,8 @@ const ZoomVideo = (credential): VideoApiAdapter => { registrants_email_notification: true } }; + + return meet; }; return { From 51a8bafaa7862e0ca1fdfd9ec4b60d1d800ca30c Mon Sep 17 00:00:00 2001 From: nicolas Date: Wed, 16 Jun 2021 22:14:44 +0200 Subject: [PATCH 23/42] Full zoom integration (except availability check) --- lib/emails/confirm-booked.ts | 53 ++++++++++++++++++++++++++++-------- lib/videoClient.ts | 7 ++--- pages/[user]/book.tsx | 2 +- pages/api/book/[user].ts | 12 ++++++-- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/lib/emails/confirm-booked.ts b/lib/emails/confirm-booked.ts index 6d7898aa47..00ab45144c 100644 --- a/lib/emails/confirm-booked.ts +++ b/lib/emails/confirm-booked.ts @@ -10,21 +10,47 @@ dayjs.extend(localizedFormat); dayjs.extend(utc); dayjs.extend(timezone); -export default function createConfirmBookedEmail(calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, options: any = {}) { +export interface VideoCallData { + type: string; + id: string; + password: string; + url: string; +}; + +export function integrationTypeToName(type: string): string { + //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. + const nameProto = type.split("_")[0]; + return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); +} + +export function formattedId(videoCallData: VideoCallData): string { + switch(videoCallData.type) { + case 'zoom_video': + const strId = videoCallData.id.toString(); + const part1 = strId.slice(0, 3); + const part2 = strId.slice(3, 7); + const part3 = strId.slice(7, 11); + return part1 + " " + part2 + " " + part3; + default: + return videoCallData.id.toString(); + } +} + +export default function createConfirmBookedEmail(calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, options: any = {}, videoCallData?: VideoCallData) { return sendEmail(calEvent, cancelLink, rescheduleLink, { provider: { transport: serverConfig.transport, from: serverConfig.from, }, ...options - }); + }, videoCallData); } const sendEmail = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, { provider, -}) => new Promise( (resolve, reject) => { +}, videoCallData?: VideoCallData) => new Promise((resolve, reject) => { - const { from, transport } = provider; + const {from, transport} = provider; const inviteeStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); nodemailer.createTransport(transport).sendMail( @@ -33,8 +59,8 @@ const sendEmail = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: from: `${calEvent.organizer.name} <${from}>`, replyTo: calEvent.organizer.email, subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`, - html: html(calEvent, cancelLink, rescheduleLink), - text: text(calEvent, cancelLink, rescheduleLink), + html: html(calEvent, cancelLink, rescheduleLink, videoCallData), + text: text(calEvent, cancelLink, rescheduleLink, videoCallData), }, (error, info) => { if (error) { @@ -46,7 +72,7 @@ const sendEmail = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: ) }); -const html = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string) => { +const html = (calEvent: CalendarEvent, cancelLink, rescheduleLink: string, videoCallData?: VideoCallData) => { const inviteeStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); return `
@@ -55,9 +81,14 @@ const html = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: strin Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')} (${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.

` + ( - calEvent.location ? `Location: ${calEvent.location}

` : '' - ) + - `Additional notes:
+ videoCallData ? `Video call provider: ${integrationTypeToName(videoCallData.type)}
+ Meeting ID: ${formattedId(videoCallData)}
+ Meeting Password: ${videoCallData.password}
+ Meeting URL: ${videoCallData.url}

` : '' + ) + ( + calEvent.location ? `Location: ${calEvent.location}

` : '' + ) + + `Additional notes:
${calEvent.description}

Need to change this event?
@@ -67,4 +98,4 @@ const html = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: strin `; }; -const text = (evt: CalendarEvent, cancelLink: string, rescheduleLink: string) => html(evt, cancelLink, rescheduleLink).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file +const text = (evt: CalendarEvent, cancelLink: string, rescheduleLink: string, videoCallData?: VideoCallData) => html(evt, cancelLink, rescheduleLink, videoCallData).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 62c653c638..4a8817dfd7 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -97,8 +97,8 @@ const ZoomVideo = (credential): VideoApiAdapter => { settings: { host_video: true, participant_video: true, - cn_meeting: false, // TODO: true if host meeting in china - in_meeting: false, // TODO: true if host meeting in india + cn_meeting: false, // TODO: true if host meeting in China + in_meeting: false, // TODO: true if host meeting in India join_before_host: true, mute_upon_entry: false, watermark: false, @@ -149,7 +149,6 @@ const ZoomVideo = (credential): VideoApiAdapter => { console.log(err); });*/ }, - //TODO Also add the client user to the meeting after creation createMeeting: (meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { method: 'POST', headers: { @@ -194,7 +193,7 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( const createMeeting = (credential, meeting: VideoMeeting): Promise => { - //TODO Implement email template + //TODO Send email to event host /*createNewMeetingEmail( meeting, );*/ diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index f27eda37c8..4598adcd46 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -76,7 +76,7 @@ export default function Book(props) { } ); - let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1`; + let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}`; if (payload['location']) { successUrl += "&location=" + encodeURIComponent(payload['location']); } diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 302bcd29c3..8f4a2a4fec 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,7 +1,7 @@ import type {NextApiRequest, NextApiResponse} from 'next'; import prisma from '../../../lib/prisma'; import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient'; -import createConfirmBookedEmail from "../../../lib/emails/confirm-booked"; +import createConfirmBookedEmail, {VideoCallData} from "../../../lib/emails/confirm-booked"; import async from 'async'; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; @@ -182,10 +182,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); + const videoResults = results.filter((res) => res.type.endsWith('_video')); + const videoCallData: VideoCallData = videoResults.length === 0 ? undefined : { + type: videoResults[0].type, + id: videoResults[0].response.id, + password: videoResults[0].response.password, + url: videoResults[0].response.join_url, + }; + // 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 + evt, cancelLink, rescheduleLink, {}, videoCallData ); } From e37dd017c8066fa72c8e8621f2064d610f7bddb7 Mon Sep 17 00:00:00 2001 From: nicolas Date: Wed, 16 Jun 2021 23:40:13 +0200 Subject: [PATCH 24/42] Introduced EventOwnerMail and VideoEventOwnerMail as class based implementations --- lib/calendarClient.ts | 20 ++-- lib/emails/EventOwnerMail.ts | 150 ++++++++++++++++++++++++++++++ lib/emails/VideoEventOwnerMail.ts | 27 ++++++ lib/videoClient.ts | 82 ++++++++-------- pages/api/book/[user].ts | 33 ++----- 5 files changed, 231 insertions(+), 81 deletions(-) create mode 100644 lib/emails/EventOwnerMail.ts create mode 100644 lib/emails/VideoEventOwnerMail.ts diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index c11348f483..fa47a3f6e5 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,5 +1,6 @@ +import EventOwnerMail from "./emails/EventOwnerMail"; + const {google} = require('googleapis'); -import createNewEventEmail from "./emails/new-event"; const googleAuth = () => { const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; @@ -323,17 +324,16 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createEvent = (credential, calEvent: CalendarEvent): Promise => { +const createEvent = async (credential, calEvent: CalendarEvent): Promise => { + const mail = new EventOwnerMail(calEvent); + const sentMail = await mail.sendEmail(); - createNewEventEmail( - calEvent, - ); + const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; - if (credential) { - return calendars([credential])[0].createEvent(calEvent); - } - - return Promise.resolve({}); + return { + createdEvent: creationResult, + sentMail: sentMail + }; }; const updateEvent = (credential, uid: String, calEvent: CalendarEvent): Promise => { diff --git a/lib/emails/EventOwnerMail.ts b/lib/emails/EventOwnerMail.ts new file mode 100644 index 0000000000..82aa82b751 --- /dev/null +++ b/lib/emails/EventOwnerMail.ts @@ -0,0 +1,150 @@ +import {CalendarEvent} from "../calendarClient"; +import {createEvent} from "ics"; +import dayjs, {Dayjs} from "dayjs"; +import {serverConfig} from "../serverConfig"; +import nodemailer from 'nodemailer'; + +export default class EventOwnerMail { + calEvent: CalendarEvent; + + /** + * An EventOwnerMail always consists of a CalendarEvent + * that stores the very basic data of the event (like date, title etc). + * + * @param calEvent + */ + constructor(calEvent: CalendarEvent) { + this.calEvent = calEvent; + } + + /** + * Returns the instance's event as an iCal event in string representation. + * @protected + */ + protected getiCalEventAsString(): string { + const icsEvent = createEvent({ + start: dayjs(this.calEvent.startTime).utc().toArray().slice(0, 6), + startInputType: 'utc', + productId: 'calendso/ics', + title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`, + description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()), + duration: {minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), 'minute')}, + organizer: {name: this.calEvent.organizer.name, email: this.calEvent.organizer.email}, + attendees: this.calEvent.attendees.map((attendee: any) => ({name: attendee.name, email: attendee.email})), + status: "CONFIRMED", + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; + } + + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected getHtmlRepresentation(): string { + return ` +
+ Hi ${this.calEvent.organizer.name},
+
+ A new event has been scheduled.
+
+ Event Type:
+ ${this.calEvent.type}
+
+ Invitee Email:
+ ${this.calEvent.attendees[0].email}
+
` + this.getAdditionalBody() + + ( + this.calEvent.location ? ` + Location:
+ ${this.calEvent.location}
+
+ ` : '' + ) + + `Invitee Time Zone:
+ ${this.calEvent.attendees[0].timeZone}
+
+ Additional notes:
+ ${this.calEvent.description} +
+ `; + } + + /** + * Returns the email text in a plain text representation + * by stripping off the HTML tags. + * + * @protected + */ + protected getPlainTextRepresentation(): string { + return this.stripHtml(this.getHtmlRepresentation()); + } + + /** + * Strips off all HTML tags and leaves plain text. + * + * @param html + * @protected + */ + protected stripHtml(html: string): string { + return html + .replace('
', "\n") + .replace(/<[^>]+>/g, ''); + } + + /** + * Sends the email to the event attendant and returns a Promise. + */ + public sendEmail(): Promise { + const options = this.getMailerOptions(); + const {transport, from} = options; + const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); + + return new Promise((resolve, reject) => nodemailer.createTransport(transport).sendMail( + { + icalEvent: { + filename: 'event.ics', + content: this.getiCalEventAsString(), + }, + from: `Calendso <${from}>`, + to: this.calEvent.organizer.email, + subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), + }, + (error, info) => { + if (error) { + console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); + reject(new Error(error)); + } else { + resolve(info); + } + })); + } + + /** + * Gathers the required provider information from the config. + * + * @protected + */ + protected getMailerOptions(): any { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + /** + * Can be used to include additional HTML or plain text + * content into the mail body and calendar event description. + * Leave it to an empty string if not desired. + * + * @protected + */ + protected getAdditionalBody(): string { + return ""; + } +} \ No newline at end of file diff --git a/lib/emails/VideoEventOwnerMail.ts b/lib/emails/VideoEventOwnerMail.ts new file mode 100644 index 0000000000..c170456f5d --- /dev/null +++ b/lib/emails/VideoEventOwnerMail.ts @@ -0,0 +1,27 @@ +import {CalendarEvent} from "../calendarClient"; +import EventOwnerMail from "./EventOwnerMail"; +import {formattedId, integrationTypeToName, VideoCallData} from "./confirm-booked"; + +export default class VideoEventOwnerMail extends EventOwnerMail { + videoCallData: VideoCallData; + + constructor(calEvent: CalendarEvent, videoCallData: VideoCallData) { + super(calEvent); + this.videoCallData = videoCallData; + } + + /** + * Adds the video call information to the mail body + * and calendar event description. + * + * @protected + */ + protected getAdditionalBody(): string { + return ` + Video call provider: ${integrationTypeToName(this.videoCallData.type)}
+ Meeting ID: ${formattedId(this.videoCallData)}
+ Meeting Password: ${this.videoCallData.password}
+ Meeting URL: ${this.videoCallData.url}
+ `; + } +} \ No newline at end of file diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 4a8817dfd7..041c9fa494 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -1,4 +1,7 @@ import prisma from "./prisma"; +import {VideoCallData} from "./emails/confirm-booked"; +import {CalendarEvent} from "./calendarClient"; +import VideoEventOwnerMail from "./emails/VideoEventOwnerMail"; function handleErrorsJson(response) { if (!response.ok) { @@ -53,26 +56,10 @@ const zoomAuth = (credential) => { }; }; -interface Person { - name?: string, - email: string, - timeZone: string -} - -interface VideoMeeting { - title: string; - startTime: string; - endTime: string; - description?: string; - timezone: string; - organizer: Person; - attendees: Person[]; -} - interface VideoApiAdapter { - createMeeting(meeting: VideoMeeting): Promise; + createMeeting(event: CalendarEvent): Promise; - updateMeeting(uid: String, meeting: VideoMeeting); + updateMeeting(uid: String, event: CalendarEvent); deleteMeeting(uid: String); @@ -83,17 +70,17 @@ const ZoomVideo = (credential): VideoApiAdapter => { const auth = zoomAuth(credential); - const translateMeeting = (meeting: VideoMeeting) => { + const translateEvent = (event: CalendarEvent) => { // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate - const meet = { - topic: meeting.title, + return { + topic: event.title, type: 2, // Means that this is a scheduled meeting - start_time: meeting.startTime, - duration: ((new Date(meeting.endTime)).getTime() - (new Date(meeting.startTime)).getTime()) / 60000, + start_time: event.startTime, + duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000, //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) - timezone: meeting.timezone, + timezone: event.attendees[0].timeZone, //password: "string", TODO: Should we use a password? Maybe generate a random one? - agenda: meeting.description, + agenda: event.description, settings: { host_video: true, participant_video: true, @@ -110,8 +97,6 @@ const ZoomVideo = (credential): VideoApiAdapter => { registrants_email_notification: true } }; - - return meet; }; return { @@ -149,13 +134,13 @@ const ZoomVideo = (credential): VideoApiAdapter => { console.log(err); });*/ }, - createMeeting: (meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { + createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { method: 'POST', headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json', }, - body: JSON.stringify(translateMeeting(meeting)) + body: JSON.stringify(translateEvent(event)) }).then(handleErrorsJson)), deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { method: 'DELETE', @@ -163,13 +148,13 @@ const ZoomVideo = (credential): VideoApiAdapter => { 'Authorization': 'Bearer ' + accessToken } }).then(handleErrorsRaw)), - updateMeeting: (uid: String, meeting: VideoMeeting) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { + updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { method: 'PATCH', headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' }, - body: JSON.stringify(translateMeeting(meeting)) + body: JSON.stringify(translateEvent(event)) }).then(handleErrorsRaw)), } }; @@ -191,23 +176,32 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = (credential, meeting: VideoMeeting): Promise => { - - //TODO Send email to event host - /*createNewMeetingEmail( - meeting, - );*/ - - if (credential) { - return videoIntegrations([credential])[0].createMeeting(meeting); +const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { + if(!credential) { + throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called."); } - return Promise.resolve({}); + const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent); + + const videoCallData: VideoCallData = { + type: credential.type, + id: creationResult.id, + password: creationResult.password, + url: creationResult.join_url, + }; + + const mail = new VideoEventOwnerMail(calEvent, videoCallData); + const sentMail = await mail.sendEmail(); + + return { + createdEvent: creationResult, + sentMail: sentMail + }; }; -const updateMeeting = (credential, uid: String, meeting: VideoMeeting): Promise => { +const updateMeeting = (credential, uid: String, event: CalendarEvent): Promise => { if (credential) { - return videoIntegrations([credential])[0].updateMeeting(uid, meeting); + return videoIntegrations([credential])[0].updateMeeting(uid, event); } return Promise.resolve({}); @@ -221,4 +215,4 @@ const deleteMeeting = (credential, uid: String): Promise => { return Promise.resolve({}); }; -export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting, VideoMeeting}; +export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting}; diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 8f4a2a4fec..f488b3bb23 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,11 +1,10 @@ import type {NextApiRequest, NextApiResponse} from 'next'; import prisma from '../../../lib/prisma'; import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient'; -import createConfirmBookedEmail, {VideoCallData} from "../../../lib/emails/confirm-booked"; import async from 'async'; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; -import {createMeeting, updateMeeting, VideoMeeting} from "../../../lib/videoClient"; +import {createMeeting, updateMeeting} from "../../../lib/videoClient"; const translator = short(); @@ -44,18 +43,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ] }; - //TODO Only create meeting if integration exists. - const meeting: VideoMeeting = { - attendees: [ - {email: req.body.email, name: req.body.name, timeZone: req.body.timeZone} - ], - endTime: req.body.end, - organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone}, - startTime: req.body.start, - timezone: currentUser.timeZone, - title: req.body.eventName + ' with ' + req.body.name, - }; - const hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID; const rescheduleLink:string = process.env.BASE_URL + '/reschedule/' + hashUID; @@ -108,7 +95,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateMeeting(credential, bookingRefUid, meeting) // TODO Maybe append links? + return await updateMeeting(credential, bookingRefUid, evt) // TODO Maybe append links? })); // Clone elements @@ -147,7 +134,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { - const response = await createMeeting(credential, meeting); + const response = await createMeeting(credential, evt); return { type: credential.type, response @@ -157,7 +144,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) referencesToCreate = results.map((result => { return { type: result.type, - uid: result.response.id.toString() + uid: result.response.createdEvent.id.toString() }; })); } @@ -182,20 +169,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - const videoResults = results.filter((res) => res.type.endsWith('_video')); - const videoCallData: VideoCallData = videoResults.length === 0 ? undefined : { - type: videoResults[0].type, - id: videoResults[0].response.id, - password: videoResults[0].response.password, - url: videoResults[0].response.join_url, - }; - // 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)) { + /*if (currentUser.credentials.length === 0 || !results.every((result) => result.disableConfirmationEmail)) { await createConfirmBookedEmail( evt, cancelLink, rescheduleLink, {}, videoCallData ); - } + }*/ res.status(200).json(results); } From 04e0b55b517852e8bb5be74ac69cad0388283873 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 00:26:51 +0200 Subject: [PATCH 25/42] Introduced more classes for event mails --- lib/calendarClient.ts | 4 +- lib/emails/EventAttendeeMail.ts | 55 +++++++++++ lib/emails/EventMail.ts | 135 +++++++++++++++++++++++++++ lib/emails/EventOwnerMail.ts | 97 ++++--------------- lib/emails/VideoEventAttendeeMail.ts | 45 +++++++++ lib/emails/VideoEventOwnerMail.ts | 4 +- lib/emails/new-event.ts | 99 -------------------- lib/videoClient.ts | 4 +- pages/api/book/[user].ts | 19 +--- 9 files changed, 262 insertions(+), 200 deletions(-) create mode 100644 lib/emails/EventAttendeeMail.ts create mode 100644 lib/emails/EventMail.ts create mode 100644 lib/emails/VideoEventAttendeeMail.ts delete mode 100644 lib/emails/new-event.ts diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index fa47a3f6e5..4b65cc8bd5 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -324,8 +324,8 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createEvent = async (credential, calEvent: CalendarEvent): Promise => { - const mail = new EventOwnerMail(calEvent); +const createEvent = async (credential, calEvent: CalendarEvent, hashUID: string): Promise => { + const mail = new EventOwnerMail(calEvent, hashUID); const sentMail = await mail.sendEmail(); const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts new file mode 100644 index 0000000000..15ef16aeea --- /dev/null +++ b/lib/emails/EventAttendeeMail.ts @@ -0,0 +1,55 @@ +import dayjs, {Dayjs} from "dayjs"; +import EventMail from "./EventMail"; + +export default class EventAttendeeMail extends EventMail { + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected getHtmlRepresentation(): string { + return ` +
+ Hi ${this.calEvent.attendees[0].name},
+
+ Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format('h:mma')} + (${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.
+
` + this.getAdditionalBody() + ( + this.calEvent.location ? `Location: ${this.calEvent.location}

` : '' + ) + + `Additional notes:
+ ${this.calEvent.description} + ` + this.getAdditionalFooter() + ` +
+ `; + } + + /** + * Returns the payload object for the nodemailer. + * + * @protected + */ + protected getNodeMailerPayload(): Object { + return { + to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `Confirmed: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), + }; + } + + protected printNodeMailerError(error: string): void { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); + } + + /** + * Returns the inviteeStart value used at multiple points. + * + * @private + */ + private getInviteeStart(): Dayjs { + return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); + } +} \ No newline at end of file diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts new file mode 100644 index 0000000000..2d4d8489bd --- /dev/null +++ b/lib/emails/EventMail.ts @@ -0,0 +1,135 @@ +import {CalendarEvent} from "../calendarClient"; +import {serverConfig} from "../serverConfig"; +import nodemailer from 'nodemailer'; + +export default abstract class EventMail { + calEvent: CalendarEvent; + uid: string; + + /** + * An EventMail always consists of a CalendarEvent + * that stores the very basic data of the event (like date, title etc). + * It also needs the UID of the stored booking in our database. + * + * @param calEvent + * @param uid + */ + constructor(calEvent: CalendarEvent, uid: string) { + this.calEvent = calEvent; + this.uid = uid; + } + + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected abstract getHtmlRepresentation(): string; + + /** + * Returns the email text in a plain text representation + * by stripping off the HTML tags. + * + * @protected + */ + protected getPlainTextRepresentation(): string { + return this.stripHtml(this.getHtmlRepresentation()); + } + + /** + * Strips off all HTML tags and leaves plain text. + * + * @param html + * @protected + */ + protected stripHtml(html: string): string { + return html + .replace('
', "\n") + .replace(/<[^>]+>/g, ''); + } + + /** + * Returns the payload object for the nodemailer. + * @protected + */ + protected abstract getNodeMailerPayload(): Object; + + /** + * 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( + this.getNodeMailerPayload(), + (error, info) => { + if (error) { + this.printNodeMailerError(error); + reject(new Error(error)); + } else { + resolve(info); + } + })); + } + + /** + * Gathers the required provider information from the config. + * + * @protected + */ + protected getMailerOptions(): any { + return { + transport: serverConfig.transport, + from: serverConfig.from, + }; + } + + /** + * Can be used to include additional HTML or plain text + * content into the mail body. Leave it to an empty + * string if not desired. + * + * @protected + */ + protected getAdditionalBody(): string { + return ""; + } + + /** + * Prints out the desired information when an error + * occured while sending the mail. + * @param error + * @protected + */ + protected abstract printNodeMailerError(error: string): void; + + /** + * Returns a link to reschedule the given booking. + * + * @protected + */ + protected getRescheduleLink(): string { + return process.env.BASE_URL + '/reschedule/' + this.uid; + } + + /** + * Returns a link to cancel the given booking. + * + * @protected + */ + protected getCancelLink(): string { + return process.env.BASE_URL + '/cancel/' + this.uid; + } + + + /** + * Defines a footer that will be appended to the email. + * @protected + */ + protected getAdditionalFooter(): string { + return ` +
+ Need to change this event?
+ Cancel: ${this.getCancelLink()}
+ Reschedule: ${this.getRescheduleLink()} + `; + } +} \ No newline at end of file diff --git a/lib/emails/EventOwnerMail.ts b/lib/emails/EventOwnerMail.ts index 82aa82b751..762a73bea5 100644 --- a/lib/emails/EventOwnerMail.ts +++ b/lib/emails/EventOwnerMail.ts @@ -1,22 +1,8 @@ -import {CalendarEvent} from "../calendarClient"; import {createEvent} from "ics"; import dayjs, {Dayjs} from "dayjs"; -import {serverConfig} from "../serverConfig"; -import nodemailer from 'nodemailer'; - -export default class EventOwnerMail { - calEvent: CalendarEvent; - - /** - * An EventOwnerMail always consists of a CalendarEvent - * that stores the very basic data of the event (like date, title etc). - * - * @param calEvent - */ - constructor(calEvent: CalendarEvent) { - this.calEvent = calEvent; - } +import EventMail from "./EventMail"; +export default class EventOwnerMail extends EventMail { /** * Returns the instance's event as an iCal event in string representation. * @protected @@ -27,7 +13,7 @@ export default class EventOwnerMail { startInputType: 'utc', productId: 'calendso/ics', title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`, - description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()), + description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.stripHtml(this.getAdditionalFooter()), duration: {minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), 'minute')}, organizer: {name: this.calEvent.organizer.name, email: this.calEvent.organizer.email}, attendees: this.calEvent.attendees.map((attendee: any) => ({name: attendee.name, email: attendee.email})), @@ -69,82 +55,33 @@ export default class EventOwnerMail {
Additional notes:
${this.calEvent.description} + ` + this.getAdditionalFooter() + `
`; } /** - * Returns the email text in a plain text representation - * by stripping off the HTML tags. + * Returns the payload object for the nodemailer. * * @protected */ - protected getPlainTextRepresentation(): string { - return this.stripHtml(this.getHtmlRepresentation()); - } - - /** - * Strips off all HTML tags and leaves plain text. - * - * @param html - * @protected - */ - protected stripHtml(html: string): string { - return html - .replace('
', "\n") - .replace(/<[^>]+>/g, ''); - } - - /** - * Sends the email to the event attendant and returns a Promise. - */ - public sendEmail(): Promise { - const options = this.getMailerOptions(); - const {transport, from} = options; + protected getNodeMailerPayload(): Object { const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return new Promise((resolve, reject) => nodemailer.createTransport(transport).sendMail( - { - icalEvent: { - filename: 'event.ics', - content: this.getiCalEventAsString(), - }, - from: `Calendso <${from}>`, - to: this.calEvent.organizer.email, - subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, - html: this.getHtmlRepresentation(), - text: this.getPlainTextRepresentation(), - }, - (error, info) => { - if (error) { - console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); - reject(new Error(error)); - } else { - resolve(info); - } - })); - } - - /** - * Gathers the required provider information from the config. - * - * @protected - */ - protected getMailerOptions(): any { return { - transport: serverConfig.transport, - from: serverConfig.from, + icalEvent: { + filename: 'event.ics', + content: this.getiCalEventAsString(), + }, + from: `Calendso <${this.getMailerOptions().from}>`, + to: this.calEvent.organizer.email, + subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), }; } - /** - * Can be used to include additional HTML or plain text - * content into the mail body and calendar event description. - * Leave it to an empty string if not desired. - * - * @protected - */ - protected getAdditionalBody(): string { - return ""; + protected printNodeMailerError(error: string): void { + console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } } \ No newline at end of file diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts new file mode 100644 index 0000000000..9ec4edf2e7 --- /dev/null +++ b/lib/emails/VideoEventAttendeeMail.ts @@ -0,0 +1,45 @@ +import {VideoCallData} from "./confirm-booked"; +import {CalendarEvent} from "../calendarClient"; +import EventAttendeeMail from "./EventAttendeeMail"; + +export default class VideoEventAttendeeMail extends EventAttendeeMail { + videoCallData: VideoCallData; + + constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { + super(calEvent, uid); + this.videoCallData = videoCallData; + } + + private getIntegrationName(): string { + //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. + const nameProto = this.videoCallData.type.split("_")[0]; + return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); + } + + private getFormattedMeetingId(): string { + switch(this.videoCallData.type) { + case 'zoom_video': + const strId = this.videoCallData.id.toString(); + const part1 = strId.slice(0, 3); + const part2 = strId.slice(3, 7); + const part3 = strId.slice(7, 11); + return part1 + " " + part2 + " " + part3; + default: + return this.videoCallData.id.toString(); + } + } + + /** + * Adds the video call information to the mail body. + * + * @protected + */ + protected getAdditionalBody(): string { + return ` + Video call provider: ${this.getIntegrationName()}
+ Meeting ID: ${this.getFormattedMeetingId()}
+ Meeting Password: ${this.videoCallData.password}
+ Meeting URL: ${this.videoCallData.url}
+ `; + } +} \ No newline at end of file diff --git a/lib/emails/VideoEventOwnerMail.ts b/lib/emails/VideoEventOwnerMail.ts index c170456f5d..597abd9154 100644 --- a/lib/emails/VideoEventOwnerMail.ts +++ b/lib/emails/VideoEventOwnerMail.ts @@ -5,8 +5,8 @@ import {formattedId, integrationTypeToName, VideoCallData} from "./confirm-booke export default class VideoEventOwnerMail extends EventOwnerMail { videoCallData: VideoCallData; - constructor(calEvent: CalendarEvent, videoCallData: VideoCallData) { - super(calEvent); + constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { + super(calEvent, uid); this.videoCallData = videoCallData; } diff --git a/lib/emails/new-event.ts b/lib/emails/new-event.ts deleted file mode 100644 index 0513ee6716..0000000000 --- a/lib/emails/new-event.ts +++ /dev/null @@ -1,99 +0,0 @@ - -import nodemailer from 'nodemailer'; -import dayjs, { Dayjs } from "dayjs"; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; -import toArray from 'dayjs/plugin/toArray'; -import { createEvent } from 'ics'; -import { CalendarEvent } from '../calendarClient'; -import { serverConfig } from '../serverConfig'; - -dayjs.extend(localizedFormat); -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(toArray); - -export default function createNewEventEmail(calEvent: CalendarEvent, options: any = {}) { - return sendEmail(calEvent, { - provider: { - transport: serverConfig.transport, - from: serverConfig.from, - }, - ...options - }); -} - -const icalEventAsString = (calEvent: CalendarEvent): string => { - const icsEvent = createEvent({ - start: dayjs(calEvent.startTime).utc().toArray().slice(0, 6), - startInputType: 'utc', - productId: 'calendso/ics', - title: `${calEvent.type} with ${calEvent.attendees[0].name}`, - description: calEvent.description, - duration: { minutes: dayjs(calEvent.endTime).diff(dayjs(calEvent.startTime), 'minute') }, - organizer: { name: calEvent.organizer.name, email: calEvent.organizer.email }, - attendees: calEvent.attendees.map( (attendee: any) => ({ name: attendee.name, email: attendee.email }) ), - status: "CONFIRMED", - }); - if (icsEvent.error) { - throw icsEvent.error; - } - return icsEvent.value; -} - -const sendEmail = (calEvent: CalendarEvent, { - provider, -}) => new Promise( (resolve, reject) => { - const { transport, from } = provider; - const organizerStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.organizer.timeZone); - nodemailer.createTransport(transport).sendMail( - { - icalEvent: { - filename: 'event.ics', - content: icalEventAsString(calEvent), - }, - from: `Calendso <${from}>`, - to: calEvent.organizer.email, - subject: `New event: ${calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${calEvent.type}`, - html: html(calEvent), - text: text(calEvent), - }, - (error) => { - if (error) { - console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", calEvent.organizer.email, error); - return reject(new Error(error)); - } - return resolve(); - }); -}); - -const html = (evt: CalendarEvent) => ` -
- Hi ${evt.organizer.name},
-
- A new event has been scheduled.
-
- Event Type:
- ${evt.type}
-
- Invitee Email:
- ${evt.attendees[0].email}
-
` + - ( - evt.location ? ` - Location:
- ${evt.location}
-
- ` : '' - ) + - `Invitee Time Zone:
- ${evt.attendees[0].timeZone}
-
- Additional notes:
- ${evt.description} -
-`; - -// just strip all HTML and convert
to \n -const text = (evt: CalendarEvent) => html(evt).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 041c9fa494..24008c78d8 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -176,7 +176,7 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { +const createMeeting = async (credential, calEvent: CalendarEvent, hashUID: string): Promise => { if(!credential) { throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called."); } @@ -190,7 +190,7 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise url: creationResult.join_url, }; - const mail = new VideoEventOwnerMail(calEvent, videoCallData); + const mail = new VideoEventOwnerMail(calEvent, hashUID, videoCallData); const sentMail = await mail.sendEmail(); return { diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index f488b3bb23..0598a456d0 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -44,17 +44,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; const hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); - const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID; - const rescheduleLink:string = process.env.BASE_URL + '/reschedule/' + hashUID; - const appendLinksToEvents = (event: CalendarEvent) => { - const eventCopy = {...event}; - eventCopy.description += "\n\n" - + "Need to change this event?\n" - + "Cancel: " + cancelLink + "\n" - + "Reschedule:" + rescheduleLink; - - return eventCopy; - } const eventType = await prisma.eventType.findFirst({ where: { @@ -90,12 +79,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Use all integrations results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) + return await updateEvent(credential, bookingRefUid, evt) })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateMeeting(credential, bookingRefUid, evt) // TODO Maybe append links? + return await updateMeeting(credential, bookingRefUid, evt) })); // Clone elements @@ -126,7 +115,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } else { // Schedule event results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { - const response = await createEvent(credential, appendLinksToEvents(evt)); + const response = await createEvent(credential, evt, hashUID); return { type: credential.type, response @@ -134,7 +123,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { - const response = await createMeeting(credential, evt); + const response = await createMeeting(credential, evt, hashUID); return { type: credential.type, response From f56ced0ff1fc1528c9d0749c08ff4ac8e2591f50 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 00:56:02 +0200 Subject: [PATCH 26/42] Re-implemented event confirmation mails object based --- lib/calendarClient.ts | 22 +- lib/emails/VideoEventAttendeeMail.ts | 26 +- lib/emails/VideoEventOwnerMail.ts | 7 +- lib/emails/confirm-booked.ts | 101 -------- lib/emails/helpers.ts | 20 ++ lib/videoClient.ts | 344 ++++++++++++++------------- pages/api/book/[user].ts | 17 +- 7 files changed, 232 insertions(+), 305 deletions(-) delete mode 100644 lib/emails/confirm-booked.ts create mode 100644 lib/emails/helpers.ts diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 4b65cc8bd5..d3d0bb3e74 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,4 +1,9 @@ import EventOwnerMail from "./emails/EventOwnerMail"; +import EventAttendeeMail from "./emails/EventAttendeeMail"; +import {v5 as uuidv5} from 'uuid'; +import short from 'short-uuid'; + +const translator = short(); const {google} = require('googleapis'); @@ -324,15 +329,22 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createEvent = async (credential, calEvent: CalendarEvent, hashUID: string): Promise => { - const mail = new EventOwnerMail(calEvent, hashUID); - const sentMail = await mail.sendEmail(); +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 ownerMail = new EventOwnerMail(calEvent, uid); + const attendeeMail = new EventAttendeeMail(calEvent, uid); + await ownerMail.sendEmail(); + + if(!creationResult || !creationResult.disableConfirmationEmail) { + await attendeeMail.sendEmail(); + } + return { - createdEvent: creationResult, - sentMail: sentMail + uid, + createdEvent: creationResult }; }; diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts index 9ec4edf2e7..7855f36d50 100644 --- a/lib/emails/VideoEventAttendeeMail.ts +++ b/lib/emails/VideoEventAttendeeMail.ts @@ -1,6 +1,7 @@ -import {VideoCallData} from "./confirm-booked"; import {CalendarEvent} from "../calendarClient"; import EventAttendeeMail from "./EventAttendeeMail"; +import {getFormattedMeetingId, getIntegrationName} from "./helpers"; +import {VideoCallData} from "../videoClient"; export default class VideoEventAttendeeMail extends EventAttendeeMail { videoCallData: VideoCallData; @@ -10,25 +11,6 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail { this.videoCallData = videoCallData; } - private getIntegrationName(): string { - //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. - const nameProto = this.videoCallData.type.split("_")[0]; - return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); - } - - private getFormattedMeetingId(): string { - switch(this.videoCallData.type) { - case 'zoom_video': - const strId = this.videoCallData.id.toString(); - const part1 = strId.slice(0, 3); - const part2 = strId.slice(3, 7); - const part3 = strId.slice(7, 11); - return part1 + " " + part2 + " " + part3; - default: - return this.videoCallData.id.toString(); - } - } - /** * Adds the video call information to the mail body. * @@ -36,8 +18,8 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail { */ protected getAdditionalBody(): string { return ` - Video call provider: ${this.getIntegrationName()}
- Meeting ID: ${this.getFormattedMeetingId()}
+ Video call provider: ${getIntegrationName(this.videoCallData)}
+ Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
Meeting Password: ${this.videoCallData.password}
Meeting URL: ${this.videoCallData.url}
`; diff --git a/lib/emails/VideoEventOwnerMail.ts b/lib/emails/VideoEventOwnerMail.ts index 597abd9154..515f123156 100644 --- a/lib/emails/VideoEventOwnerMail.ts +++ b/lib/emails/VideoEventOwnerMail.ts @@ -1,6 +1,7 @@ import {CalendarEvent} from "../calendarClient"; import EventOwnerMail from "./EventOwnerMail"; -import {formattedId, integrationTypeToName, VideoCallData} from "./confirm-booked"; +import {VideoCallData} from "../videoClient"; +import {getFormattedMeetingId, getIntegrationName} from "./helpers"; export default class VideoEventOwnerMail extends EventOwnerMail { videoCallData: VideoCallData; @@ -18,8 +19,8 @@ export default class VideoEventOwnerMail extends EventOwnerMail { */ protected getAdditionalBody(): string { return ` - Video call provider: ${integrationTypeToName(this.videoCallData.type)}
- Meeting ID: ${formattedId(this.videoCallData)}
+ Video call provider: ${getIntegrationName(this.videoCallData)}
+ Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
Meeting Password: ${this.videoCallData.password}
Meeting URL: ${this.videoCallData.url}
`; diff --git a/lib/emails/confirm-booked.ts b/lib/emails/confirm-booked.ts deleted file mode 100644 index 00ab45144c..0000000000 --- a/lib/emails/confirm-booked.ts +++ /dev/null @@ -1,101 +0,0 @@ -import nodemailer from 'nodemailer'; -import {serverConfig} from "../serverConfig"; -import {CalendarEvent} from "../calendarClient"; -import dayjs, {Dayjs} from "dayjs"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; - -dayjs.extend(localizedFormat); -dayjs.extend(utc); -dayjs.extend(timezone); - -export interface VideoCallData { - type: string; - id: string; - password: string; - url: string; -}; - -export function integrationTypeToName(type: string): string { - //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. - const nameProto = type.split("_")[0]; - return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); -} - -export function formattedId(videoCallData: VideoCallData): string { - switch(videoCallData.type) { - case 'zoom_video': - const strId = videoCallData.id.toString(); - const part1 = strId.slice(0, 3); - const part2 = strId.slice(3, 7); - const part3 = strId.slice(7, 11); - return part1 + " " + part2 + " " + part3; - default: - return videoCallData.id.toString(); - } -} - -export default function createConfirmBookedEmail(calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, options: any = {}, videoCallData?: VideoCallData) { - return sendEmail(calEvent, cancelLink, rescheduleLink, { - provider: { - transport: serverConfig.transport, - from: serverConfig.from, - }, - ...options - }, videoCallData); -} - -const sendEmail = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, { - provider, -}, videoCallData?: VideoCallData) => new Promise((resolve, reject) => { - - const {from, transport} = provider; - const inviteeStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); - - nodemailer.createTransport(transport).sendMail( - { - to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`, - from: `${calEvent.organizer.name} <${from}>`, - replyTo: calEvent.organizer.email, - subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`, - html: html(calEvent, cancelLink, rescheduleLink, videoCallData), - text: text(calEvent, cancelLink, rescheduleLink, videoCallData), - }, - (error, info) => { - if (error) { - console.error("SEND_BOOKING_CONFIRMATION_ERROR", calEvent.attendees[0].email, error); - return reject(new Error(error)); - } - return resolve(); - } - ) -}); - -const html = (calEvent: CalendarEvent, cancelLink, rescheduleLink: string, videoCallData?: VideoCallData) => { - const inviteeStart: Dayjs = dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); - return ` -
- Hi ${calEvent.attendees[0].name},
-
- Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')} - (${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.
-
` + ( - videoCallData ? `Video call provider: ${integrationTypeToName(videoCallData.type)}
- Meeting ID: ${formattedId(videoCallData)}
- Meeting Password: ${videoCallData.password}
- Meeting URL: ${videoCallData.url}

` : '' - ) + ( - calEvent.location ? `Location: ${calEvent.location}

` : '' - ) + - `Additional notes:
- ${calEvent.description}
-
- Need to change this event?
- Cancel: ${cancelLink}
- Reschedule: ${rescheduleLink} -
- `; -}; - -const text = (evt: CalendarEvent, cancelLink: string, rescheduleLink: string, videoCallData?: VideoCallData) => html(evt, cancelLink, rescheduleLink, videoCallData).replace('
', "\n").replace(/<[^>]+>/g, ''); \ No newline at end of file diff --git a/lib/emails/helpers.ts b/lib/emails/helpers.ts new file mode 100644 index 0000000000..ed5a10c479 --- /dev/null +++ b/lib/emails/helpers.ts @@ -0,0 +1,20 @@ +import {VideoCallData} from "../videoClient"; + +export function getIntegrationName(videoCallData: VideoCallData): string { + //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. + const nameProto = videoCallData.type.split("_")[0]; + return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); +} + +export function getFormattedMeetingId(videoCallData: VideoCallData): string { + switch(videoCallData.type) { + case 'zoom_video': + const strId = videoCallData.id.toString(); + const part1 = strId.slice(0, 3); + const part2 = strId.slice(3, 7); + const part3 = strId.slice(7, 11); + return part1 + " " + part2 + " " + part3; + default: + return videoCallData.id.toString(); + } +} \ No newline at end of file diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 24008c78d8..a21f2b2cf5 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -1,218 +1,236 @@ import prisma from "./prisma"; -import {VideoCallData} from "./emails/confirm-booked"; import {CalendarEvent} from "./calendarClient"; import VideoEventOwnerMail from "./emails/VideoEventOwnerMail"; +import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; +import {v5 as uuidv5} from 'uuid'; +import short from 'short-uuid'; + +const translator = short(); + +export interface VideoCallData { + type: string; + id: string; + password: string; + url: string; +} function handleErrorsJson(response) { - if (!response.ok) { - response.json().then(console.log); - throw Error(response.statusText); - } - return response.json(); + if (!response.ok) { + response.json().then(console.log); + throw Error(response.statusText); + } + return response.json(); } function handleErrorsRaw(response) { - if (!response.ok) { - response.text().then(console.log); - throw Error(response.statusText); - } - return response.text(); + if (!response.ok) { + response.text().then(console.log); + throw Error(response.statusText); + } + return response.text(); } const zoomAuth = (credential) => { - const isExpired = (expiryDate) => expiryDate < +(new Date()); - const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64'); + const isExpired = (expiryDate) => expiryDate < +(new Date()); + const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64'); - const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', { - method: 'POST', - headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - 'refresh_token': refreshToken, - 'grant_type': 'refresh_token', - }) + const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', { + method: 'POST', + headers: { + 'Authorization': authHeader, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + }) + }) + .then(handleErrorsJson) + .then(async (responseBody) => { + // Store new tokens in database. + await prisma.credential.update({ + where: { + id: credential.id + }, + data: { + key: responseBody + } + }); + credential.key.access_token = responseBody.access_token; + credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in); + return credential.key.access_token; }) - .then(handleErrorsJson) - .then(async (responseBody) => { - // Store new tokens in database. - await prisma.credential.update({ - where: { - id: credential.id - }, - data: { - key: responseBody - } - }); - credential.key.access_token = responseBody.access_token; - credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in); - return credential.key.access_token; - }) - return { - getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) - }; + return { + getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) + }; }; interface VideoApiAdapter { - createMeeting(event: CalendarEvent): Promise; + createMeeting(event: CalendarEvent): Promise; - updateMeeting(uid: String, event: CalendarEvent); + updateMeeting(uid: String, event: CalendarEvent); - deleteMeeting(uid: String); + deleteMeeting(uid: String); - getAvailability(dateFrom, dateTo): Promise; + getAvailability(dateFrom, dateTo): Promise; } const ZoomVideo = (credential): VideoApiAdapter => { - const auth = zoomAuth(credential); - - const translateEvent = (event: CalendarEvent) => { - // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate - return { - topic: event.title, - type: 2, // Means that this is a scheduled meeting - start_time: event.startTime, - duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000, - //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) - timezone: event.attendees[0].timeZone, - //password: "string", TODO: Should we use a password? Maybe generate a random one? - agenda: event.description, - settings: { - host_video: true, - participant_video: true, - cn_meeting: false, // TODO: true if host meeting in China - in_meeting: false, // TODO: true if host meeting in India - join_before_host: true, - mute_upon_entry: false, - watermark: false, - use_pmi: false, - approval_type: 2, - audio: "both", - auto_recording: "none", - enforce_login: false, - registrants_email_notification: true - } - }; - }; + const auth = zoomAuth(credential); + const translateEvent = (event: CalendarEvent) => { + // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate return { - getAvailability: (dateFrom, dateTo) => { - /*const payload = { - schedules: [credential.key.email], - startTime: { - dateTime: dateFrom, - timeZone: 'UTC', - }, - endTime: { - dateTime: dateTo, - timeZone: 'UTC', - }, - availabilityViewInterval: 60 - }; + topic: event.title, + type: 2, // Means that this is a scheduled meeting + start_time: event.startTime, + duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000, + //schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?) + timezone: event.attendees[0].timeZone, + //password: "string", TODO: Should we use a password? Maybe generate a random one? + agenda: event.description, + settings: { + host_video: true, + participant_video: true, + cn_meeting: false, // TODO: true if host meeting in China + in_meeting: false, // TODO: true if host meeting in India + join_before_host: true, + mute_upon_entry: false, + watermark: false, + use_pmi: false, + approval_type: 2, + audio: "both", + auto_recording: "none", + enforce_login: false, + registrants_email_notification: true + } + }; + }; - return auth.getToken().then( - (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { - method: 'post', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) - }) - .then(handleErrorsJson) - .then(responseBody => { - return responseBody.value[0].scheduleItems.map((evt) => ({ - start: evt.start.dateTime + 'Z', - end: evt.end.dateTime + 'Z' - })) - }) - ).catch((err) => { - console.log(err); - });*/ - }, - createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { - method: 'POST', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(translateEvent(event)) - }).then(handleErrorsJson)), - deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { - method: 'DELETE', - headers: { - 'Authorization': 'Bearer ' + accessToken - } - }).then(handleErrorsRaw)), - updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { - method: 'PATCH', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(translateEvent(event)) - }).then(handleErrorsRaw)), - } + return { + getAvailability: (dateFrom, dateTo) => { + /*const payload = { + schedules: [credential.key.email], + startTime: { + dateTime: dateFrom, + timeZone: 'UTC', + }, + endTime: { + dateTime: dateTo, + timeZone: 'UTC', + }, + availabilityViewInterval: 60 + }; + + return auth.getToken().then( + (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { + method: 'post', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(handleErrorsJson) + .then(responseBody => { + return responseBody.value[0].scheduleItems.map((evt) => ({ + start: evt.start.dateTime + 'Z', + end: evt.end.dateTime + 'Z' + })) + }) + ).catch((err) => { + console.log(err); + });*/ + }, + createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(translateEvent(event)) + }).then(handleErrorsJson)), + deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { + method: 'DELETE', + headers: { + 'Authorization': 'Bearer ' + accessToken + } + }).then(handleErrorsRaw)), + updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { + method: 'PATCH', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(translateEvent(event)) + }).then(handleErrorsRaw)), + } }; // factory const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { - switch (cred.type) { - case 'zoom_video': - return ZoomVideo(cred); - default: - return; // unknown credential, could be legacy? In any case, ignore - } + switch (cred.type) { + case 'zoom_video': + return ZoomVideo(cred); + default: + return; // unknown credential, could be legacy? In any case, ignore + } }).filter(Boolean); const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) ).then( - (results) => results.reduce((acc, availability) => acc.concat(availability), []) + (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = async (credential, calEvent: CalendarEvent, hashUID: string): Promise => { - if(!credential) { - throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called."); - } +const createMeeting = async (credential, calEvent: CalendarEvent): Promise => { + const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); - const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent); + if (!credential) { + throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."); + } - const videoCallData: VideoCallData = { - type: credential.type, - id: creationResult.id, - password: creationResult.password, - url: creationResult.join_url, - }; + const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent); - const mail = new VideoEventOwnerMail(calEvent, hashUID, videoCallData); - const sentMail = await mail.sendEmail(); + const videoCallData: VideoCallData = { + type: credential.type, + id: creationResult.id, + password: creationResult.password, + url: creationResult.join_url, + }; - return { - createdEvent: creationResult, - sentMail: sentMail - }; + const ownerMail = new VideoEventOwnerMail(calEvent, uid, videoCallData); + const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); + await ownerMail.sendEmail(); + + if(!creationResult || !creationResult.disableConfirmationEmail) { + await attendeeMail.sendEmail(); + } + + return { + uid, + createdEvent: creationResult + }; }; const updateMeeting = (credential, uid: String, event: CalendarEvent): Promise => { - if (credential) { - return videoIntegrations([credential])[0].updateMeeting(uid, event); - } + if (credential) { + return videoIntegrations([credential])[0].updateMeeting(uid, event); + } - return Promise.resolve({}); + return Promise.resolve({}); }; const deleteMeeting = (credential, uid: String): Promise => { - if (credential) { - return videoIntegrations([credential])[0].deleteMeeting(uid); - } + if (credential) { + return videoIntegrations([credential])[0].deleteMeeting(uid); + } - return Promise.resolve({}); + return Promise.resolve({}); }; export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting}; diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 0598a456d0..9435876037 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -43,8 +43,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ] }; - const hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); - const eventType = await prisma.eventType.findFirst({ where: { userId: currentUser.id, @@ -115,7 +113,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } else { // Schedule event results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { - const response = await createEvent(credential, evt, hashUID); + const response = await createEvent(credential, evt); return { type: credential.type, response @@ -123,7 +121,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { - const response = await createMeeting(credential, evt, hashUID); + const response = await createMeeting(credential, evt); return { type: credential.type, response @@ -138,6 +136,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) })); } + // 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. + const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); + await prisma.booking.create({ data: { uid: hashUID, @@ -158,12 +160,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - // 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, {}, videoCallData - ); - }*/ - res.status(200).json(results); } From 9b4cf088c066ab35ebf0df6102cac62820474103 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 00:57:59 +0200 Subject: [PATCH 27/42] Added line break --- lib/emails/EventAttendeeMail.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts index 15ef16aeea..265104d8f5 100644 --- a/lib/emails/EventAttendeeMail.ts +++ b/lib/emails/EventAttendeeMail.ts @@ -18,7 +18,7 @@ export default class EventAttendeeMail extends EventMail { this.calEvent.location ? `Location: ${this.calEvent.location}

` : '' ) + `Additional notes:
- ${this.calEvent.description} + ${this.calEvent.description}
` + this.getAdditionalFooter() + `
`; From 3366a05c1d0eeae3065deafc9dd76538210d2732 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 01:04:08 +0200 Subject: [PATCH 28/42] Implemented legacy method to send email even if there is no integration --- pages/api/book/[user].ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 9435876037..2f40276b5d 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -5,6 +5,7 @@ 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"; const translator = short(); @@ -139,6 +140,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // 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. const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); + 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. + const mail = new EventAttendeeMail(evt, hashUID); + await mail.sendEmail(); + } await prisma.booking.create({ data: { From a11641d7b94d5b9f9d222745c42c2740c422e7a1 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 01:41:56 +0200 Subject: [PATCH 29/42] Implemented common availability --- lib/calendarClient.ts | 4 +-- lib/videoClient.ts | 50 ++++++++++++-------------------- pages/api/availability/[user].ts | 24 ++++++++++++--- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index d3d0bb3e74..786a47d522 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -323,7 +323,7 @@ const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map }).filter(Boolean); -const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( +const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo) => Promise.all( calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) ).then( (results) => results.reduce((acc, availability) => acc.concat(availability), []) @@ -364,4 +364,4 @@ const deleteEvent = (credential, uid: String): Promise => { return Promise.resolve({}); }; -export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent}; +export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent}; diff --git a/lib/videoClient.ts b/lib/videoClient.ts index a21f2b2cf5..c3e3bcae9d 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -112,38 +112,24 @@ const ZoomVideo = (credential): VideoApiAdapter => { return { getAvailability: (dateFrom, dateTo) => { - /*const payload = { - schedules: [credential.key.email], - startTime: { - dateTime: dateFrom, - timeZone: 'UTC', - }, - endTime: { - dateTime: dateTo, - timeZone: 'UTC', - }, - availabilityViewInterval: 60 - }; - return auth.getToken().then( - (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { - method: 'post', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) + // TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled. + (accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', { + method: 'get', + headers: { + 'Authorization': 'Bearer ' + accessToken + } + }) + .then(handleErrorsJson) + .then(responseBody => { + return responseBody.meetings.map((meeting) => ({ + start: meeting.start_time, + end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString() + })) }) - .then(handleErrorsJson) - .then(responseBody => { - return responseBody.value[0].scheduleItems.map((evt) => ({ - start: evt.start.dateTime + 'Z', - end: evt.end.dateTime + 'Z' - })) - }) ).catch((err) => { - console.log(err); - });*/ + console.log(err); + }); }, createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { method: 'POST', @@ -181,7 +167,7 @@ const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredential }).filter(Boolean); -const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( +const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all( videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) ).then( (results) => results.reduce((acc, availability) => acc.concat(availability), []) @@ -207,7 +193,7 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); await ownerMail.sendEmail(); - if(!creationResult || !creationResult.disableConfirmationEmail) { + if (!creationResult || !creationResult.disableConfirmationEmail) { await attendeeMail.sendEmail(); } @@ -233,4 +219,4 @@ const deleteMeeting = (credential, uid: String): Promise => { return Promise.resolve({}); }; -export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting}; +export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting}; diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index d3dfd8565d..4eb706836e 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -1,6 +1,7 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; +import type {NextApiRequest, NextApiResponse} from 'next'; import prisma from '../../../lib/prisma'; -import { getBusyTimes } from '../../../lib/calendarClient'; +import {getBusyCalendarTimes} from '../../../lib/calendarClient'; +import {getBusyVideoTimes} from '../../../lib/videoClient'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { user } = req.query @@ -15,6 +16,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo); - res.status(200).json(availability); + 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, req.query.dateFrom, req.query.dateTo); + const videoAvailability = await getBusyVideoTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo); + + let commonAvailability = []; + + if(hasCalendarIntegrations && hasVideoIntegrations) { + commonAvailability = calendarAvailability.filter(availability => videoAvailability.includes(availability)); + } else if(hasVideoIntegrations) { + commonAvailability = videoAvailability; + } else if(hasCalendarIntegrations) { + commonAvailability = calendarAvailability; + } + + res.status(200).json(commonAvailability); } From 869ba9b97c71a7a61c9aa00135d800eda7a7605c Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 02:44:13 +0200 Subject: [PATCH 30/42] Implemented reschedule mail and fixed bug that rescheduling weren't saved --- lib/calendarClient.ts | 593 +++++++++++---------- lib/emails/EventAttendeeMail.ts | 2 +- lib/emails/EventAttendeeRescheduledMail.ts | 40 ++ lib/emails/EventOwnerRescheduledMail.ts | 64 +++ lib/videoClient.ts | 25 +- pages/api/book/[user].ts | 13 +- 6 files changed, 440 insertions(+), 297 deletions(-) create mode 100644 lib/emails/EventAttendeeRescheduledMail.ts create mode 100644 lib/emails/EventOwnerRescheduledMail.ts diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 786a47d522..5e670a95c6 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -2,366 +2,379 @@ import EventOwnerMail from "./emails/EventOwnerMail"; import EventAttendeeMail from "./emails/EventAttendeeMail"; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; +import EventOwnerRescheduledMail from "./emails/EventOwnerRescheduledMail"; +import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; const translator = short(); const {google} = require('googleapis'); const googleAuth = () => { - 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 {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]); }; function handleErrorsJson(response) { - if (!response.ok) { - response.json().then(console.log); - throw Error(response.statusText); - } - return response.json(); + if (!response.ok) { + response.json().then(console.log); + throw Error(response.statusText); + } + return response.json(); } function handleErrorsRaw(response) { - if (!response.ok) { - response.text().then(console.log); - throw Error(response.statusText); - } - return response.text(); + if (!response.ok) { + response.text().then(console.log); + throw Error(response.statusText); + } + return response.text(); } 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, - }) + 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; }) - .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) - }; + return { + 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 { - type: string; - title: string; - startTime: string; - endTime: string; - description?: string; - location?: string; - organizer: Person; - attendees: Person[]; + type: string; + title: string; + startTime: string; + endTime: string; + description?: string; + location?: string; + organizer: Person; + attendees: Person[]; }; interface CalendarApiAdapter { - createEvent(event: CalendarEvent): Promise; + createEvent(event: CalendarEvent): Promise; - updateEvent(uid: String, event: CalendarEvent); + updateEvent(uid: String, event: CalendarEvent); - deleteEvent(uid: String); + deleteEvent(uid: String); - getAvailability(dateFrom, dateTo): Promise; + getAvailability(dateFrom, dateTo): Promise; } const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { - const auth = o365Auth(credential); + const auth = o365Auth(credential); - const translateEvent = (event: CalendarEvent) => { + const translateEvent = (event: CalendarEvent) => { - let optional = {}; - if (event.location) { - optional.location = {displayName: event.location}; - } - - return { - subject: event.title, - body: { - contentType: 'HTML', - content: event.description, - }, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees.map(attendee => ({ - emailAddress: { - address: attendee.email, - name: attendee.name - }, - type: "required" - })), - ...optional - } - }; + let optional = {}; + if (event.location) { + optional.location = {displayName: event.location}; + } return { - getAvailability: (dateFrom, dateTo) => { - const payload = { - schedules: [credential.key.email], - startTime: { - dateTime: dateFrom, - timeZone: 'UTC', - }, - endTime: { - dateTime: dateTo, - timeZone: 'UTC', - }, - availabilityViewInterval: 60 - }; - - return auth.getToken().then( - (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { - method: 'post', - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) - }) - .then(handleErrorsJson) - .then(responseBody => { - return responseBody.value[0].scheduleItems.map((evt) => ({ - start: evt.start.dateTime + 'Z', - end: evt.end.dateTime + 'Z' - })) - }) - ).catch((err) => { - console.log(err); - }); + subject: event.title, + body: { + contentType: 'HTML', + content: event.description, + }, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees.map(attendee => ({ + emailAddress: { + address: attendee.email, + name: attendee.name }, - 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)), + type: "required" + })), + ...optional } + }; + + return { + getAvailability: (dateFrom, dateTo) => { + const payload = { + schedules: [credential.key.email], + startTime: { + dateTime: dateFrom, + timeZone: 'UTC', + }, + endTime: { + dateTime: dateTo, + timeZone: 'UTC', + }, + availabilityViewInterval: 60 + }; + + return auth.getToken().then( + (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { + method: 'post', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(handleErrorsJson) + .then(responseBody => { + return responseBody.value[0].scheduleItems.map((evt) => ({ + start: evt.start.dateTime + 'Z', + end: evt.end.dateTime + 'Z' + })) + }) + ).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)), + } }; const GoogleCalendar = (credential): CalendarApiAdapter => { - const myGoogleAuth = googleAuth(); - myGoogleAuth.setCredentials(credential.key); - return { - getAvailability: (dateFrom, dateTo) => new Promise((resolve, reject) => { - const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); - calendar.calendarList - .list() - .then(cals => { - calendar.freebusy.query({ - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: 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 myGoogleAuth = googleAuth(); + myGoogleAuth.setCredentials(credential.key); + return { + getAvailability: (dateFrom, dateTo) => new Promise((resolve, reject) => { + const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); + calendar.calendarList + .list() + .then(cals => { + calendar.freebusy.query({ + requestBody: { + timeMin: dateFrom, + timeMax: dateTo, + items: cals.data.items } - - 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; + }, (err, apires) => { + if (err) { + reject(err); } - - 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); - }); + 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) { + 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); + }); + }) + }; }; // 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 - } + 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) => Promise.all( - calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) + calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) ).then( - (results) => results.reduce((acc, availability) => acc.concat(availability), []) + (results) => results.reduce((acc, availability) => acc.concat(availability), []) ); const createEvent = async (credential, calEvent: CalendarEvent): Promise => { - const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); + 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 ownerMail = new EventOwnerMail(calEvent, uid); - const attendeeMail = new EventAttendeeMail(calEvent, uid); - await ownerMail.sendEmail(); + const ownerMail = new EventOwnerMail(calEvent, uid); + const attendeeMail = new EventAttendeeMail(calEvent, uid); + await ownerMail.sendEmail(); - if(!creationResult || !creationResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); - } + if (!creationResult || !creationResult.disableConfirmationEmail) { + await attendeeMail.sendEmail(); + } - return { - uid, - createdEvent: creationResult - }; + return { + uid, + createdEvent: creationResult + }; }; -const updateEvent = (credential, uid: String, calEvent: CalendarEvent): Promise => { - if (credential) { - return calendars([credential])[0].updateEvent(uid, calEvent); - } +const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise => { + const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); - return Promise.resolve({}); + const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) : null; + + const ownerMail = new EventOwnerRescheduledMail(calEvent, newUid); + const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); + await ownerMail.sendEmail(); + + if (!updateResult || !updateResult.disableConfirmationEmail) { + await attendeeMail.sendEmail(); + } + + return { + uid: newUid, + updatedEvent: updateResult + }; }; const deleteEvent = (credential, uid: String): Promise => { - if (credential) { - return calendars([credential])[0].deleteEvent(uid); - } + if (credential) { + return calendars([credential])[0].deleteEvent(uid); + } - return Promise.resolve({}); + return Promise.resolve({}); }; export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent}; diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts index 265104d8f5..b8bef1ba21 100644 --- a/lib/emails/EventAttendeeMail.ts +++ b/lib/emails/EventAttendeeMail.ts @@ -49,7 +49,7 @@ export default class EventAttendeeMail extends EventMail { * * @private */ - private getInviteeStart(): Dayjs { + protected getInviteeStart(): Dayjs { return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); } } \ No newline at end of file diff --git a/lib/emails/EventAttendeeRescheduledMail.ts b/lib/emails/EventAttendeeRescheduledMail.ts new file mode 100644 index 0000000000..760aa040f8 --- /dev/null +++ b/lib/emails/EventAttendeeRescheduledMail.ts @@ -0,0 +1,40 @@ +import EventAttendeeMail from "./EventAttendeeMail"; + +export default class EventAttendeeRescheduledMail extends EventAttendeeMail { + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected getHtmlRepresentation(): string { + return ` +
+ Hi ${this.calEvent.attendees[0].name},
+
+ Your ${this.calEvent.type} with ${this.calEvent.organizer.name} has been rescheduled to ${this.getInviteeStart().format('h:mma')} + (${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')}.
+ ` + this.getAdditionalFooter() + ` +
+ `; + } + + /** + * Returns the payload object for the nodemailer. + * + * @protected + */ + protected getNodeMailerPayload(): Object { + return { + to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `Rescheduled: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), + }; + } + + protected printNodeMailerError(error: string): void { + console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); + } +} \ No newline at end of file diff --git a/lib/emails/EventOwnerRescheduledMail.ts b/lib/emails/EventOwnerRescheduledMail.ts new file mode 100644 index 0000000000..1b9ac9caa6 --- /dev/null +++ b/lib/emails/EventOwnerRescheduledMail.ts @@ -0,0 +1,64 @@ +import dayjs, {Dayjs} from "dayjs"; +import EventOwnerMail from "./EventOwnerMail"; + +export default class EventOwnerRescheduledMail extends EventOwnerMail { + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected getHtmlRepresentation(): string { + return ` +
+ Hi ${this.calEvent.organizer.name},
+
+ Your event has been rescheduled.
+
+ Event Type:
+ ${this.calEvent.type}
+
+ Invitee Email:
+ ${this.calEvent.attendees[0].email}
+
` + this.getAdditionalBody() + + ( + this.calEvent.location ? ` + Location:
+ ${this.calEvent.location}
+
+ ` : '' + ) + + `Invitee Time Zone:
+ ${this.calEvent.attendees[0].timeZone}
+
+ Additional notes:
+ ${this.calEvent.description} + ` + this.getAdditionalFooter() + ` +
+ `; + } + + /** + * Returns the payload object for the nodemailer. + * + * @protected + */ + protected getNodeMailerPayload(): Object { + const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); + + return { + icalEvent: { + filename: 'event.ics', + content: this.getiCalEventAsString(), + }, + from: `Calendso <${this.getMailerOptions().from}>`, + to: this.calEvent.organizer.email, + subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), + }; + } + + protected printNodeMailerError(error: string): void { + console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); + } +} \ No newline at end of file diff --git a/lib/videoClient.ts b/lib/videoClient.ts index c3e3bcae9d..6397f97164 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -4,6 +4,8 @@ import VideoEventOwnerMail from "./emails/VideoEventOwnerMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; +import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; +import EventOwnerRescheduledMail from "./emails/EventOwnerRescheduledMail"; const translator = short(); @@ -203,12 +205,27 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise }; }; -const updateMeeting = (credential, uid: String, event: CalendarEvent): Promise => { - if (credential) { - return videoIntegrations([credential])[0].updateMeeting(uid, event); +const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise => { + const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); + + if (!credential) { + throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."); } - return Promise.resolve({}); + const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null; + + const ownerMail = new EventOwnerRescheduledMail(calEvent, newUid); + const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); + await ownerMail.sendEmail(); + + if (!updateResult || !updateResult.disableConfirmationEmail) { + await attendeeMail.sendEmail(); + } + + return { + uid: newUid, + updatedEvent: updateResult + }; }; const deleteMeeting = (credential, uid: String): Promise => { diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 2f40276b5d..83707bf248 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -78,12 +78,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Use all integrations results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateEvent(credential, bookingRefUid, evt) + const response = await updateEvent(credential, bookingRefUid, evt); + + return { + type: credential.type, + response + }; })); results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return await updateMeeting(credential, bookingRefUid, evt) + const response = await updateMeeting(credential, bookingRefUid, evt); + return { + type: credential.type, + response + }; })); // Clone elements From 9e9ffdd656d33ec719a8a7a3c541020e1adaccb5 Mon Sep 17 00:00:00 2001 From: nicolas Date: Thu, 17 Jun 2021 10:59:59 +0200 Subject: [PATCH 31/42] Added section for zoom app generation in README.md --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index c38bb14cea..964e87cdcb 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,30 @@ Contributions are what make the open source community such an amazing place to b 5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env 6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte +## Obtaining Zoom Client ID and Secret +1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account. +2. On the upper right, click "Develop" => "Build App". +3. On "OAuth", select "Create". +4. Name your App. +5. Choose "Account-level app" as the app type. +6. De-select the option to publish the app on the Zoom App Marketplace. +7. Click "Create". +8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields. +4. Set the Redirect URL for OAuth `/api/integrations/zoomvideo/callback` replacing CALENDSO URL with the URI at which your application runs. +5. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form. +7. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". Search for and check the following scopes: + 1. account:master + 2. account:read:admin + 3. account:write:admin + 4. meeting:master + 5. meeting:read:admin + 6. meeting:write:admin + 7. user:master + 8. user:read:admin + 9. user:write:admin +8. Click "Done". +9. You're good to go. Now you can easily add your Zoom integration in the Calendso settings. + ## License From 46698e4a407e25ce61ae8ff3a62031f2e49d28be Mon Sep 17 00:00:00 2001 From: Rrrricky Date: Thu, 17 Jun 2021 16:47:49 +0200 Subject: [PATCH 32/42] fix: Use proper url parameter for profile img placeholder --- components/Avatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Avatar.tsx b/components/Avatar.tsx index 1356d445d4..3d83232abd 100644 --- a/components/Avatar.tsx +++ b/components/Avatar.tsx @@ -16,7 +16,7 @@ export default function Avatar({ user, className = '', fallback }: { return ( setGravatarAvailable(false)} - src={`https://www.gravatar.com/avatar/${md5(user.email)}?d=404&s=160`} + src={`https://www.gravatar.com/avatar/${md5(user.email)}?s=160&d=identicon&r=PG`} alt="Avatar" className={className} /> From b3c5752d67b5f5e8c084872d14da223eced28077 Mon Sep 17 00:00:00 2001 From: Peer_Rich Date: Thu, 17 Jun 2021 17:06:32 +0100 Subject: [PATCH 33/42] updated integration description --- pages/integrations/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 3596cba15c..040d47dc45 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -212,24 +212,24 @@ export async function getServerSideProps(context) { type: "google_calendar", title: "Google Calendar", imageSrc: "integrations/google-calendar.png", - description: "For personal and business accounts", + description: "For personal and business calendars", }, { installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), type: "office365_calendar", credential: 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", + description: "For personal and business calendars", }, { installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), type: "zoom_video", credential: credentials.find( (integration) => integration.type === "zoom_video" ) || null, title: "Zoom", imageSrc: "integrations/zoom.png", - description: "For personal and business accounts", + description: "Video Conferencing", } ]; return { props: {integrations}, } -} \ No newline at end of file +} From c662c97d14dbe19dc3236a9ce61b6d1690318d0e Mon Sep 17 00:00:00 2001 From: nicolas Date: Fri, 18 Jun 2021 02:44:41 +0200 Subject: [PATCH 34/42] Refactored EventOwner to EventOrganizer --- lib/calendarClient.ts | 12 ++++++------ .../{EventOwnerMail.ts => EventOrganizerMail.ts} | 2 +- ...duledMail.ts => EventOrganizerRescheduledMail.ts} | 4 ++-- ...oEventOwnerMail.ts => VideoEventOrganizerMail.ts} | 4 ++-- lib/videoClient.ts | 12 ++++++------ 5 files changed, 17 insertions(+), 17 deletions(-) rename lib/emails/{EventOwnerMail.ts => EventOrganizerMail.ts} (97%) rename lib/emails/{EventOwnerRescheduledMail.ts => EventOrganizerRescheduledMail.ts} (93%) rename lib/emails/{VideoEventOwnerMail.ts => VideoEventOrganizerMail.ts} (87%) diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 5e670a95c6..d6ca21ff1a 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,8 +1,8 @@ -import EventOwnerMail from "./emails/EventOwnerMail"; +import EventOrganizerMail from "./emails/EventOrganizerMail"; import EventAttendeeMail from "./emails/EventAttendeeMail"; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; -import EventOwnerRescheduledMail from "./emails/EventOwnerRescheduledMail"; +import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; const translator = short(); @@ -336,9 +336,9 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise => const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; - const ownerMail = new EventOwnerMail(calEvent, uid); + const organizerMail = new EventOrganizerMail(calEvent, uid); const attendeeMail = new EventAttendeeMail(calEvent, uid); - await ownerMail.sendEmail(); + await organizerMail.sendEmail(); if (!creationResult || !creationResult.disableConfirmationEmail) { await attendeeMail.sendEmail(); @@ -355,9 +355,9 @@ const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEv const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) : null; - const ownerMail = new EventOwnerRescheduledMail(calEvent, newUid); + const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - await ownerMail.sendEmail(); + await organizerMail.sendEmail(); if (!updateResult || !updateResult.disableConfirmationEmail) { await attendeeMail.sendEmail(); diff --git a/lib/emails/EventOwnerMail.ts b/lib/emails/EventOrganizerMail.ts similarity index 97% rename from lib/emails/EventOwnerMail.ts rename to lib/emails/EventOrganizerMail.ts index 762a73bea5..0f671efaba 100644 --- a/lib/emails/EventOwnerMail.ts +++ b/lib/emails/EventOrganizerMail.ts @@ -2,7 +2,7 @@ import {createEvent} from "ics"; import dayjs, {Dayjs} from "dayjs"; import EventMail from "./EventMail"; -export default class EventOwnerMail extends EventMail { +export default class EventOrganizerMail extends EventMail { /** * Returns the instance's event as an iCal event in string representation. * @protected diff --git a/lib/emails/EventOwnerRescheduledMail.ts b/lib/emails/EventOrganizerRescheduledMail.ts similarity index 93% rename from lib/emails/EventOwnerRescheduledMail.ts rename to lib/emails/EventOrganizerRescheduledMail.ts index 1b9ac9caa6..7e67ac4466 100644 --- a/lib/emails/EventOwnerRescheduledMail.ts +++ b/lib/emails/EventOrganizerRescheduledMail.ts @@ -1,7 +1,7 @@ import dayjs, {Dayjs} from "dayjs"; -import EventOwnerMail from "./EventOwnerMail"; +import EventOrganizerMail from "./EventOrganizerMail"; -export default class EventOwnerRescheduledMail extends EventOwnerMail { +export default class EventOrganizerRescheduledMail extends EventOrganizerMail { /** * Returns the email text as HTML representation. * diff --git a/lib/emails/VideoEventOwnerMail.ts b/lib/emails/VideoEventOrganizerMail.ts similarity index 87% rename from lib/emails/VideoEventOwnerMail.ts rename to lib/emails/VideoEventOrganizerMail.ts index 515f123156..60d85237cf 100644 --- a/lib/emails/VideoEventOwnerMail.ts +++ b/lib/emails/VideoEventOrganizerMail.ts @@ -1,9 +1,9 @@ import {CalendarEvent} from "../calendarClient"; -import EventOwnerMail from "./EventOwnerMail"; +import EventOrganizerMail from "./EventOrganizerMail"; import {VideoCallData} from "../videoClient"; import {getFormattedMeetingId, getIntegrationName} from "./helpers"; -export default class VideoEventOwnerMail extends EventOwnerMail { +export default class VideoEventOrganizerMail extends EventOrganizerMail { videoCallData: VideoCallData; constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) { diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 6397f97164..b359e83a96 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -1,11 +1,11 @@ import prisma from "./prisma"; import {CalendarEvent} from "./calendarClient"; -import VideoEventOwnerMail from "./emails/VideoEventOwnerMail"; +import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import {v5 as uuidv5} from 'uuid'; import short from 'short-uuid'; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; -import EventOwnerRescheduledMail from "./emails/EventOwnerRescheduledMail"; +import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; const translator = short(); @@ -191,9 +191,9 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise url: creationResult.join_url, }; - const ownerMail = new VideoEventOwnerMail(calEvent, uid, videoCallData); + const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData); const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); - await ownerMail.sendEmail(); + await organizerMail.sendEmail(); if (!creationResult || !creationResult.disableConfirmationEmail) { await attendeeMail.sendEmail(); @@ -214,9 +214,9 @@ const updateMeeting = async (credential, uidToUpdate: String, calEvent: Calendar const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null; - const ownerMail = new EventOwnerRescheduledMail(calEvent, newUid); + const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - await ownerMail.sendEmail(); + await organizerMail.sendEmail(); if (!updateResult || !updateResult.disableConfirmationEmail) { await attendeeMail.sendEmail(); From 76b72f64d87994aa958c7df81e0bb4e9f9ad3202 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Fri, 18 Jun 2021 21:58:42 +0200 Subject: [PATCH 35/42] event type custom input WIP --- lib/EventTypeInput.ts | 14 ++ pages/api/availability/eventtype.ts | 23 ++- pages/availability/event/[type].tsx | 136 +++++++++++++++++- .../migration.sql | 13 ++ 4 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 lib/EventTypeInput.ts create mode 100644 prisma/migrations/20210618140954_added_event_type_custom/migration.sql diff --git a/lib/EventTypeInput.ts b/lib/EventTypeInput.ts new file mode 100644 index 0000000000..ac0799d348 --- /dev/null +++ b/lib/EventTypeInput.ts @@ -0,0 +1,14 @@ + +export enum EventTypeCustomInputType { + Text = 'text', + TextLong = 'textLong', + Number = 'number', +} + +export interface EventTypeCustomInput { + id?: number; + type: EventTypeCustomInputType; + label: string; + required: boolean; +} + diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index 8f03e3e91b..885c117cd5 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -18,6 +18,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) length: parseInt(req.body.length), hidden: req.body.hidden, locations: req.body.locations, + customInputs: !req.body.customInputs + ? undefined + : { + createMany: { + data: req.body.customInputs.filter(input => !input.id).map(input => ({ + type: input.type, + label: input.label, + required: input.required + })) + }, + update: req.body.customInputs.filter(input => !!input.id).map(input => ({ + data: { + type: input.type, + label: input.label, + required: input.required + }, + where: { + id: input.id + } + })) + }, }; if (req.method == "POST") { @@ -50,4 +71,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(200).json({message: 'Event deleted successfully'}); } -} \ No newline at end of file +} diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index ed575f25f1..54ff28b3e8 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -13,14 +13,25 @@ import { XIcon, PhoneIcon, } from '@heroicons/react/outline'; +import {EventTypeCustomInput, EventTypeCustomInputType} from "../../../lib/EventTypeInput"; +import {PlusIcon} from "@heroicons/react/solid"; export default function EventType(props) { const router = useRouter(); + const inputOptions: OptionBase[] = [ + { value: EventTypeCustomInputType.Text, label: 'Text' }, + { value: EventTypeCustomInputType.TextLong, label: 'Multiline Text' }, + { value: EventTypeCustomInputType.Number, label: 'Number', }, + ] + const [ session, loading ] = useSession(); const [ showLocationModal, setShowLocationModal ] = useState(false); + const [ showAddCustomModal, setShowAddCustomModal ] = useState(false); const [ selectedLocation, setSelectedLocation ] = useState(undefined); + 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 titleRef = useRef(); const slugRef = useRef(); @@ -44,7 +55,7 @@ export default function EventType(props) { const response = await fetch('/api/availability/eventtype', { method: 'PATCH', - body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations }), + body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, customInputs }), headers: { 'Content-Type': 'application/json' } @@ -83,6 +94,11 @@ export default function EventType(props) { setShowLocationModal(false); }; + const closeAddCustomModal = () => { + setSelectedInputOption(inputOptions[0]); + setShowAddCustomModal(false); + }; + const LocationOptions = () => { if (!selectedLocation) { return null; @@ -133,7 +149,22 @@ export default function EventType(props) { setLocations(locations.filter( (location) => location.type !== selectedLocation.type )); }; - return ( + const updateCustom = (e) => { + e.preventDefault(); + + const customInput: EventTypeCustomInput = { + label: e.target.label.value, + required: e.target.required.checked, + type: e.target.type.value + }; + + setCustomInputs(customInputs.concat(customInput)); + + console.log(customInput) + setShowAddCustomModal(false); + }; + + return (
{props.eventType.title} | Event Type | Calendso @@ -232,6 +263,44 @@ export default function EventType(props) {
+
+ +
    + {customInputs.map( (customInput) => ( +
  • +
    +
    +
    + Label: {customInput.label} +
    +
    + Type: {customInput.type} +
    +
    + {customInput.required ? "Required" : "Optional"} +
    +
    +
    + + +
    +
    +
  • + ))} +
  • + +
  • +
+
@@ -317,6 +386,66 @@ export default function EventType(props) {
} + {showAddCustomModal && +
+
+ +
+
+ }
); @@ -348,6 +477,7 @@ export async function getServerSideProps(context) { length: true, hidden: true, locations: true, + customInputs: true } }); @@ -357,4 +487,4 @@ export async function getServerSideProps(context) { eventType }, } -} \ No newline at end of file +} diff --git a/prisma/migrations/20210618140954_added_event_type_custom/migration.sql b/prisma/migrations/20210618140954_added_event_type_custom/migration.sql new file mode 100644 index 0000000000..5d038f2b06 --- /dev/null +++ b/prisma/migrations/20210618140954_added_event_type_custom/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "EventTypeCustomInput" ( + "id" SERIAL NOT NULL, + "eventTypeId" INTEGER NOT NULL, + "label" TEXT NOT NULL, + "type" TEXT NOT NULL, + "required" BOOLEAN NOT NULL, + + PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "EventTypeCustomInput" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 50d325d20af757b50cb921b9d18936558d400c71 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sat, 19 Jun 2021 20:55:40 +0200 Subject: [PATCH 36/42] added schema for EventTypeCustomInput --- prisma/schema.prisma | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fb9e1d9a7..6b33ce753c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model EventType { userId Int? bookings Booking[] eventName String? + customInputs EventTypeCustomInput[] } model Credential { @@ -131,3 +132,13 @@ model SelectedCalendar { externalId String @@id([userId,integration,externalId]) } + +model EventTypeCustomInput { + id Int @id @default(autoincrement()) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id]) + label String + type String + required Boolean +} + From 639341f701375678d76483b7324f1dde4e23fbab Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sat, 19 Jun 2021 21:44:36 +0200 Subject: [PATCH 37/42] show custom inputs on booking screen --- lib/{EventTypeInput.ts => eventTypeInput.ts} | 1 + pages/[user]/book.tsx | 54 +++++++++++++++++++- pages/availability/event/[type].tsx | 3 +- 3 files changed, 55 insertions(+), 3 deletions(-) rename lib/{EventTypeInput.ts => eventTypeInput.ts} (92%) diff --git a/lib/EventTypeInput.ts b/lib/eventTypeInput.ts similarity index 92% rename from lib/EventTypeInput.ts rename to lib/eventTypeInput.ts index ac0799d348..f3d9f66e2f 100644 --- a/lib/EventTypeInput.ts +++ b/lib/eventTypeInput.ts @@ -3,6 +3,7 @@ export enum EventTypeCustomInputType { Text = 'text', TextLong = 'textLong', Number = 'number', + Bool = 'bool', } export interface EventTypeCustomInput { diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index fb9165e9ad..0030af8f4e 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -13,6 +13,7 @@ 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); @@ -49,12 +50,31 @@ export default function Book(props) { const bookingHandler = event => { event.preventDefault(); + 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 += "Additional 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: event.target.notes.value, + notes: notes, timeZone: preferredTimeZone, eventTypeId: props.eventType.id, rescheduleUid: rescheduleUid @@ -143,9 +163,38 @@ export default function Book(props) { {}} /> )} + {props.eventType.customInputs && props.eventType.customInputs.sort((a,b) => a.id - b.id).map(input => ( +
+ {input.type !== EventTypeCustomInputType.Bool && + } + {input.type === EventTypeCustomInputType.TextLong && + +