Bugfix/include zoom location (#715)

* Store video data in event location; fixed several types

* fixed malformed id

* Insert Zoom data when updating as well

* Add columns to store (video) meetings

* Store meeting data

* fixed type

* Use stored videoCallData

* Store location in field as well

* Use meta field for booking reference

* Introduced meta field in code

* Revert "Introduced meta field in code"

This reverts commit 535baccee3d87e3e793e84c4b91f8cad0e09063f.

* Revert "Use meta field for booking reference"

This reverts commit 174c252f672bcc3e461c8b3b975ac7541066d6a8.

* Linting fixes

Co-authored-by: nicolas <privat@nicolasjessen.de>
Co-authored-by: Peer_Rich <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
This commit is contained in:
Nico 2021-09-23 00:43:10 +02:00 committed by GitHub
parent 4f964533cf
commit 3764a9d462
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 190 additions and 40 deletions

View file

@ -1,18 +1,21 @@
import short from "short-uuid"; import short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import { CalendarEvent } from "./calendarClient"; import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers"; import { stripHtml } from "./emails/helpers";
import { VideoCallData } from "@lib/videoClient";
import { getIntegrationName } from "@lib/integrations";
const translator = short(); const translator = short();
export default class CalEventParser { export default class CalEventParser {
protected calEvent: CalendarEvent; protected calEvent: CalendarEvent;
protected maybeUid: string; protected maybeUid?: string;
protected optionalVideoCallData?: VideoCallData;
constructor(calEvent: CalendarEvent, maybeUid: string = null) { constructor(calEvent: CalendarEvent, maybeUid?: string, optionalVideoCallData?: VideoCallData) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.maybeUid = maybeUid; this.maybeUid = maybeUid;
this.optionalVideoCallData = optionalVideoCallData;
} }
/** /**
@ -62,16 +65,46 @@ export default class CalEventParser {
<strong>Event Type:</strong><br />${this.calEvent.type}<br /> <strong>Event Type:</strong><br />${this.calEvent.type}<br />
<strong>Invitee Email:</strong><br /><a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br /> <strong>Invitee Email:</strong><br /><a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
` + ` +
(this.calEvent.location (this.getLocation()
? `<strong>Location:</strong><br />${this.calEvent.location}<br /> ? `<strong>Location:</strong><br />${this.getLocation()}<br />
` `
: "") + : "") +
`<strong>Invitee Time Zone:</strong><br />${this.calEvent.attendees[0].timeZone}<br /> `<strong>Invitee Time Zone:</strong><br />${this.calEvent.attendees[0].timeZone}<br />
<strong>Additional notes:</strong><br />${this.calEvent.description}<br />` + <strong>Additional notes:</strong><br />${this.getDescriptionText()}<br />` +
this.getChangeEventFooterHtml() this.getChangeEventFooterHtml()
); );
} }
/**
* Conditionally returns the event's location. When VideoCallData is set,
* it returns the meeting url. Otherwise, the regular location is returned.
*
* @protected
*/
protected getLocation(): string | undefined {
if (this.optionalVideoCallData) {
return this.optionalVideoCallData.url;
}
return this.calEvent.location;
}
/**
* Returns the event's description text. If VideoCallData is set, it prepends
* some video call information before the text as well.
*
* @protected
*/
protected getDescriptionText(): string | undefined {
if (this.optionalVideoCallData) {
return `
${getIntegrationName(this.optionalVideoCallData.type)} meeting
ID: ${this.optionalVideoCallData.id}
Password: ${this.optionalVideoCallData.password}
${this.calEvent.description}`;
}
return this.calEvent.description;
}
/** /**
* Returns an extended description with all important information (as plain text). * Returns an extended description with all important information (as plain text).
* *
@ -87,6 +120,7 @@ export default class CalEventParser {
public asRichEvent(): CalendarEvent { public asRichEvent(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent }; const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescriptionHtml(); eventCopy.description = this.getRichDescriptionHtml();
eventCopy.location = this.getLocation();
return eventCopy; return eventCopy;
} }
@ -96,6 +130,7 @@ export default class CalEventParser {
public asRichEventPlain(): CalendarEvent { public asRichEventPlain(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent }; const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescription(); eventCopy.description = this.getRichDescription();
eventCopy.location = this.getLocation();
return eventCopy; return eventCopy;
} }
} }

View file

@ -1,14 +1,13 @@
import { Prisma, Credential } from "@prisma/client"; import { Prisma, Credential } from "@prisma/client";
import { EventResult } from "@lib/events/EventManager"; import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
import CalEventParser from "./CalEventParser"; import CalEventParser from "./CalEventParser";
import EventOrganizerMail from "./emails/EventOrganizerMail"; import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter"; import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter"; import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
import prisma from "./prisma"; import prisma from "./prisma";
import { VideoCallData } from "@lib/videoClient";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
@ -554,9 +553,10 @@ const createEvent = async (
credential: Credential, credential: Credential,
calEvent: CalendarEvent, calEvent: CalendarEvent,
noMail = false, noMail = false,
maybeUid: string = null maybeUid?: string,
optionalVideoCallData?: VideoCallData
): Promise<EventResult> => { ): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); const parser: CalEventParser = new CalEventParser(calEvent, maybeUid, optionalVideoCallData);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
/* /*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r). * Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
@ -607,9 +607,10 @@ const updateEvent = async (
credential: Credential, credential: Credential,
uidToUpdate: string, uidToUpdate: string,
calEvent: CalendarEvent, calEvent: CalendarEvent,
noMail = false noMail = false,
optionalVideoCallData?: VideoCallData
): Promise<EventResult> => { ): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent); const parser: CalEventParser = new CalEventParser(calEvent, undefined, optionalVideoCallData);
const newUid: string = parser.getUid(); const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain(); const richEvent: CalendarEvent = parser.asRichEventPlain();

View file

@ -37,7 +37,7 @@ export default abstract class EventMail {
* @param uid * @param uid
* @param additionInformation * @param additionInformation
*/ */
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) { constructor(calEvent: CalendarEvent, uid: string, additionInformation?: AdditionInformation) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.uid = uid; this.uid = uid;
this.parser = new CalEventParser(calEvent, uid); this.parser = new CalEventParser(calEvent, uid);

View file

@ -8,7 +8,7 @@ import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
import { createMeeting, updateMeeting } from "@lib/videoClient"; import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient";
export interface EventResult { export interface EventResult {
type: string; type: string;
@ -17,6 +17,7 @@ export interface EventResult {
createdEvent?: unknown; createdEvent?: unknown;
updatedEvent?: unknown; updatedEvent?: unknown;
originalEvent: CalendarEvent; originalEvent: CalendarEvent;
videoCallData?: VideoCallData;
} }
export interface CreateUpdateResult { export interface CreateUpdateResult {
@ -33,6 +34,9 @@ export interface PartialReference {
id?: number; id?: number;
type: string; type: string;
uid: string; uid: string;
meetingId?: string;
meetingPassword?: string;
meetingUrl?: string;
} }
interface GetLocationRequestFromIntegrationRequest { interface GetLocationRequestFromIntegrationRequest {
@ -62,23 +66,37 @@ export default class EventManager {
* @param event * @param event
* @param maybeUid * @param maybeUid
*/ */
public async create(event: CalendarEvent, maybeUid: string = null): Promise<CreateUpdateResult> { public async create(event: CalendarEvent, maybeUid?: string): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event); event = EventManager.processLocation(event);
const isDedicated = EventManager.isDedicatedIntegration(event.location); const isDedicated = EventManager.isDedicatedIntegration(event.location);
// First, create all calendar events. If this is a dedicated integration event, don't send a mail right here. let results: Array<EventResult> = [];
const results: Array<EventResult> = await this.createAllCalendarEvents(event, isDedicated, maybeUid); let optionalVideoCallData: VideoCallData | undefined = undefined;
// If and only if event type is a dedicated meeting, create a dedicated video meeting as well.
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
if (isDedicated) { if (isDedicated) {
results.push(await this.createVideoEvent(event, maybeUid)); const result = await this.createVideoEvent(event, maybeUid);
if (result.videoCallData) {
optionalVideoCallData = result.videoCallData;
}
results.push(result);
} else { } else {
await this.sendAttendeeMail("new", results, event, maybeUid); await EventManager.sendAttendeeMail("new", results, event, maybeUid);
} }
const referencesToCreate: Array<PartialReference> = results.map((result) => { // Now create all calendar events. If this is a dedicated integration event,
// don't send a mail right here, because it has already been sent.
results = results.concat(
await this.createAllCalendarEvents(event, isDedicated, maybeUid, optionalVideoCallData)
);
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
return { return {
type: result.type, type: result.type,
uid: result.createdEvent.id.toString(), uid: result.createdEvent.id.toString(),
meetingId: result.videoCallData?.id.toString(),
meetingPassword: result.videoCallData?.password,
meetingUrl: result.videoCallData?.url,
}; };
}); });
@ -110,6 +128,9 @@ export default class EventManager {
id: true, id: true,
type: true, type: true,
uid: true, uid: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
}, },
}, },
}, },
@ -117,16 +138,26 @@ export default class EventManager {
const isDedicated = EventManager.isDedicatedIntegration(event.location); const isDedicated = EventManager.isDedicatedIntegration(event.location);
// First, update all calendar events. If this is a dedicated event, don't send a mail right here. let results: Array<EventResult> = [];
const results: Array<EventResult> = await this.updateAllCalendarEvents(event, booking, isDedicated); let optionalVideoCallData: VideoCallData | undefined = undefined;
// If and only if event type is a dedicated meeting, update the dedicated video meeting as well. // If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) { if (isDedicated) {
results.push(await this.updateVideoEvent(event, booking)); const result = await this.updateVideoEvent(event, booking);
if (result.videoCallData) {
optionalVideoCallData = result.videoCallData;
}
results.push(result);
} else { } else {
await this.sendAttendeeMail("reschedule", results, event, rescheduleUid); await EventManager.sendAttendeeMail("reschedule", results, event, rescheduleUid);
} }
// Now update all calendar events. If this is a dedicated integration event,
// don't send a mail right here, because it has already been sent.
results = results.concat(
await this.updateAllCalendarEvents(event, booking, isDedicated, optionalVideoCallData)
);
// Now we can delete the old booking and its references. // Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: { where: {
@ -164,15 +195,17 @@ export default class EventManager {
* @param event * @param event
* @param noMail * @param noMail
* @param maybeUid * @param maybeUid
* @param optionalVideoCallData
* @private * @private
*/ */
private createAllCalendarEvents( private createAllCalendarEvents(
event: CalendarEvent, event: CalendarEvent,
noMail: boolean, noMail: boolean,
maybeUid: string = null maybeUid?: string,
optionalVideoCallData?: VideoCallData
): Promise<Array<EventResult>> { ): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
return createEvent(credential, event, noMail, maybeUid); return createEvent(credential, event, noMail, maybeUid, optionalVideoCallData);
}); });
} }
@ -196,7 +229,7 @@ export default class EventManager {
* @param maybeUid * @param maybeUid
* @private * @private
*/ */
private createVideoEvent(event: CalendarEvent, maybeUid: string = null): Promise<EventResult> { private createVideoEvent(event: CalendarEvent, maybeUid?: string): Promise<EventResult> {
const credential = this.getVideoCredential(event); const credential = this.getVideoCredential(event);
if (credential) { if (credential) {
@ -220,11 +253,12 @@ export default class EventManager {
private updateAllCalendarEvents( private updateAllCalendarEvents(
event: CalendarEvent, event: CalendarEvent,
booking: PartialBooking, booking: PartialBooking,
noMail: boolean noMail: boolean,
optionalVideoCallData?: VideoCallData
): Promise<Array<EventResult>> { ): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential) => { return async.mapLimit(this.calendarCredentials, 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 updateEvent(credential, bookingRefUid, event, noMail); return updateEvent(credential, bookingRefUid, event, noMail, optionalVideoCallData);
}); });
} }
@ -239,8 +273,15 @@ export default class EventManager {
const credential = this.getVideoCredential(event); const credential = this.getVideoCredential(event);
if (credential) { if (credential) {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; const bookingRef = booking.references.filter((ref) => ref.type === credential.type)[0];
return updateMeeting(credential, bookingRefUid, event);
return updateMeeting(credential, bookingRef.uid, event).then((returnVal: EventResult) => {
// Some video integrations, such as Zoom, don't return any data about the booking when updating it.
if (returnVal.videoCallData == undefined) {
returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef);
}
return returnVal;
});
} else { } else {
return Promise.reject("No suitable credentials given for the requested integration name."); return Promise.reject("No suitable credentials given for the requested integration name.");
} }
@ -310,7 +351,58 @@ export default class EventManager {
return event; return event;
} }
private async sendAttendeeMail(type: "new" | "reschedule", results, event, maybeUid) { /**
* Accepts a PartialReference object and, if all data is complete,
* returns a VideoCallData object containing the meeting information.
*
* @param reference
* @private
*/
private static bookingReferenceToVideoCallData(reference: PartialReference): VideoCallData | undefined {
let isComplete = true;
switch (reference.type) {
case "zoom_video":
// Zoom meetings in our system should always have an ID, a password and a join URL. In the
// future, it might happen that we consider making passwords for Zoom meetings optional.
// Then, this part below (where the password existence is checked) needs to be adapted.
isComplete =
reference.meetingId != undefined &&
reference.meetingPassword != undefined &&
reference.meetingUrl != undefined;
break;
default:
isComplete = true;
}
if (isComplete) {
return {
type: reference.type,
// The null coalescing operator should actually never be used here, because we checked if it's defined beforehand.
id: reference.meetingId ?? "",
password: reference.meetingPassword ?? "",
url: reference.meetingUrl ?? "",
};
} else {
return undefined;
}
}
/**
* Conditionally sends an email to the attendee.
*
* @param type
* @param results
* @param event
* @param maybeUid
* @private
*/
private static async sendAttendeeMail(
type: "new" | "reschedule",
results: Array<EventResult>,
event: CalendarEvent,
maybeUid?: string
) {
if ( if (
!results.length || !results.length ||
!results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent).disableConfirmationEmail) !results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent).disableConfirmationEmail)

View file

@ -219,7 +219,7 @@ const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> =
const createMeeting = async ( const createMeeting = async (
credential: Credential, credential: Credential,
calEvent: CalendarEvent, calEvent: CalendarEvent,
maybeUid: string = null maybeUid?: string
): Promise<EventResult> => { ): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
@ -279,6 +279,7 @@ const createMeeting = async (
uid, uid,
createdEvent: creationResult, createdEvent: creationResult,
originalEvent: calEvent, originalEvent: calEvent,
videoCallData: videoCallData,
}; };
}; };

View file

@ -77,9 +77,11 @@
}, },
"devDependencies": { "devDependencies": {
"@trivago/prettier-plugin-sort-imports": "2.0.4", "@trivago/prettier-plugin-sort-imports": "2.0.4",
"@types/async": "^3.2.7",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/jest": "^27.0.1", "@types/jest": "^27.0.1",
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
"@types/lodash.merge": "^4.6.6",
"@types/node": "^16.6.1", "@types/node": "^16.6.1",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.1", "@types/qrcode": "^1.4.1",

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "BookingReference" ADD COLUMN "meetingId" TEXT,
ADD COLUMN "meetingPassword" TEXT,
ADD COLUMN "meetingUrl" TEXT;

View file

@ -135,11 +135,14 @@ model VerificationRequest {
} }
model BookingReference { model BookingReference {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type String type String
uid String uid String
booking Booking? @relation(fields: [bookingId], references: [id]) meetingId String?
bookingId Int? meetingPassword String?
meetingUrl String?
booking Booking? @relation(fields: [bookingId], references: [id])
bookingId Int?
} }
model Attendee { model Attendee {

View file

@ -1450,6 +1450,11 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==
"@types/async@^3.2.7":
version "3.2.7"
resolved "https://registry.yarnpkg.com/@types/async/-/async-3.2.7.tgz#f784478440d313941e7b12c2e4db53b0ed55637b"
integrity sha512-a+MBBfOTs3ShFMlbH9qsRVFkjIUunEtxrBT0gxRx1cntjKRg2WApuGmNYzHkwKaIhMi3SMbKktaD/rLObQMwIw==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14":
version "7.1.16" version "7.1.16"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.16.tgz#bc12c74b7d65e82d29876b5d0baf5c625ac58702" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.16.tgz#bc12c74b7d65e82d29876b5d0baf5c625ac58702"
@ -1535,6 +1540,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
"@types/lodash.merge@^4.6.6":
version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6"
integrity sha512-IB90krzMf7YpfgP3u/EvZEdXVvm4e3gJbUvh5ieuI+o+XqiNEt6fCzqNRaiLlPVScLI59RxIGZMQ3+Ko/DJ8vQ==
dependencies:
"@types/lodash" "*"
"@types/lodash.debounce@^4.0.6": "@types/lodash.debounce@^4.0.6":
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60" resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"