diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts new file mode 100644 index 00000000..4668fb77 --- /dev/null +++ b/lib/CalEventParser.ts @@ -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 ` +
+
+ Need to change this event?
+ Cancel: ${this.getCancelLink()}
+ Reschedule: ${this.getRescheduleLink()} + `; + } + + /** + * 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 ( + ` +
+ Event Type:
+ ${this.calEvent.type}
+
+ Invitee Email:
+ ${this.calEvent.attendees[0].email}
+
` + + (this.calEvent.location + ? ` + Location:
+ ${this.calEvent.location}
+
+ ` + : "") + + `Invitee Time Zone:
+ ${this.calEvent.attendees[0].timeZone}
+
+ Additional notes:
+ ${this.calEvent.description} + ` + + this.getChangeEventFooterHtml() + + ` +
+ ` + ); + } + + /** + * 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; + } +} diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 4d8d7421..80b17de2 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -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; @@ -507,9 +509,11 @@ const listCalendars = (withCredentials) => ); const createEvent = async (credential, calEvent: CalendarEvent): Promise => { - 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 => }; const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise => { - 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); diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts index 55f231f9..cc4cf460 100644 --- a/lib/emails/EventAttendeeMail.ts +++ b/lib/emails/EventAttendeeMail.ts @@ -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 ( + `
Hi ${this.calEvent.attendees[0].name},

- 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.
-
` + this.getAdditionalBody() + ( - this.calEvent.location ? `Location: ${this.calEvent.location}

` : '' - ) + + 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.
+
` + + this.getAdditionalBody() + + (this.calEvent.location ? `Location: ${this.calEvent.location}

` : "") + `Additional notes:
${this.calEvent.description}
- ` + this.getAdditionalFooter() + ` + ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } /** @@ -36,12 +44,14 @@ export default class EventAttendeeMail extends EventMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { 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(), }; @@ -59,4 +69,4 @@ export default class EventAttendeeMail extends EventMail { protected getInviteeStart(): Dayjs { return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); } -} \ No newline at end of file +} diff --git a/lib/emails/EventAttendeeRescheduledMail.ts b/lib/emails/EventAttendeeRescheduledMail.ts index 760aa040..1286c7ab 100644 --- a/lib/emails/EventAttendeeRescheduledMail.ts +++ b/lib/emails/EventAttendeeRescheduledMail.ts @@ -7,15 +7,21 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { * @protected */ protected getHtmlRepresentation(): string { - return ` + return ( + `
Hi ${this.calEvent.attendees[0].name},

- 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')}.
- ` + 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")}.
+ ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } /** @@ -23,12 +29,14 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { 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(), }; @@ -37,4 +45,4 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { protected printNodeMailerError(error: string): void { console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); } -} \ No newline at end of file +} diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index de1c0507..9ff1cabb 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -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('
', "\n") - .replace(/<[^>]+>/g, ''); + return stripHtml(this.getHtmlRepresentation()); } /** * Returns the payload object for the nodemailer. * @protected */ - protected abstract getNodeMailerPayload(): Object; + protected abstract getNodeMailerPayload(): Record; /** * Sends the email to the event attendant and returns a Promise. */ public sendEmail(): Promise { - 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 ` -
-
- Need to change this event?
- Cancel: ${this.getCancelLink()}
- Reschedule: ${this.getRescheduleLink()} - `; + return this.parser.getChangeEventFooterHtml(); } } diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts index 6d3060b6..20409ce2 100644 --- a/lib/emails/EventOrganizerMail.ts +++ b/lib/emails/EventOrganizerMail.ts @@ -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 ( + `
Hi ${this.calEvent.organizer.name},

@@ -51,22 +64,26 @@ export default class EventOrganizerMail extends EventMail {
Invitee Email:
${this.calEvent.attendees[0].email}
-
` + this.getAdditionalBody() + - ( - this.calEvent.location ? ` +
` + + this.getAdditionalBody() + + (this.calEvent.location + ? ` Location:
${this.calEvent.location}

- ` : '' - ) + + ` + : "") + `Invitee Time Zone:
${this.calEvent.attendees[0].timeZone}

Additional notes:
${this.calEvent.description} - ` + this.getAdditionalFooter() + ` + ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } /** @@ -74,17 +91,19 @@ export default class EventOrganizerMail extends EventMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { const organizerStart: 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(), }; @@ -93,4 +112,4 @@ export default class EventOrganizerMail extends EventMail { protected printNodeMailerError(error: string): void { console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } -} \ No newline at end of file +} diff --git a/lib/emails/EventOrganizerRescheduledMail.ts b/lib/emails/EventOrganizerRescheduledMail.ts index 7e67ac44..af9cc864 100644 --- a/lib/emails/EventOrganizerRescheduledMail.ts +++ b/lib/emails/EventOrganizerRescheduledMail.ts @@ -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 ( + `
Hi ${this.calEvent.organizer.name},

@@ -19,22 +20,26 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
Invitee Email:
${this.calEvent.attendees[0].email}
-
` + this.getAdditionalBody() + - ( - this.calEvent.location ? ` +
` + + this.getAdditionalBody() + + (this.calEvent.location + ? ` Location:
${this.calEvent.location}

- ` : '' - ) + + ` + : "") + `Invitee Time Zone:
${this.calEvent.attendees[0].timeZone}

Additional notes:
${this.calEvent.description} - ` + this.getAdditionalFooter() + ` + ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } /** @@ -42,17 +47,19 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { const organizerStart: 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(), }; @@ -61,4 +68,4 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail { protected printNodeMailerError(error: string): void { console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } -} \ No newline at end of file +} diff --git a/lib/emails/helpers.ts b/lib/emails/helpers.ts index ed5a10c4..e5218a0a 100644 --- a/lib/emails/helpers.ts +++ b/lib/emails/helpers.ts @@ -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(); } -} \ No newline at end of file +} + +export function stripHtml(html: string): string { + return html.replace("
", "\n").replace(/<[^>]+>/g, ""); +}