Re-implemented event confirmation mails object based
This commit is contained in:
		
							parent
							
								
									04e0b55b51
								
							
						
					
					
						commit
						f56ced0ff1
					
				
					 7 changed files with 232 additions and 305 deletions
				
			
		|  | @ -1,4 +1,9 @@ | ||||||
| import EventOwnerMail from "./emails/EventOwnerMail"; | 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'); | const {google} = require('googleapis'); | ||||||
| 
 | 
 | ||||||
|  | @ -324,15 +329,22 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( | ||||||
|     (results) => results.reduce((acc, availability) => acc.concat(availability), []) |     (results) => results.reduce((acc, availability) => acc.concat(availability), []) | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| const createEvent = async (credential, calEvent: CalendarEvent, hashUID: string): Promise<any> => { | const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => { | ||||||
|     const mail = new EventOwnerMail(calEvent, hashUID); |     const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); | ||||||
|     const sentMail = await mail.sendEmail(); |  | ||||||
| 
 | 
 | ||||||
|     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(); | ||||||
|  | 
 | ||||||
|  |     if(!creationResult || !creationResult.disableConfirmationEmail) { | ||||||
|  |         await attendeeMail.sendEmail(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return { |     return { | ||||||
|         createdEvent: creationResult, |         uid, | ||||||
|         sentMail: sentMail |         createdEvent: creationResult | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import {VideoCallData} from "./confirm-booked"; |  | ||||||
| import {CalendarEvent} from "../calendarClient"; | import {CalendarEvent} from "../calendarClient"; | ||||||
| import EventAttendeeMail from "./EventAttendeeMail"; | import EventAttendeeMail from "./EventAttendeeMail"; | ||||||
|  | import {getFormattedMeetingId, getIntegrationName} from "./helpers"; | ||||||
|  | import {VideoCallData} from "../videoClient"; | ||||||
| 
 | 
 | ||||||
| export default class VideoEventAttendeeMail extends EventAttendeeMail { | export default class VideoEventAttendeeMail extends EventAttendeeMail { | ||||||
|   videoCallData: VideoCallData; |   videoCallData: VideoCallData; | ||||||
|  | @ -10,25 +11,6 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail { | ||||||
|     this.videoCallData = videoCallData; |     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. |    * Adds the video call information to the mail body. | ||||||
|    * |    * | ||||||
|  | @ -36,8 +18,8 @@ export default class VideoEventAttendeeMail extends EventAttendeeMail { | ||||||
|    */ |    */ | ||||||
|   protected getAdditionalBody(): string { |   protected getAdditionalBody(): string { | ||||||
|     return ` |     return ` | ||||||
|       <strong>Video call provider:</strong> ${this.getIntegrationName()}<br /> |       <strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> | ||||||
|       <strong>Meeting ID:</strong> ${this.getFormattedMeetingId()}<br /> |       <strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br /> | ||||||
|       <strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> |       <strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> | ||||||
|       <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> |       <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> | ||||||
|     `;
 |     `;
 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import {CalendarEvent} from "../calendarClient"; | import {CalendarEvent} from "../calendarClient"; | ||||||
| import EventOwnerMail from "./EventOwnerMail"; | 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 { | export default class VideoEventOwnerMail extends EventOwnerMail { | ||||||
|   videoCallData: VideoCallData; |   videoCallData: VideoCallData; | ||||||
|  | @ -18,8 +19,8 @@ export default class VideoEventOwnerMail extends EventOwnerMail { | ||||||
|    */ |    */ | ||||||
|   protected getAdditionalBody(): string { |   protected getAdditionalBody(): string { | ||||||
|     return ` |     return ` | ||||||
|       <strong>Video call provider:</strong> ${integrationTypeToName(this.videoCallData.type)}<br /> |       <strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> | ||||||
|       <strong>Meeting ID:</strong> ${formattedId(this.videoCallData)}<br /> |       <strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br /> | ||||||
|       <strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> |       <strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> | ||||||
|       <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> |       <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> | ||||||
|     `;
 |     `;
 | ||||||
|  |  | ||||||
|  | @ -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>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>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); |  | ||||||
|   return ` |  | ||||||
|     <div> |  | ||||||
|       Hi ${calEvent.attendees[0].name},<br /> |  | ||||||
|       <br /> |  | ||||||
|       Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')}  |  | ||||||
|       (${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.<br /> |  | ||||||
|       <br />` + (
 |  | ||||||
|       videoCallData ? `<strong>Video call provider:</strong> ${integrationTypeToName(videoCallData.type)}<br />
 |  | ||||||
|       <strong>Meeting ID:</strong> ${formattedId(videoCallData)}<br /> |  | ||||||
|       <strong>Meeting Password:</strong> ${videoCallData.password}<br /> |  | ||||||
|       <strong>Meeting URL:</strong> <a href="${videoCallData.url}">${videoCallData.url}</a><br /><br />` : ''
 |  | ||||||
|     ) + ( |  | ||||||
|       calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : '' |  | ||||||
|     ) + |  | ||||||
|     `<strong>Additional notes:</strong><br />
 |  | ||||||
|       ${calEvent.description}<br /> |  | ||||||
|       <br /> |  | ||||||
|       Need to change this event?<br /> |  | ||||||
|       Cancel: <a href="${cancelLink}">${cancelLink}</a><br /> |  | ||||||
|       Reschedule: <a href="${rescheduleLink}">${rescheduleLink}</a> |  | ||||||
|     </div> |  | ||||||
|   `;
 |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const text = (evt: CalendarEvent, cancelLink: string, rescheduleLink: string, videoCallData?: VideoCallData) => html(evt, cancelLink, rescheduleLink, videoCallData).replace('<br />', "\n").replace(/<[^>]+>/g, ''); |  | ||||||
							
								
								
									
										20
									
								
								lib/emails/helpers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/emails/helpers.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -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(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -1,218 +1,236 @@ | ||||||
| import prisma from "./prisma"; | import prisma from "./prisma"; | ||||||
| import {VideoCallData} from "./emails/confirm-booked"; |  | ||||||
| import {CalendarEvent} from "./calendarClient"; | import {CalendarEvent} from "./calendarClient"; | ||||||
| import VideoEventOwnerMail from "./emails/VideoEventOwnerMail"; | 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) { | function handleErrorsJson(response) { | ||||||
|     if (!response.ok) { |   if (!response.ok) { | ||||||
|         response.json().then(console.log); |     response.json().then(console.log); | ||||||
|         throw Error(response.statusText); |     throw Error(response.statusText); | ||||||
|     } |   } | ||||||
|     return response.json(); |   return response.json(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function handleErrorsRaw(response) { | function handleErrorsRaw(response) { | ||||||
|     if (!response.ok) { |   if (!response.ok) { | ||||||
|         response.text().then(console.log); |     response.text().then(console.log); | ||||||
|         throw Error(response.statusText); |     throw Error(response.statusText); | ||||||
|     } |   } | ||||||
|     return response.text(); |   return response.text(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const zoomAuth = (credential) => { | const zoomAuth = (credential) => { | ||||||
| 
 | 
 | ||||||
|     const isExpired = (expiryDate) => expiryDate < +(new Date()); |   const isExpired = (expiryDate) => expiryDate < +(new Date()); | ||||||
|     const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_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', { |   const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', { | ||||||
|         method: 'POST', |     method: 'POST', | ||||||
|         headers: { |     headers: { | ||||||
|             'Authorization': authHeader, |       'Authorization': authHeader, | ||||||
|             'Content-Type': 'application/x-www-form-urlencoded' |       'Content-Type': 'application/x-www-form-urlencoded' | ||||||
|         }, |     }, | ||||||
|         body: new URLSearchParams({ |     body: new URLSearchParams({ | ||||||
|             'refresh_token': refreshToken, |       'refresh_token': refreshToken, | ||||||
|             'grant_type': 'refresh_token', |       '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 { |   return { | ||||||
|         getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) |     getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) | ||||||
|     }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| interface VideoApiAdapter { | interface VideoApiAdapter { | ||||||
|     createMeeting(event: CalendarEvent): Promise<any>; |   createMeeting(event: CalendarEvent): Promise<any>; | ||||||
| 
 | 
 | ||||||
|     updateMeeting(uid: String, event: CalendarEvent); |   updateMeeting(uid: String, event: CalendarEvent); | ||||||
| 
 | 
 | ||||||
|     deleteMeeting(uid: String); |   deleteMeeting(uid: String); | ||||||
| 
 | 
 | ||||||
|     getAvailability(dateFrom, dateTo): Promise<any>; |   getAvailability(dateFrom, dateTo): Promise<any>; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const ZoomVideo = (credential): VideoApiAdapter => { | const ZoomVideo = (credential): VideoApiAdapter => { | ||||||
| 
 | 
 | ||||||
|     const auth = zoomAuth(credential); |   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 translateEvent = (event: CalendarEvent) => { | ||||||
|  |     // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
 | ||||||
|     return { |     return { | ||||||
|         getAvailability: (dateFrom, dateTo) => { |       topic: event.title, | ||||||
|             /*const payload = { |       type: 2,    // Means that this is a scheduled meeting
 | ||||||
|                 schedules: [credential.key.email], |       start_time: event.startTime, | ||||||
|                 startTime: { |       duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000, | ||||||
|                     dateTime: dateFrom, |       //schedule_for: "string",   TODO: Used when scheduling the meeting for someone else (needed?)
 | ||||||
|                     timeZone: 'UTC', |       timezone: event.attendees[0].timeZone, | ||||||
|                 }, |       //password: "string",       TODO: Should we use a password? Maybe generate a random one?
 | ||||||
|                 endTime: { |       agenda: event.description, | ||||||
|                     dateTime: dateTo, |       settings: { | ||||||
|                     timeZone: 'UTC', |         host_video: true, | ||||||
|                 }, |         participant_video: true, | ||||||
|                 availabilityViewInterval: 60 |         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( |   return { | ||||||
|                 (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { |     getAvailability: (dateFrom, dateTo) => { | ||||||
|                     method: 'post', |       /*const payload = { | ||||||
|                     headers: { |           schedules: [credential.key.email], | ||||||
|                         'Authorization': 'Bearer ' + accessToken, |           startTime: { | ||||||
|                         'Content-Type': 'application/json' |               dateTime: dateFrom, | ||||||
|                     }, |               timeZone: 'UTC', | ||||||
|                     body: JSON.stringify(payload) |           }, | ||||||
|                 }) |           endTime: { | ||||||
|                     .then(handleErrorsJson) |               dateTime: dateTo, | ||||||
|                     .then(responseBody => { |               timeZone: 'UTC', | ||||||
|                         return responseBody.value[0].scheduleItems.map((evt) => ({ |           }, | ||||||
|                             start: evt.start.dateTime + 'Z', |           availabilityViewInterval: 60 | ||||||
|                             end: evt.end.dateTime + 'Z' |       }; | ||||||
|                         })) | 
 | ||||||
|                     }) |       return auth.getToken().then( | ||||||
|             ).catch((err) => { |           (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { | ||||||
|                 console.log(err); |               method: 'post', | ||||||
|             });*/ |               headers: { | ||||||
|         }, |                   'Authorization': 'Bearer ' + accessToken, | ||||||
|         createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { |                   'Content-Type': 'application/json' | ||||||
|             method: 'POST', |               }, | ||||||
|             headers: { |               body: JSON.stringify(payload) | ||||||
|                 'Authorization': 'Bearer ' + accessToken, |           }) | ||||||
|                 'Content-Type': 'application/json', |               .then(handleErrorsJson) | ||||||
|             }, |               .then(responseBody => { | ||||||
|             body: JSON.stringify(translateEvent(event)) |                   return responseBody.value[0].scheduleItems.map((evt) => ({ | ||||||
|         }).then(handleErrorsJson)), |                       start: evt.start.dateTime + 'Z', | ||||||
|         deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { |                       end: evt.end.dateTime + 'Z' | ||||||
|             method: 'DELETE', |                   })) | ||||||
|             headers: { |               }) | ||||||
|                 'Authorization': 'Bearer ' + accessToken |       ).catch((err) => { | ||||||
|             } |           console.log(err); | ||||||
|         }).then(handleErrorsRaw)), |       });*/ | ||||||
|         updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { |     }, | ||||||
|             method: 'PATCH', |     createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', { | ||||||
|             headers: { |       method: 'POST', | ||||||
|                 'Authorization': 'Bearer ' + accessToken, |       headers: { | ||||||
|                 'Content-Type': 'application/json' |         'Authorization': 'Bearer ' + accessToken, | ||||||
|             }, |         'Content-Type': 'application/json', | ||||||
|             body: JSON.stringify(translateEvent(event)) |       }, | ||||||
|         }).then(handleErrorsRaw)), |       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
 | // factory
 | ||||||
| const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { | const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => { | ||||||
|     switch (cred.type) { |   switch (cred.type) { | ||||||
|         case 'zoom_video': |     case 'zoom_video': | ||||||
|             return ZoomVideo(cred); |       return ZoomVideo(cred); | ||||||
|         default: |     default: | ||||||
|             return; // unknown credential, could be legacy? In any case, ignore
 |       return; // unknown credential, could be legacy? In any case, ignore
 | ||||||
|     } |   } | ||||||
| }).filter(Boolean); | }).filter(Boolean); | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( | const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( | ||||||
|   videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) |   videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)) | ||||||
| ).then( | ).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<any> => { | const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => { | ||||||
|     if(!credential) { |   const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); | ||||||
|         throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called."); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     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 = { |   const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent); | ||||||
|         type: credential.type, |  | ||||||
|         id: creationResult.id, |  | ||||||
|         password: creationResult.password, |  | ||||||
|         url: creationResult.join_url, |  | ||||||
|     }; |  | ||||||
| 
 | 
 | ||||||
|     const mail = new VideoEventOwnerMail(calEvent, hashUID, videoCallData); |   const videoCallData: VideoCallData = { | ||||||
|     const sentMail = await mail.sendEmail(); |     type: credential.type, | ||||||
|  |     id: creationResult.id, | ||||||
|  |     password: creationResult.password, | ||||||
|  |     url: creationResult.join_url, | ||||||
|  |   }; | ||||||
| 
 | 
 | ||||||
|     return { |   const ownerMail = new VideoEventOwnerMail(calEvent, uid, videoCallData); | ||||||
|         createdEvent: creationResult, |   const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); | ||||||
|         sentMail: sentMail |   await ownerMail.sendEmail(); | ||||||
|     }; | 
 | ||||||
|  |   if(!creationResult || !creationResult.disableConfirmationEmail) { | ||||||
|  |     await attendeeMail.sendEmail(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     uid, | ||||||
|  |     createdEvent: creationResult | ||||||
|  |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const updateMeeting = (credential, uid: String, event: CalendarEvent): Promise<any> => { | const updateMeeting = (credential, uid: String, event: CalendarEvent): Promise<any> => { | ||||||
|     if (credential) { |   if (credential) { | ||||||
|         return videoIntegrations([credential])[0].updateMeeting(uid, event); |     return videoIntegrations([credential])[0].updateMeeting(uid, event); | ||||||
|     } |   } | ||||||
| 
 | 
 | ||||||
|     return Promise.resolve({}); |   return Promise.resolve({}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const deleteMeeting = (credential, uid: String): Promise<any> => { | const deleteMeeting = (credential, uid: String): Promise<any> => { | ||||||
|     if (credential) { |   if (credential) { | ||||||
|         return videoIntegrations([credential])[0].deleteMeeting(uid); |     return videoIntegrations([credential])[0].deleteMeeting(uid); | ||||||
|     } |   } | ||||||
| 
 | 
 | ||||||
|     return Promise.resolve({}); |   return Promise.resolve({}); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting}; | export {getBusyTimes, createMeeting, updateMeeting, deleteMeeting}; | ||||||
|  |  | ||||||
|  | @ -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({ |   const eventType = await prisma.eventType.findFirst({ | ||||||
|     where: { |     where: { | ||||||
|       userId: currentUser.id, |       userId: currentUser.id, | ||||||
|  | @ -115,7 +113,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | ||||||
|   } else { |   } else { | ||||||
|     // Schedule event
 |     // Schedule event
 | ||||||
|     results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { |     results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => { | ||||||
|       const response = await createEvent(credential, evt, hashUID); |       const response = await createEvent(credential, evt); | ||||||
|       return { |       return { | ||||||
|         type: credential.type, |         type: credential.type, | ||||||
|         response |         response | ||||||
|  | @ -123,7 +121,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | ||||||
|     })); |     })); | ||||||
| 
 | 
 | ||||||
|     results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { |     results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => { | ||||||
|       const response = await createMeeting(credential, evt, hashUID); |       const response = await createMeeting(credential, evt); | ||||||
|       return { |       return { | ||||||
|         type: credential.type, |         type: credential.type, | ||||||
|         response |         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({ |   await prisma.booking.create({ | ||||||
|     data: { |     data: { | ||||||
|       uid: hashUID, |       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); |   res.status(200).json(results); | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue
	
	 nicolas
						nicolas