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'); const {google} = require('googleapis');
import createNewEventEmail from "./emails/new-event";
const googleAuth = () => { const googleAuth = () => {
const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; 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), []) (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( const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;
calEvent,
);
if (credential) { return {
return calendars([credential])[0].createEvent(calEvent); createdEvent: creationResult,
} sentMail: sentMail
};
return Promise.resolve({});
}; };
const updateEvent = (credential, uid: String, calEvent: CalendarEvent): Promise<any> => { 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 prisma from "./prisma";
import {VideoCallData} from "./emails/confirm-booked";
import {CalendarEvent} from "./calendarClient";
import VideoEventOwnerMail from "./emails/VideoEventOwnerMail";
function handleErrorsJson(response) { function handleErrorsJson(response) {
if (!response.ok) { 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 { 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); deleteMeeting(uid: String);
@ -83,17 +70,17 @@ const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential); 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 // Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
const meet = { return {
topic: meeting.title, topic: event.title,
type: 2, // Means that this is a scheduled meeting type: 2, // Means that this is a scheduled meeting
start_time: meeting.startTime, start_time: event.startTime,
duration: ((new Date(meeting.endTime)).getTime() - (new Date(meeting.startTime)).getTime()) / 60000, 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?) //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? //password: "string", TODO: Should we use a password? Maybe generate a random one?
agenda: meeting.description, agenda: event.description,
settings: { settings: {
host_video: true, host_video: true,
participant_video: true, participant_video: true,
@ -110,8 +97,6 @@ const ZoomVideo = (credential): VideoApiAdapter => {
registrants_email_notification: true registrants_email_notification: true
} }
}; };
return meet;
}; };
return { return {
@ -149,13 +134,13 @@ const ZoomVideo = (credential): VideoApiAdapter => {
console.log(err); 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', method: 'POST',
headers: { headers: {
'Authorization': 'Bearer ' + accessToken, 'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(translateMeeting(meeting)) body: JSON.stringify(translateEvent(event))
}).then(handleErrorsJson)), }).then(handleErrorsJson)),
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, { deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'DELETE', method: 'DELETE',
@ -163,13 +148,13 @@ const ZoomVideo = (credential): VideoApiAdapter => {
'Authorization': 'Bearer ' + accessToken 'Authorization': 'Bearer ' + accessToken
} }
}).then(handleErrorsRaw)), }).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', method: 'PATCH',
headers: { headers: {
'Authorization': 'Bearer ' + accessToken, 'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(translateMeeting(meeting)) body: JSON.stringify(translateEvent(event))
}).then(handleErrorsRaw)), }).then(handleErrorsRaw)),
} }
}; };
@ -191,23 +176,32 @@ const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
(results) => results.reduce((acc, availability) => acc.concat(availability), []) (results) => results.reduce((acc, availability) => acc.concat(availability), [])
); );
const createMeeting = (credential, meeting: VideoMeeting): Promise<any> => { const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
if(!credential) {
//TODO Send email to event host throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called.");
/*createNewMeetingEmail(
meeting,
);*/
if (credential) {
return videoIntegrations([credential])[0].createMeeting(meeting);
} }
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) { if (credential) {
return videoIntegrations([credential])[0].updateMeeting(uid, meeting); return videoIntegrations([credential])[0].updateMeeting(uid, event);
} }
return Promise.resolve({}); return Promise.resolve({});
@ -221,4 +215,4 @@ const deleteMeeting = (credential, uid: String): Promise<any> => {
return Promise.resolve({}); 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 type {NextApiRequest, NextApiResponse} from 'next';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient'; import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient';
import createConfirmBookedEmail, {VideoCallData} from "../../../lib/emails/confirm-booked";
import async from 'async'; import async from 'async';
import {v5 as uuidv5} from 'uuid'; import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid'; import short from 'short-uuid';
import {createMeeting, updateMeeting, VideoMeeting} from "../../../lib/videoClient"; import {createMeeting, updateMeeting} from "../../../lib/videoClient";
const translator = short(); 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 hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID; const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID;
const rescheduleLink:string = process.env.BASE_URL + '/reschedule/' + 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) => { results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; 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 // 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) => { results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
const response = await createMeeting(credential, meeting); const response = await createMeeting(credential, evt);
return { return {
type: credential.type, type: credential.type,
response response
@ -157,7 +144,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
referencesToCreate = results.map((result => { referencesToCreate = results.map((result => {
return { return {
type: result.type, 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 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( await createConfirmBookedEmail(
evt, cancelLink, rescheduleLink, {}, videoCallData evt, cancelLink, rescheduleLink, {}, videoCallData
); );
} }*/
res.status(200).json(results); res.status(200).json(results);
} }