Introduced EventOwnerMail and VideoEventOwnerMail as class based implementations
This commit is contained in:
		
							parent
							
								
									51a8bafaa7
								
							
						
					
					
						commit
						e37dd017c8
					
				
					 5 changed files with 231 additions and 81 deletions
				
			
		|  | @ -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<any> => { | ||||
| const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => { | ||||
|     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<any> => { | ||||
|  |  | |||
							
								
								
									
										150
									
								
								lib/emails/EventOwnerMail.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								lib/emails/EventOwnerMail.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 ` | ||||
|       <div> | ||||
|         Hi ${this.calEvent.organizer.name},<br /> | ||||
|         <br /> | ||||
|         A new event has been scheduled.<br /> | ||||
|         <br /> | ||||
|         <strong>Event Type:</strong><br /> | ||||
|         ${this.calEvent.type}<br /> | ||||
|         <br /> | ||||
|         <strong>Invitee Email:</strong><br /> | ||||
|         <a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br /> | ||||
|         <br />` + this.getAdditionalBody() +
 | ||||
|       ( | ||||
|         this.calEvent.location ? ` | ||||
|             <strong>Location:</strong><br /> | ||||
|             ${this.calEvent.location}<br /> | ||||
|             <br /> | ||||
|           ` : ''
 | ||||
|       ) + | ||||
|       `<strong>Invitee Time Zone:</strong><br />
 | ||||
|         ${this.calEvent.attendees[0].timeZone}<br /> | ||||
|         <br /> | ||||
|         <strong>Additional notes:</strong><br /> | ||||
|         ${this.calEvent.description} | ||||
|       </div> | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * 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('<br />', "\n") | ||||
|       .replace(/<[^>]+>/g, ''); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Sends the email to the event attendant and returns a Promise. | ||||
|    */ | ||||
|   public sendEmail(): Promise<any> { | ||||
|     const options = this.getMailerOptions(); | ||||
|     const {transport, from} = options; | ||||
|     const organizerStart: Dayjs = <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 ""; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										27
									
								
								lib/emails/VideoEventOwnerMail.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								lib/emails/VideoEventOwnerMail.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 ` | ||||
|       <strong>Video call provider:</strong> ${integrationTypeToName(this.videoCallData.type)}<br /> | ||||
|       <strong>Meeting ID:</strong> ${formattedId(this.videoCallData)}<br /> | ||||
|       <strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> | ||||
|       <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> | ||||
|     `;
 | ||||
|   } | ||||
| } | ||||
|  | @ -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<any>; | ||||
|     createMeeting(event: CalendarEvent): Promise<any>; | ||||
| 
 | ||||
|     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<any> => { | ||||
| 
 | ||||
|     //TODO Send email to event host
 | ||||
|     /*createNewMeetingEmail( | ||||
|       meeting, | ||||
|     );*/ | ||||
| 
 | ||||
|     if (credential) { | ||||
|         return videoIntegrations([credential])[0].createMeeting(meeting); | ||||
| const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => { | ||||
|     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 updateMeeting = (credential, uid: String, meeting: VideoMeeting): Promise<any> => { | ||||
|     const mail = new VideoEventOwnerMail(calEvent, videoCallData); | ||||
|     const sentMail = await mail.sendEmail(); | ||||
| 
 | ||||
|     return { | ||||
|         createdEvent: creationResult, | ||||
|         sentMail: sentMail | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const updateMeeting = (credential, uid: String, event: CalendarEvent): Promise<any> => { | ||||
|     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<any> => { | |||
|     return Promise.resolve({}); | ||||
| }; | ||||
| 
 | ||||
| export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting, VideoMeeting}; | ||||
| export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting}; | ||||
|  |  | |||
|  | @ -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); | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 nicolas
						nicolas