diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index c11348f4..fa47a3f6 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 00000000..82aa82b7 --- /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 00000000..c170456f --- /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 4a8817df..041c9fa4 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 8f4a2a4f..f488b3bb 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); }