diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index 57bcd09160..5f8e57adfe 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 ece32763c8..ef6dc19a22 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 a04350c09c..998aaee5c5 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 8c565586a3..436c1ba79a 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 7ed0a1bb4f..1e9f2cf5db 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 3d6825c7bc..7bc5ec23f9 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 0000000000..6d7974038b --- /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 6871a13c67..df9b0fec86 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 272b01a2e3..3e80b7d37b 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"