Introduced EventOwnerMail and VideoEventOwnerMail as class based implementations

This commit is contained in:
nicolas 2021-06-16 23:40:13 +02:00
parent 51a8bafaa7
commit e37dd017c8
5 changed files with 231 additions and 81 deletions

View File

@ -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> => {

View 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 "";
}
}

View 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 />
`;
}
}

View File

@ -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 mail = new VideoEventOwnerMail(calEvent, videoCallData);
const sentMail = await mail.sendEmail();
return {
createdEvent: creationResult,
sentMail: sentMail
};
};
const updateMeeting = (credential, uid: String, meeting: VideoMeeting): Promise<any> => {
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};

View File

@ -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);
}