Introduced CalEventParser to acquire rich descriptions for events in integrations

This commit is contained in:
nicolas 2021-06-29 23:43:18 +02:00
parent 3aa1e1716d
commit 098b95ef55
8 changed files with 268 additions and 115 deletions

108
lib/CalEventParser.ts Normal file
View file

@ -0,0 +1,108 @@
import { CalendarEvent } from "./calendarClient";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import { stripHtml } from "./emails/helpers";
const translator = short();
export default class CalEventParser {
calEvent: CalendarEvent;
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
}
/**
* Returns a link to reschedule the given booking.
*/
public getRescheduleLink(): string {
return process.env.BASE_URL + "/reschedule/" + this.getUid();
}
/**
* Returns a link to cancel the given booking.
*/
public getCancelLink(): string {
return process.env.BASE_URL + "/cancel/" + this.getUid();
}
/**
* Returns a unique identifier for the given calendar event.
*/
public getUid(): string {
return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
}
/**
* Returns a footer section with links to change the event (as HTML).
*/
public getChangeEventFooterHtml(): string {
return `
<br/>
<br/>
<strong>Need to change this event?</strong><br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
}
/**
* Returns a footer section with links to change the event (as plain text).
*/
public getChangeEventFooter(): string {
return stripHtml(this.getChangeEventFooterHtml());
}
/**
* Returns an extended description with all important information (as HTML).
*
* @protected
*/
public getRichDescriptionHtml(): string {
return (
`
<div>
<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.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}
` +
this.getChangeEventFooterHtml() +
`
</div>
`
);
}
/**
* Returns an extended description with all important information (as plain text).
*
* @protected
*/
public getRichDescription(): string {
return stripHtml(this.getRichDescriptionHtml());
}
/**
* Returns a calendar event with rich description.
*/
public asRichEvent(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescription();
return eventCopy;
}
}

View file

@ -1,15 +1,12 @@
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventAttendeeMail from "./emails/EventAttendeeMail";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
const translator = short();
import prisma from "./prisma";
import CalEventParser from "./CalEventParser";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { google } = require("googleapis");
import prisma from "./prisma";
const googleAuth = (credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
@ -105,12 +102,14 @@ const o365Auth = (credential) => {
};
};
// eslint-disable-next-line
interface Person {
name?: string;
email: string;
timeZone: string;
}
// eslint-disable-next-line
interface CalendarEvent {
type: string;
title: string;
@ -123,10 +122,12 @@ interface CalendarEvent {
conferenceData?: ConferenceData;
}
// eslint-disable-next-line
interface ConferenceData {
createRequest: any;
}
// eslint-disable-next-line
interface IntegrationCalendar {
integration: string;
primary: boolean;
@ -134,6 +135,7 @@ interface IntegrationCalendar {
name: string;
}
// eslint-disable-next-line
interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>;
@ -507,9 +509,11 @@ const listCalendars = (withCredentials) =>
);
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const parser: CalEventParser = new CalEventParser(calEvent);
const uid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEvent();
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;
const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
const organizerMail = new EventOrganizerMail(calEvent, uid);
const attendeeMail = new EventAttendeeMail(calEvent, uid);
@ -534,10 +538,12 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> =>
};
const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise<any> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const parser: CalEventParser = new CalEventParser(calEvent);
const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEvent();
const updateResult = credential
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent)
? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent)
: null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);

View file

@ -1,9 +1,9 @@
import dayjs, {Dayjs} from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import EventMail from "./EventMail";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import localizedFormat from "dayjs/plugin/localizedFormat";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
@ -15,20 +15,28 @@ export default class EventAttendeeMail extends EventMail {
* @protected
*/
protected getHtmlRepresentation(): string {
return `
return (
`
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.<br />
<br />` + this.getAdditionalBody() + (
this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : ''
) +
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format(
"h:mma"
)}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format(
"dddd, LL"
)} is scheduled.<br />
<br />` +
this.getAdditionalBody() +
(this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "") +
`<strong>Additional notes:</strong><br />
${this.calEvent.description}<br />
` + this.getAdditionalFooter() + `
` +
this.getAdditionalFooter() +
`
</div>
`;
`
);
}
/**
@ -36,12 +44,14 @@ export default class EventAttendeeMail extends EventMail {
*
* @protected
*/
protected getNodeMailerPayload(): Object {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `Confirmed: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`,
subject: `Confirmed: ${this.calEvent.type} with ${
this.calEvent.organizer.name
} on ${this.getInviteeStart().format("dddd, LL")}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};

View file

@ -7,15 +7,21 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
* @protected
*/
protected getHtmlRepresentation(): string {
return `
return (
`
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} has been rescheduled to ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')}.<br />
` + this.getAdditionalFooter() + `
Your ${this.calEvent.type} with ${
this.calEvent.organizer.name
} has been rescheduled to ${this.getInviteeStart().format("h:mma")}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format("dddd, LL")}.<br />
` +
this.getAdditionalFooter() +
`
</div>
`;
`
);
}
/**
@ -23,12 +29,14 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
*
* @protected
*/
protected getNodeMailerPayload(): Object {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `Rescheduled: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`,
subject: `Rescheduled: ${this.calEvent.type} with ${
this.calEvent.organizer.name
} on ${this.getInviteeStart().format("dddd, LL")}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};

View file

@ -1,9 +1,12 @@
import {CalendarEvent} from "../calendarClient";
import {serverConfig} from "../serverConfig";
import nodemailer from 'nodemailer';
import { CalendarEvent } from "../calendarClient";
import { serverConfig } from "../serverConfig";
import nodemailer from "nodemailer";
import CalEventParser from "../CalEventParser";
import { stripHtml } from "./helpers";
export default abstract class EventMail {
calEvent: CalendarEvent;
parser: CalEventParser;
uid: string;
/**
@ -17,6 +20,7 @@ export default abstract class EventMail {
constructor(calEvent: CalendarEvent, uid: string) {
this.calEvent = calEvent;
this.uid = uid;
this.parser = new CalEventParser(calEvent);
}
/**
@ -33,41 +37,30 @@ export default abstract class EventMail {
* @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, '');
return stripHtml(this.getHtmlRepresentation());
}
/**
* Returns the payload object for the nodemailer.
* @protected
*/
protected abstract getNodeMailerPayload(): Object;
protected abstract getNodeMailerPayload(): Record<string, unknown>;
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail(): Promise<any> {
new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail(
this.getNodeMailerPayload(),
(error, info) => {
if (error) {
this.printNodeMailerError(error);
reject(new Error(error));
} else {
resolve(info);
}
})
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (error, info) => {
if (error) {
this.printNodeMailerError(error);
reject(new Error(error));
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
@ -109,7 +102,7 @@ export default abstract class EventMail {
* @protected
*/
protected getRescheduleLink(): string {
return process.env.BASE_URL + '/reschedule/' + this.uid;
return this.parser.getRescheduleLink();
}
/**
@ -118,21 +111,14 @@ export default abstract class EventMail {
* @protected
*/
protected getCancelLink(): string {
return process.env.BASE_URL + '/cancel/' + this.uid;
return this.parser.getCancelLink();
}
/**
* Defines a footer that will be appended to the email.
* @protected
*/
protected getAdditionalFooter(): string {
return `
<br/>
<br/>
<strong>Need to change this event?</strong><br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
return this.parser.getChangeEventFooterHtml();
}
}

View file

@ -1,11 +1,13 @@
import {createEvent} from "ics";
import dayjs, {Dayjs} from "dayjs";
import { createEvent } from "ics";
import dayjs, { Dayjs } from "dayjs";
import EventMail from "./EventMail";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import toArray from 'dayjs/plugin/toArray';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { stripHtml } from "./helpers";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
@ -18,14 +20,24 @@ export default class EventOrganizerMail extends EventMail {
*/
protected getiCalEventAsString(): string {
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime).utc().toArray().slice(0, 6).map((v, i) => i === 1 ? v + 1 : v),
startInputType: 'utc',
productId: 'calendso/ics',
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)),
startInputType: "utc",
productId: "calendso/ics",
title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`,
description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.stripHtml(this.getAdditionalFooter()),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), 'minute') },
description:
this.calEvent.description +
stripHtml(this.getAdditionalBody()) +
stripHtml(this.getAdditionalFooter()),
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 }) ),
attendees: this.calEvent.attendees.map((attendee: any) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CONFIRMED",
});
if (icsEvent.error) {
@ -40,7 +52,8 @@ export default class EventOrganizerMail extends EventMail {
* @protected
*/
protected getHtmlRepresentation(): string {
return `
return (
`
<div>
Hi ${this.calEvent.organizer.name},<br />
<br />
@ -51,22 +64,26 @@ export default class EventOrganizerMail extends EventMail {
<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 ? `
<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}
` + this.getAdditionalFooter() + `
` +
this.getAdditionalFooter() +
`
</div>
`;
`
);
}
/**
@ -74,17 +91,19 @@ export default class EventOrganizerMail extends EventMail {
*
* @protected
*/
protected getNodeMailerPayload(): Object {
protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`,
subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${
this.calEvent.type
}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};

View file

@ -1,4 +1,4 @@
import dayjs, {Dayjs} from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import EventOrganizerMail from "./EventOrganizerMail";
export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
@ -8,7 +8,8 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
* @protected
*/
protected getHtmlRepresentation(): string {
return `
return (
`
<div>
Hi ${this.calEvent.organizer.name},<br />
<br />
@ -19,22 +20,26 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
<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 ? `
<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}
` + this.getAdditionalFooter() + `
` +
this.getAdditionalFooter() +
`
</div>
`;
`
);
}
/**
@ -42,17 +47,19 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
*
* @protected
*/
protected getNodeMailerPayload(): Object {
protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`,
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format(
"LT dddd, LL"
)} - ${this.calEvent.type}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};

View file

@ -1,4 +1,4 @@
import {VideoCallData} from "../videoClient";
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.
@ -6,15 +6,24 @@ export function getIntegrationName(videoCallData: VideoCallData): string {
return nameProto.charAt(0).toUpperCase() + nameProto.slice(1);
}
function extractZoom(videoCallData: VideoCallData): string {
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;
}
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;
switch (videoCallData.type) {
case "zoom_video":
return extractZoom(videoCallData);
default:
return videoCallData.id.toString();
}
}
export function stripHtml(html: string): string {
return html.replace("<br />", "\n").replace(/<[^>]+>/g, "");
}