From 24607873c0c54d171dfbbfefea7ba81630ad2ddd Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:28:11 -0500 Subject: [PATCH] refactor: Abstract `handleSeats` from `handleNewBooking` (#12062) * Refactor createBooking * Type fix * Abstract handleSeats * Create Invitee type * Create OrganizerUser type * Abstract addVideoCallDataToEvt * Abstract createLoggerWithEventDetails * Abstract `handleAppStatus` from handler * Create ReqAppsStatus type * Move `deleteMeeting` and `getCalendar` * Set parameters for `handleSeats` * Typescript refactor * Change function params from req * Type fix --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Erik --- .../features/bookings/lib/handleNewBooking.ts | 287 ++++--- packages/features/bookings/lib/handleSeats.ts | 796 ++++++++++++++++++ 2 files changed, 964 insertions(+), 119 deletions(-) create mode 100644 packages/features/bookings/lib/handleSeats.ts diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a951390e95..ce29e75f94 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -6,6 +6,7 @@ import { isValidPhoneNumber } from "libphonenumber-js"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { NextApiRequest } from "next"; +import type { TFunction } from "next-i18next"; import short, { uuid } from "short-uuid"; import type { Logger } from "tslog"; import { v5 as uuidv5 } from "uuid"; @@ -98,6 +99,7 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventTypeInfo } from "../../webhooks/lib/sendPayload"; import getBookingDataSchema from "./getBookingDataSchema"; +import type { BookingSeat } from "./handleSeats"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); @@ -105,7 +107,7 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; type BookingType = Prisma.PromiseReturnType; -type Booking = Prisma.PromiseReturnType; +export type Booking = Prisma.PromiseReturnType; export type NewBookingEventType = | Awaited> | Awaited>; @@ -113,8 +115,36 @@ export type NewBookingEventType = // Work with Typescript to require reqBody.end type ReqBodyWithoutEnd = z.infer>; type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; +export type Invitee = { + email: string; + name: string; + firstName: string; + lastName: string; + timeZone: string; + language: { + translate: TFunction; + locale: string; + }; +}[]; +export type OrganizerUser = Awaited>[number] & { + isFixed?: boolean; + metadata?: Prisma.JsonValue; +}; +export type OriginalRescheduledBooking = Awaited>; -interface IEventTypePaymentCredentialType { +type AwaitedBookingData = Awaited>; +export type RescheduleReason = AwaitedBookingData["rescheduleReason"]; +export type NoEmail = AwaitedBookingData["noEmail"]; +export type AdditionalNotes = AwaitedBookingData["notes"]; +export type ReqAppsStatus = AwaitedBookingData["appsStatus"]; +export type SmsReminderNumber = AwaitedBookingData["smsReminderNumber"]; +export type EventTypeId = AwaitedBookingData["eventTypeId"]; +export type ReqBodyMetadata = ReqBodyWithEnd["metadata"]; + +export type IsConfirmedByDefault = ReturnType["isConfirmedByDefault"]; +export type PaymentAppData = ReturnType; + +export interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; app: { categories: App["categories"]; @@ -148,7 +178,9 @@ async function refreshCredential(credential: CredentialPayload): Promise): Promise> { +export async function refreshCredentials( + credentials: Array +): Promise> { return await async.mapLimit(credentials, 5, refreshCredential); } @@ -156,7 +188,7 @@ async function refreshCredentials(credentials: Array): Promis * Gets credentials from the user, team, and org if applicable * */ -const getAllCredentials = async ( +export const getAllCredentials = async ( user: User & { credentials: CredentialPayload[] }, eventType: Awaited> ) => { @@ -634,18 +666,18 @@ async function createBooking({ paymentAppData, changedOrganizer, }: { - originalRescheduledBooking: Awaited>; + originalRescheduledBooking: OriginalRescheduledBooking; evt: CalendarEvent; eventType: NewBookingEventType; - eventTypeId: Awaited>["eventTypeId"]; - eventTypeSlug: Awaited>["eventTypeSlug"]; + eventTypeId: EventTypeId; + eventTypeSlug: AwaitedBookingData["eventTypeSlug"]; reqBodyUser: ReqBodyWithEnd["user"]; reqBodyMetadata: ReqBodyWithEnd["metadata"]; reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; uid: short.SUUID; responses: ReqBodyWithEnd["responses"] | null; - isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; - smsReminderNumber: Awaited>["smsReminderNumber"]; + isConfirmedByDefault: IsConfirmedByDefault; + smsReminderNumber: AwaitedBookingData["smsReminderNumber"]; organizerUser: Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; @@ -822,6 +854,76 @@ export function getCustomInputsResponses( return customInputsResponses; } +/** Updates the evt object with video call data found from booking references + * + * @param bookingReferences + * @param evt + * + * @returns updated evt with video call data + */ +export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt: CalendarEvent) => { + const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); + + if (videoCallReference) { + evt.videoCallData = { + type: videoCallReference.type, + id: videoCallReference.meetingId, + password: videoCallReference?.meetingPassword, + url: videoCallReference.meetingUrl, + }; + } + + return evt; +}; + +export const createLoggerWithEventDetails = ( + eventTypeId: number, + reqBodyUser: string | string[] | undefined, + eventTypeSlug: string | undefined +) => { + return logger.getSubLogger({ + prefix: ["book:user", `${eventTypeId}:${reqBodyUser}/${eventTypeSlug}`], + }); +}; + +export function handleAppsStatus( + results: EventResult[], + booking: (Booking & { appsStatus?: AppsStatus[] }) | null, + reqAppsStatus: ReqAppsStatus +) { + // Taking care of apps status + let resultStatus: AppsStatus[] = results.map((app) => ({ + appName: app.appName, + type: app.type, + success: app.success ? 1 : 0, + failures: !app.success ? 1 : 0, + errors: app.calError ? [app.calError] : [], + warnings: app.calWarnings, + })); + + if (reqAppsStatus === undefined) { + if (booking !== null) { + booking.appsStatus = resultStatus; + } + return resultStatus; + } + // From down here we can assume reqAppsStatus is not undefined anymore + // Other status exist, so this is the last booking of a series, + // proceeding to prepare the info for the event + const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => { + if (prev[curr.type]) { + prev[curr.type].success += curr.success; + prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors); + prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []); + } else { + prev[curr.type] = curr; + } + return prev; + }, {} as { [key: string]: AppsStatus }); + resultStatus = Object.values(calcAppsStatus); + return resultStatus; +} + function getICalSequence(originalRescheduledBooking: BookingType | null) { // If new booking set the sequence to 0 if (!originalRescheduledBooking) { @@ -837,6 +939,52 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) { return originalRescheduledBooking.iCalSequence + 1; } +export const findBookingQuery = async (bookingId: number) => { + const foundBooking = await prisma.booking.findUnique({ + where: { + id: bookingId, + }, + select: { + uid: true, + location: true, + startTime: true, + endTime: true, + title: true, + description: true, + status: true, + responses: true, + user: { + select: { + name: true, + email: true, + timeZone: true, + username: true, + }, + }, + eventType: { + select: { + title: true, + description: true, + currency: true, + length: true, + lockTimeZoneToggleOnBookingPage: true, + requiresConfirmation: true, + requiresBookerEmailVerification: true, + price: true, + }, + }, + }, + }); + + // This should never happen but it's just typescript safe + if (!foundBooking) { + throw new Error("Internal Error. Couldn't find booking"); + } + + // Don't leak any sensitive data + return foundBooking; +}; + async function handler( req: NextApiRequest & { userId?: number | undefined }, { @@ -884,9 +1032,7 @@ async function handler( eventType, }); - const loggerWithEventDetails = logger.getSubLogger({ - prefix: ["book:user", `${eventTypeId}:${reqBody.user}/${eventTypeSlug}`], - }); + const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { logger.settings.minLevel = 0; @@ -1052,7 +1198,7 @@ async function handler( } let rescheduleUid = reqBody.rescheduleUid; - let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null; + let bookingSeat: BookingSeat = null; let originalRescheduledBooking: BookingType = null; @@ -1176,7 +1322,7 @@ async function handler( } } - const invitee = [ + const invitee: Invitee = [ { email: bookerEmail, name: fullName, @@ -1201,7 +1347,7 @@ async function handler( language: { translate: tGuests, locale: "en" }, }); return guestArray; - }, [] as typeof invitee); + }, [] as Invitee); const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); @@ -1314,20 +1460,6 @@ async function handler( evt.destinationCalendar?.push(...teamDestinationCalendars); } - /* Used for seats bookings to update evt object with video data */ - const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => { - const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); - - if (videoCallReference) { - evt.videoCallData = { - type: videoCallReference.type, - id: videoCallReference.meetingId, - password: videoCallReference?.meetingPassword, - url: videoCallReference.meetingUrl, - }; - } - }; - /* Check if the original booking has no more attendees, if so delete the booking and any calendar or video integrations */ const lastAttendeeDeleteBooking = async ( @@ -1574,7 +1706,7 @@ async function handler( }, }); - addVideoCallDataToEvt(newBooking.references); + evt = addVideoCallDataToEvt(newBooking.references, evt); const copyEvent = cloneDeep(evt); @@ -1611,7 +1743,7 @@ async function handler( metadata.hangoutLink = updatedEvent.hangoutLink; metadata.conferenceData = updatedEvent.conferenceData; metadata.entryPoints = updatedEvent.entryPoints; - evt.appsStatus = handleAppsStatus(results, newBooking); + evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus); } } } @@ -1719,7 +1851,7 @@ async function handler( evt.attendees = updatedBookingAttendees; - addVideoCallDataToEvt(updatedNewBooking.references); + evt = addVideoCallDataToEvt(updatedNewBooking.references, evt); const copyEvent = cloneDeep(evt); @@ -2167,43 +2299,6 @@ async function handler( const credentials = await refreshCredentials(allCredentials); const eventManager = new EventManager({ ...organizerUser, credentials }); - function handleAppsStatus( - results: EventResult[], - booking: (Booking & { appsStatus?: AppsStatus[] }) | null - ) { - // Taking care of apps status - let resultStatus: AppsStatus[] = results.map((app) => ({ - appName: app.appName, - type: app.type, - success: app.success ? 1 : 0, - failures: !app.success ? 1 : 0, - errors: app.calError ? [app.calError] : [], - warnings: app.calWarnings, - })); - - if (reqAppsStatus === undefined) { - if (booking !== null) { - booking.appsStatus = resultStatus; - } - return resultStatus; - } - // From down here we can assume reqAppsStatus is not undefined anymore - // Other status exist, so this is the last booking of a series, - // proceeding to prepare the info for the event - const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => { - if (prev[curr.type]) { - prev[curr.type].success += curr.success; - prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors); - prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []); - } else { - prev[curr.type] = curr; - } - return prev; - }, {} as { [key: string]: AppsStatus }); - resultStatus = Object.values(calcAppsStatus); - return resultStatus; - } - let videoCallUrl; //this is the actual rescheduling logic @@ -2219,7 +2314,7 @@ async function handler( ); } - addVideoCallDataToEvt(originalRescheduledBooking.references); + addVideoCallDataToEvt(originalRescheduledBooking.references, evt); //update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot) { @@ -2346,7 +2441,7 @@ async function handler( metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.entryPoints = results[0].createdEvent?.entryPoints; - evt.appsStatus = handleAppsStatus(results, booking); + evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = metadata.hangoutLink || results[0].createdEvent?.url || @@ -2361,7 +2456,7 @@ async function handler( : calendarResult?.updatedEvent?.iCalUID || undefined; } - evt.appsStatus = handleAppsStatus(results, booking); + evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); // If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) { @@ -2516,7 +2611,7 @@ async function handler( metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.entryPoints = results[0].createdEvent?.entryPoints; - evt.appsStatus = handleAppsStatus(results, booking); + evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; @@ -2911,49 +3006,3 @@ function handleCustomInputs( } }); } - -const findBookingQuery = async (bookingId: number) => { - const foundBooking = await prisma.booking.findUnique({ - where: { - id: bookingId, - }, - select: { - uid: true, - location: true, - startTime: true, - endTime: true, - title: true, - description: true, - status: true, - responses: true, - user: { - select: { - name: true, - email: true, - timeZone: true, - username: true, - }, - }, - eventType: { - select: { - title: true, - description: true, - currency: true, - length: true, - lockTimeZoneToggleOnBookingPage: true, - requiresConfirmation: true, - requiresBookerEmailVerification: true, - price: true, - }, - }, - }, - }); - - // This should never happen but it's just typescript safe - if (!foundBooking) { - throw new Error("Internal Error. Couldn't find booking"); - } - - // Don't leak any sensitive data - return foundBooking; -}; diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts new file mode 100644 index 0000000000..9f993ebd65 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats.ts @@ -0,0 +1,796 @@ +import type { Prisma, Attendee } from "@prisma/client"; +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; +import type { TFunction } from "next-i18next"; +import type short from "short-uuid"; +import { uuid } from "short-uuid"; + +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import EventManager from "@calcom/core/EventManager"; +import { deleteMeeting } from "@calcom/core/videoClient"; +import dayjs from "@calcom/dayjs"; +import { sendRescheduledEmails, sendRescheduledSeatEmail, sendScheduledSeatsEmails } from "@calcom/emails"; +import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; +import { + allowDisablingAttendeeConfirmationEmails, + allowDisablingHostConfirmationEmails, +} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import type { getFullName } from "@calcom/features/form-builder/utils"; +import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; +import { HttpError } from "@calcom/lib/http-error"; +import { handlePayment } from "@calcom/lib/payment/handlePayment"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; + +import type { EventTypeInfo } from "../../webhooks/lib/sendPayload"; +import { + refreshCredentials, + addVideoCallDataToEvt, + createLoggerWithEventDetails, + handleAppsStatus, + findBookingQuery, +} from "./handleNewBooking"; +import type { + Booking, + Invitee, + NewBookingEventType, + getAllCredentials, + OrganizerUser, + OriginalRescheduledBooking, + RescheduleReason, + NoEmail, + IsConfirmedByDefault, + AdditionalNotes, + ReqAppsStatus, + PaymentAppData, + IEventTypePaymentCredentialType, + SmsReminderNumber, + EventTypeId, + ReqBodyMetadata, +} from "./handleNewBooking"; + +export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; + +/* Check if the original booking has no more attendees, if so delete the booking + and any calendar or video integrations */ +const lastAttendeeDeleteBooking = async ( + originalRescheduledBooking: OriginalRescheduledBooking, + filteredAttendees: Partial[], + originalBookingEvt?: CalendarEvent +) => { + let deletedReferences = false; + if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) { + const integrationsToDelete = []; + + for (const reference of originalRescheduledBooking.references) { + if (reference.credentialId) { + const credential = await prisma.credential.findUnique({ + where: { + id: reference.credentialId, + }, + select: credentialForCalendarServiceSelect, + }); + + if (credential) { + if (reference.type.includes("_video")) { + integrationsToDelete.push(deleteMeeting(credential, reference.uid)); + } + if (reference.type.includes("_calendar") && originalBookingEvt) { + const calendar = await getCalendar(credential); + if (calendar) { + integrationsToDelete.push( + calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId) + ); + } + } + } + } + } + + await Promise.all(integrationsToDelete).then(async () => { + await prisma.booking.update({ + where: { + id: originalRescheduledBooking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + }); + deletedReferences = true; + } + return deletedReferences; +}; + +const handleSeats = async ({ + rescheduleUid, + reqBookingUid, + eventType, + evt, + invitee, + allCredentials, + organizerUser, + originalRescheduledBooking, + bookerEmail, + tAttendees, + bookingSeat, + reqUserId, + rescheduleReason, + reqBodyUser, + noEmail, + isConfirmedByDefault, + additionalNotes, + reqAppsStatus, + attendeeLanguage, + paymentAppData, + fullName, + smsReminderNumber, + eventTypeInfo, + uid, + eventTypeId, + reqBodyMetadata, + subscriberOptions, + eventTrigger, +}: { + rescheduleUid: string; + reqBookingUid: string; + eventType: NewBookingEventType; + evt: CalendarEvent; + invitee: Invitee; + allCredentials: Awaited>; + organizerUser: OrganizerUser; + originalRescheduledBooking: OriginalRescheduledBooking; + bookerEmail: string; + tAttendees: TFunction; + bookingSeat: BookingSeat; + reqUserId: number | undefined; + rescheduleReason: RescheduleReason; + reqBodyUser: string | string[] | undefined; + noEmail: NoEmail; + isConfirmedByDefault: IsConfirmedByDefault; + additionalNotes: AdditionalNotes; + reqAppsStatus: ReqAppsStatus; + attendeeLanguage: string | null; + paymentAppData: PaymentAppData; + fullName: ReturnType; + smsReminderNumber: SmsReminderNumber; + eventTypeInfo: EventTypeInfo; + uid: short.SUUID; + eventTypeId: EventTypeId; + reqBodyMetadata: ReqBodyMetadata; + subscriberOptions: GetSubscriberOptions; + eventTrigger: WebhookTriggerEvents; +}) => { + const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); + + let resultBooking: + | (Partial & { + appsStatus?: AppsStatus[]; + seatReferenceUid?: string; + paymentUid?: string; + message?: string; + paymentId?: number; + }) + | null = null; + + const booking = await prisma.booking.findFirst({ + where: { + OR: [ + { + uid: rescheduleUid || reqBookingUid, + }, + { + eventTypeId: eventType.id, + startTime: evt.startTime, + }, + ], + status: BookingStatus.ACCEPTED, + }, + select: { + uid: true, + id: true, + attendees: { include: { bookingSeat: true } }, + userId: true, + references: true, + startTime: true, + user: true, + status: true, + smsReminderNumber: true, + endTime: true, + scheduledJobs: true, + }, + }); + + if (!booking) { + throw new HttpError({ statusCode: 404, message: "Could not find booking" }); + } + + // See if attendee is already signed up for timeslot + if ( + booking.attendees.find((attendee) => attendee.email === invitee[0].email) && + dayjs.utc(booking.startTime).format() === evt.startTime + ) { + throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." }); + } + + // There are two paths here, reschedule a booking with seats and booking seats without reschedule + if (rescheduleUid) { + // See if the new date has a booking already + const newTimeSlotBooking = await prisma.booking.findFirst({ + where: { + startTime: evt.startTime, + eventTypeId: eventType.id, + status: BookingStatus.ACCEPTED, + }, + select: { + id: true, + uid: true, + attendees: { + include: { + bookingSeat: true, + }, + }, + }, + }); + + const credentials = await refreshCredentials(allCredentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); + + if (!originalRescheduledBooking) { + // typescript isn't smart enough; + throw new Error("Internal Error."); + } + + const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce( + (filteredAttendees, attendee) => { + if (attendee.email === bookerEmail) { + return filteredAttendees; // skip current booker, as we know the language already. + } + filteredAttendees.push({ + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }); + return filteredAttendees; + }, + [] as Person[] + ); + + // If original booking has video reference we need to add the videoCallData to the new evt + const videoReference = originalRescheduledBooking.references.find((reference) => + reference.type.includes("_video") + ); + + const originalBookingEvt = { + ...evt, + title: originalRescheduledBooking.title, + startTime: dayjs(originalRescheduledBooking.startTime).utc().format(), + endTime: dayjs(originalRescheduledBooking.endTime).utc().format(), + attendees: updatedBookingAttendees, + // If the location is a video integration then include the videoCallData + ...(videoReference && { + videoCallData: { + type: videoReference.type, + id: videoReference.meetingId, + password: videoReference.meetingPassword, + url: videoReference.meetingUrl, + }, + }), + }; + + if (!bookingSeat) { + // if no bookingSeat is given and the userId != owner, 401. + // TODO: Next step; Evaluate ownership, what about teams? + if (booking.user?.id !== reqUserId) { + throw new HttpError({ statusCode: 401 }); + } + + // Moving forward in this block is the owner making changes to the booking. All attendees should be affected + evt.attendees = originalRescheduledBooking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }; + }); + + // If owner reschedules the event we want to update the entire booking + // Also if owner is rescheduling there should be no bookingSeat + + // If there is no booking during the new time slot then update the current booking to the new date + if (!newTimeSlotBooking) { + const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + startTime: evt.startTime, + endTime: evt.endTime, + cancellationReason: rescheduleReason, + }, + include: { + user: true, + references: true, + payment: true, + attendees: true, + }, + }); + + evt = addVideoCallDataToEvt(newBooking.references, evt); + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id); + + // @NOTE: This code is duplicated and should be moved to a function + // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; + + const results = updateManager.results; + + const calendarResult = results.find((result) => result.type.includes("_calendar")); + + evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined; + + if (results.length > 0 && results.some((res) => !res.success)) { + const error = { + errorCode: "BookingReschedulingMeetingFailed", + message: "Booking Rescheduling failed", + }; + loggerWithEventDetails.error( + `Booking ${organizerUser.name} failed`, + JSON.stringify({ error, results }) + ); + } else { + const metadata: AdditionalInformation = {}; + if (results.length) { + // TODO: Handle created event metadata more elegantly + const [updatedEvent] = Array.isArray(results[0].updatedEvent) + ? results[0].updatedEvent + : [results[0].updatedEvent]; + if (updatedEvent) { + metadata.hangoutLink = updatedEvent.hangoutLink; + metadata.conferenceData = updatedEvent.conferenceData; + metadata.entryPoints = updatedEvent.entryPoints; + evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus); + } + } + } + + if (noEmail !== true && isConfirmedByDefault) { + const copyEvent = cloneDeep(evt); + loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } + const foundBooking = await findBookingQuery(newBooking.id); + + resultBooking = { ...foundBooking, appsStatus: newBooking.appsStatus }; + } else { + // Merge two bookings together + const attendeesToMove = [], + attendeesToDelete = []; + + for (const attendee of booking.attendees) { + // If the attendee already exists on the new booking then delete the attendee record of the old booking + if ( + newTimeSlotBooking.attendees.some( + (newBookingAttendee) => newBookingAttendee.email === attendee.email + ) + ) { + attendeesToDelete.push(attendee.id); + // If the attendee does not exist on the new booking then move that attendee record to the new booking + } else { + attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id }); + } + } + + // Confirm that the new event will have enough available seats + if ( + !eventType.seatsPerTimeSlot || + attendeesToMove.length + + newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length > + eventType.seatsPerTimeSlot + ) { + throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" }); + } + + const moveAttendeeCalls = []; + for (const attendeeToMove of attendeesToMove) { + moveAttendeeCalls.push( + prisma.attendee.update({ + where: { + id: attendeeToMove.id, + }, + data: { + bookingId: newTimeSlotBooking.id, + bookingSeat: { + upsert: { + create: { + referenceUid: uuid(), + bookingId: newTimeSlotBooking.id, + }, + update: { + bookingId: newTimeSlotBooking.id, + }, + }, + }, + }, + }) + ); + } + + await Promise.all([ + ...moveAttendeeCalls, + // Delete any attendees that are already a part of that new time slot booking + prisma.attendee.deleteMany({ + where: { + id: { + in: attendeesToDelete, + }, + }, + }), + ]); + + const updatedNewBooking = await prisma.booking.findUnique({ + where: { + id: newTimeSlotBooking.id, + }, + include: { + attendees: true, + references: true, + }, + }); + + if (!updatedNewBooking) { + throw new HttpError({ statusCode: 404, message: "Updated booking not found" }); + } + + // Update the evt object with the new attendees + const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => { + const evtAttendee = { + ...attendee, + language: { translate: tAttendees, locale: attendeeLanguage ?? "en" }, + }; + return evtAttendee; + }); + + evt.attendees = updatedBookingAttendees; + + evt = addVideoCallDataToEvt(updatedNewBooking.references, evt); + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id); + + const results = updateManager.results; + + const calendarResult = results.find((result) => result.type.includes("_calendar")); + + evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) + ? calendarResult?.updatedEvent[0]?.iCalUID + : calendarResult?.updatedEvent?.iCalUID || undefined; + + if (noEmail !== true && isConfirmedByDefault) { + // TODO send reschedule emails to attendees of the old booking + loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } + + // Update the old booking with the cancelled status + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + resultBooking = { ...foundBooking }; + } + } + + // seatAttendee is null when the organizer is rescheduling. + const seatAttendee: Partial | null = bookingSeat?.attendee || null; + if (seatAttendee) { + seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" }; + + // If there is no booking then remove the attendee from the old booking and create a new one + if (!newTimeSlotBooking) { + await prisma.attendee.delete({ + where: { + id: seatAttendee?.id, + }, + }); + + // Update the original calendar event by removing the attendee that is rescheduling + if (originalBookingEvt && originalRescheduledBooking) { + // Event would probably be deleted so we first check than instead of updating references + const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { + return attendee.email !== bookerEmail; + }); + const deletedReference = await lastAttendeeDeleteBooking( + originalRescheduledBooking, + filteredAttendees, + originalBookingEvt + ); + + if (!deletedReference) { + await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking); + } + } + + // We don't want to trigger rescheduling logic of the original booking + originalRescheduledBooking = null; + + return null; + } + + // Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking + // https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones + if (seatAttendee?.id && bookingSeat?.id) { + await Promise.all([ + await prisma.attendee.update({ + where: { + id: seatAttendee.id, + }, + data: { + bookingId: newTimeSlotBooking.id, + }, + }), + await prisma.bookingSeat.update({ + where: { + id: bookingSeat.id, + }, + data: { + bookingId: newTimeSlotBooking.id, + }, + }), + ]); + } + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id); + + const results = updateManager.results; + + const calendarResult = results.find((result) => result.type.includes("_calendar")); + + evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) + ? calendarResult?.updatedEvent[0]?.iCalUID + : calendarResult?.updatedEvent?.iCalUID || undefined; + + await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person); + const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { + return attendee.email !== bookerEmail; + }); + await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid }; + } + } else { + // Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language + const bookingAttendees = booking.attendees.map((attendee) => { + return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } }; + }); + + evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] }; + + if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= booking.attendees.length) { + throw new HttpError({ statusCode: 409, message: "Booking seats are full" }); + } + + const videoCallReference = booking.references.find((reference) => reference.type.includes("_video")); + + if (videoCallReference) { + evt.videoCallData = { + type: videoCallReference.type, + id: videoCallReference.meetingId, + password: videoCallReference?.meetingPassword, + url: videoCallReference.meetingUrl, + }; + } + + const attendeeUniqueId = uuid(); + + await prisma.booking.update({ + where: { + uid: reqBookingUid, + }, + include: { + attendees: true, + }, + data: { + attendees: { + create: { + email: invitee[0].email, + name: invitee[0].name, + timeZone: invitee[0].timeZone, + locale: invitee[0].language.locale, + bookingSeat: { + create: { + referenceUid: attendeeUniqueId, + data: { + description: additionalNotes, + }, + booking: { + connect: { + id: booking.id, + }, + }, + }, + }, + }, + }, + ...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }), + }, + }); + + evt.attendeeSeatId = attendeeUniqueId; + + const newSeat = booking.attendees.length !== 0; + + /** + * Remember objects are passed into functions as references + * so if you modify it in a inner function it will be modified in the outer function + * deep cloning evt to avoid this + */ + if (!evt?.uid) { + evt.uid = booking?.uid ?? null; + } + const copyEvent = cloneDeep(evt); + copyEvent.uid = booking.uid; + if (noEmail !== true) { + let isHostConfirmationEmailsDisabled = false; + let isAttendeeConfirmationEmailDisabled = false; + + const workflows = eventType.workflows.map((workflow) => workflow.workflow); + + if (eventType.workflows) { + isHostConfirmationEmailsDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.host || false; + isAttendeeConfirmationEmailDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; + + if (isHostConfirmationEmailsDisabled) { + isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); + } + + if (isAttendeeConfirmationEmailDisabled) { + isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); + } + } + await sendScheduledSeatsEmails( + copyEvent, + invitee[0], + newSeat, + !!eventType.seatsShowAttendees, + isHostConfirmationEmailsDisabled, + isAttendeeConfirmationEmailDisabled + ); + } + const credentials = await refreshCredentials(allCredentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); + await eventManager.updateCalendarAttendees(evt, booking); + + const foundBooking = await findBookingQuery(booking.id); + + if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) { + const credentialPaymentAppCategories = await prisma.credential.findMany({ + where: { + ...(paymentAppData.credentialId + ? { id: paymentAppData.credentialId } + : { userId: organizerUser.id }), + app: { + categories: { + hasSome: ["payment"], + }, + }, + }, + select: { + key: true, + appId: true, + app: { + select: { + categories: true, + dirName: true, + }, + }, + }, + }); + + const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => { + return credential.appId === paymentAppData.appId; + }); + + if (!eventTypePaymentAppCredential) { + throw new HttpError({ statusCode: 400, message: "Missing payment credentials" }); + } + if (!eventTypePaymentAppCredential?.appId) { + throw new HttpError({ statusCode: 400, message: "Missing payment app id" }); + } + + const payment = await handlePayment( + evt, + eventType, + eventTypePaymentAppCredential as IEventTypePaymentCredentialType, + booking, + fullName, + bookerEmail + ); + + resultBooking = { ...foundBooking }; + resultBooking["message"] = "Payment required"; + resultBooking["paymentUid"] = payment?.uid; + resultBooking["id"] = payment?.id; + } else { + resultBooking = { ...foundBooking }; + } + + resultBooking["seatReferenceUid"] = evt.attendeeSeatId; + } + + // Here we should handle every after action that needs to be done after booking creation + + // Obtain event metadata that includes videoCallUrl + const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined; + try { + await scheduleWorkflowReminders({ + workflows: eventType.workflows, + smsReminderNumber: smsReminderNumber || null, + calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } }, + isNotConfirmed: evt.requiresConfirmation || false, + isRescheduleEvent: !!rescheduleUid, + isFirstRecurringEvent: true, + emailAttendeeSendToOverride: bookerEmail, + seatReferenceUid: evt.attendeeSeatId, + eventTypeRequiresConfirmation: eventType.requiresConfirmation, + }); + } catch (error) { + loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); + } + + const webhookData = { + ...evt, + ...eventTypeInfo, + uid: resultBooking?.uid || uid, + bookingId: booking?.id, + rescheduleUid, + rescheduleStartTime: originalRescheduledBooking?.startTime + ? dayjs(originalRescheduledBooking?.startTime).utc().format() + : undefined, + rescheduleEndTime: originalRescheduledBooking?.endTime + ? dayjs(originalRescheduledBooking?.endTime).utc().format() + : undefined, + metadata: { ...metadata, ...reqBodyMetadata }, + eventTypeId, + status: "ACCEPTED", + smsReminderNumber: booking?.smsReminderNumber || undefined, + }; + + await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); + + return resultBooking; +}; + +export default handleSeats;