diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 4b65cc8b..d3d0bb3e 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 9ec4edf2..7855f36d 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 597abd91..515f1231 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 00ab4514..00000000 --- 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 00000000..ed5a10c4 --- /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 24008c78..a21f2b2c 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 0598a456..94358760 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); }