diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts
index 57bcd091..5f8e57ad 100644
--- a/lib/CalEventParser.ts
+++ b/lib/CalEventParser.ts
@@ -1,18 +1,21 @@
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
-
import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers";
+import { VideoCallData } from "@lib/videoClient";
+import { getIntegrationName } from "@lib/integrations";
const translator = short();
export default class CalEventParser {
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.maybeUid = maybeUid;
+ this.optionalVideoCallData = optionalVideoCallData;
}
/**
@@ -62,16 +65,46 @@ export default class CalEventParser {
Event Type:
${this.calEvent.type}
Invitee Email:
${this.calEvent.attendees[0].email}
` +
- (this.calEvent.location
- ? `Location:
${this.calEvent.location}
+ (this.getLocation()
+ ? `Location:
${this.getLocation()}
`
: "") +
`Invitee Time Zone:
${this.calEvent.attendees[0].timeZone}
-Additional notes:
${this.calEvent.description}
` +
+Additional notes:
${this.getDescriptionText()}
` +
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).
*
@@ -87,6 +120,7 @@ export default class CalEventParser {
public asRichEvent(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescriptionHtml();
+ eventCopy.location = this.getLocation();
return eventCopy;
}
@@ -96,6 +130,7 @@ export default class CalEventParser {
public asRichEventPlain(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescription();
+ eventCopy.location = this.getLocation();
return eventCopy;
}
}
diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts
index ece32763..ef6dc19a 100644
--- a/lib/calendarClient.ts
+++ b/lib/calendarClient.ts
@@ -1,14 +1,13 @@
import { Prisma, Credential } from "@prisma/client";
-
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
-
import CalEventParser from "./CalEventParser";
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
import prisma from "./prisma";
+import { VideoCallData } from "@lib/videoClient";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
@@ -554,9 +553,10 @@ const createEvent = async (
credential: Credential,
calEvent: CalendarEvent,
noMail = false,
- maybeUid: string = null
+ maybeUid?: string,
+ optionalVideoCallData?: VideoCallData
): Promise => {
- const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
+ const parser: CalEventParser = new CalEventParser(calEvent, maybeUid, optionalVideoCallData);
const uid: string = parser.getUid();
/*
* 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,
uidToUpdate: string,
calEvent: CalendarEvent,
- noMail = false
+ noMail = false,
+ optionalVideoCallData?: VideoCallData
): Promise => {
- const parser: CalEventParser = new CalEventParser(calEvent);
+ const parser: CalEventParser = new CalEventParser(calEvent, undefined, optionalVideoCallData);
const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain();
diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts
index a04350c0..998aaee5 100644
--- a/lib/emails/EventMail.ts
+++ b/lib/emails/EventMail.ts
@@ -37,7 +37,7 @@ export default abstract class EventMail {
* @param uid
* @param additionInformation
*/
- constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
+ constructor(calEvent: CalendarEvent, uid: string, additionInformation?: AdditionInformation) {
this.calEvent = calEvent;
this.uid = uid;
this.parser = new CalEventParser(calEvent, uid);
diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts
index 8c565586..436c1ba7 100644
--- a/lib/events/EventManager.ts
+++ b/lib/events/EventManager.ts
@@ -8,7 +8,7 @@ import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
import { LocationType } from "@lib/location";
import prisma from "@lib/prisma";
-import { createMeeting, updateMeeting } from "@lib/videoClient";
+import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient";
export interface EventResult {
type: string;
@@ -17,6 +17,7 @@ export interface EventResult {
createdEvent?: unknown;
updatedEvent?: unknown;
originalEvent: CalendarEvent;
+ videoCallData?: VideoCallData;
}
export interface CreateUpdateResult {
@@ -33,6 +34,9 @@ export interface PartialReference {
id?: number;
type: string;
uid: string;
+ meetingId?: string;
+ meetingPassword?: string;
+ meetingUrl?: string;
}
interface GetLocationRequestFromIntegrationRequest {
@@ -62,23 +66,37 @@ export default class EventManager {
* @param event
* @param maybeUid
*/
- public async create(event: CalendarEvent, maybeUid: string = null): Promise {
+ public async create(event: CalendarEvent, maybeUid?: string): Promise {
event = EventManager.processLocation(event);
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.
- const results: Array = await this.createAllCalendarEvents(event, isDedicated, maybeUid);
- // If and only if event type is a dedicated meeting, create a dedicated video meeting as well.
+ let results: Array = [];
+ let optionalVideoCallData: VideoCallData | undefined = undefined;
+
+ // If and only if event type is a dedicated meeting, create a dedicated video meeting.
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 {
- await this.sendAttendeeMail("new", results, event, maybeUid);
+ await EventManager.sendAttendeeMail("new", results, event, maybeUid);
}
- const referencesToCreate: Array = 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 = results.map((result: EventResult) => {
return {
type: result.type,
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,
type: true,
uid: true,
+ meetingId: true,
+ meetingPassword: true,
+ meetingUrl: true,
},
},
},
@@ -117,16 +138,26 @@ export default class EventManager {
const isDedicated = EventManager.isDedicatedIntegration(event.location);
- // First, update all calendar events. If this is a dedicated event, don't send a mail right here.
- const results: Array = await this.updateAllCalendarEvents(event, booking, isDedicated);
+ let results: Array = [];
+ 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) {
- results.push(await this.updateVideoEvent(event, booking));
+ const result = await this.updateVideoEvent(event, booking);
+ if (result.videoCallData) {
+ optionalVideoCallData = result.videoCallData;
+ }
+ results.push(result);
} 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.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
@@ -164,15 +195,17 @@ export default class EventManager {
* @param event
* @param noMail
* @param maybeUid
+ * @param optionalVideoCallData
* @private
*/
private createAllCalendarEvents(
event: CalendarEvent,
noMail: boolean,
- maybeUid: string = null
+ maybeUid?: string,
+ optionalVideoCallData?: VideoCallData
): Promise> {
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
* @private
*/
- private createVideoEvent(event: CalendarEvent, maybeUid: string = null): Promise {
+ private createVideoEvent(event: CalendarEvent, maybeUid?: string): Promise {
const credential = this.getVideoCredential(event);
if (credential) {
@@ -220,11 +253,12 @@ export default class EventManager {
private updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking,
- noMail: boolean
+ noMail: boolean,
+ optionalVideoCallData?: VideoCallData
): Promise> {
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
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);
if (credential) {
- const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
- return updateMeeting(credential, bookingRefUid, event);
+ const bookingRef = booking.references.filter((ref) => ref.type === credential.type)[0];
+
+ 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 {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
@@ -310,7 +351,58 @@ export default class EventManager {
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,
+ event: CalendarEvent,
+ maybeUid?: string
+ ) {
if (
!results.length ||
!results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent).disableConfirmationEmail)
diff --git a/lib/videoClient.ts b/lib/videoClient.ts
index 7ed0a1bb..1e9f2cf5 100644
--- a/lib/videoClient.ts
+++ b/lib/videoClient.ts
@@ -219,7 +219,7 @@ const getBusyVideoTimes: (withCredentials: Credential[]) => Promise =
const createMeeting = async (
credential: Credential,
calEvent: CalendarEvent,
- maybeUid: string = null
+ maybeUid?: string
): Promise => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid();
@@ -279,6 +279,7 @@ const createMeeting = async (
uid,
createdEvent: creationResult,
originalEvent: calEvent,
+ videoCallData: videoCallData,
};
};
diff --git a/package.json b/package.json
index 3d6825c7..7bc5ec23 100644
--- a/package.json
+++ b/package.json
@@ -77,9 +77,11 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "2.0.4",
+ "@types/async": "^3.2.7",
"@types/bcryptjs": "^2.4.2",
"@types/jest": "^27.0.1",
"@types/lodash.debounce": "^4.0.6",
+ "@types/lodash.merge": "^4.6.6",
"@types/node": "^16.6.1",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.1",
diff --git a/prisma/migrations/20210913211650_add_meeting_info/migration.sql b/prisma/migrations/20210913211650_add_meeting_info/migration.sql
new file mode 100644
index 00000000..6d797403
--- /dev/null
+++ b/prisma/migrations/20210913211650_add_meeting_info/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "BookingReference" ADD COLUMN "meetingId" TEXT,
+ADD COLUMN "meetingPassword" TEXT,
+ADD COLUMN "meetingUrl" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6871a13c..df9b0fec 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -135,11 +135,14 @@ model VerificationRequest {
}
model BookingReference {
- id Int @id @default(autoincrement())
- type String
- uid String
- booking Booking? @relation(fields: [bookingId], references: [id])
- bookingId Int?
+ id Int @id @default(autoincrement())
+ type String
+ uid String
+ meetingId String?
+ meetingPassword String?
+ meetingUrl String?
+ booking Booking? @relation(fields: [bookingId], references: [id])
+ bookingId Int?
}
model Attendee {
diff --git a/yarn.lock b/yarn.lock
index 272b01a2..3e80b7d3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1450,6 +1450,11 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
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":
version "7.1.16"
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"
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":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"