import type { DestinationCalendar } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { NextApiRequest } from "next"; import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId"; import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata"; import { getLocationValueForDB, MeetLocationType, OrganizerDefaultConferencingAppType, } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; import monitorCallbackAsync from "@calcom/core/sentryWrapper"; import dayjs from "@calcom/dayjs"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail, sendRescheduledEmailsAndSMS, sendRoundRobinCancelledEmailsAndSMS, sendRoundRobinRescheduledEmailsAndSMS, sendRoundRobinScheduledEmailsAndSMS, sendScheduledEmailsAndSMS, } from "@calcom/emails"; import getICalUID from "@calcom/emails/lib/getICalUID"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { getFullName } from "@calcom/features/form-builder/utils"; import { UsersRepository } from "@calcom/features/users/users.repository"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { deleteWebhookScheduledTriggers, scheduleTrigger, } from "@calcom/features/webhooks/lib/scheduleTrigger"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { isRerouting, shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { handlePayment } from "@calcom/lib/payment/handlePayment"; import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getLuckyUser } from "@calcom/lib/server/getLuckyUser"; import { getTranslation } from "@calcom/lib/server/i18n"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { CreationSource } from "@calcom/prisma/enums"; import { eventTypeAppMetadataOptionalSchema, eventTypeMetaDataSchemaWithTypedApps, } from "@calcom/prisma/zod-utils"; import type { PlatformClientParams } from "@calcom/prisma/zod-utils"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload"; import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials"; import getBookingDataSchema from "./getBookingDataSchema"; import { addVideoCallDataToEvent } from "./handleNewBooking/addVideoCallDataToEvent"; import { checkBookingAndDurationLimits } from "./handleNewBooking/checkBookingAndDurationLimits"; import { checkIfBookerEmailIsBlocked } from "./handleNewBooking/checkIfBookerEmailIsBlocked"; import { createBooking } from "./handleNewBooking/createBooking"; import { ensureAvailableUsers } from "./handleNewBooking/ensureAvailableUsers"; import { getBookingData } from "./handleNewBooking/getBookingData"; import { getCustomInputsResponses } from "./handleNewBooking/getCustomInputsResponses"; import { getEventTypesFromDB } from "./handleNewBooking/getEventTypesFromDB"; import type { getEventTypeResponse } from "./handleNewBooking/getEventTypesFromDB"; import { getLocationValuesForDb } from "./handleNewBooking/getLocationValuesForDb"; import { getOriginalRescheduledBooking } from "./handleNewBooking/getOriginalRescheduledBooking"; import { getRequiresConfirmationFlags } from "./handleNewBooking/getRequiresConfirmationFlags"; import { getSeatedBooking } from "./handleNewBooking/getSeatedBooking"; import { getVideoCallDetails } from "./handleNewBooking/getVideoCallDetails"; import { handleAppsStatus } from "./handleNewBooking/handleAppsStatus"; import { loadAndValidateUsers } from "./handleNewBooking/loadAndValidateUsers"; import { scheduleNoShowTriggers } from "./handleNewBooking/scheduleNoShowTriggers"; import type { Booking, BookingType, IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser, } from "./handleNewBooking/types"; import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds"; import { validateEventLength } from "./handleNewBooking/validateEventLength"; import handleSeats from "./handleSeats/handleSeats"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); export const createLoggerWithEventDetails = ( eventTypeId: number, reqBodyUser: string | string[] | undefined, eventTypeSlug: string | undefined ) => { return logger.getSubLogger({ prefix: ["book:user", `${eventTypeId}:${reqBodyUser}/${eventTypeSlug}`], }); }; function assertNonEmptyArray(arr: T[]): asserts arr is [T, ...T[]] { if (arr.length === 0) { throw new Error("Array should have at least one item, but it's empty"); } } function getICalSequence(originalRescheduledBooking: BookingType | null) { // If new booking set the sequence to 0 if (!originalRescheduledBooking) { return 0; } // If rescheduling and there is no sequence set, assume sequence should be 1 if (!originalRescheduledBooking.iCalSequence) { return 1; } // If rescheduling then increment sequence by 1 return originalRescheduledBooking.iCalSequence + 1; } const getEventType = async ({ eventTypeId, eventTypeSlug, }: { eventTypeId: number; eventTypeSlug?: string; }) => { // handle dynamic user const eventType = !eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId); const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId; return { ...eventType, bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }), }; }; type BookingDataSchemaGetter = | typeof getBookingDataSchema | typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default; /** * Adds the contact owner to be the only lucky user * @returns */ function buildLuckyUsersWithJustContactOwner({ contactOwnerEmail, availableUsers, fixedUserPool, }: { contactOwnerEmail: string | null; availableUsers: IsFixedAwareUser[]; fixedUserPool: IsFixedAwareUser[]; }) { const luckyUsers: Awaited>["qualifiedRRUsers"] = []; if (!contactOwnerEmail) { return luckyUsers; } const isContactOwnerAFixedHostAlready = fixedUserPool.some((user) => user.email === contactOwnerEmail); if (isContactOwnerAFixedHostAlready) { return luckyUsers; } const teamMember = availableUsers.find((user) => user.email === contactOwnerEmail); if (teamMember) { luckyUsers.push(teamMember); } return luckyUsers; } type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }; const buildDryRunBooking = ({ eventTypeId, organizerUser, eventName, startTime, endTime, contactOwnerFromReq, contactOwnerEmail, allHostUsers, isManagedEventType, }: { eventTypeId: number; organizerUser: { id: number; name: string | null; username: string | null; email: string; timeZone: string; }; eventName: string; startTime: string; endTime: string; contactOwnerFromReq: string | null; contactOwnerEmail: string | null; allHostUsers: { id: number }[]; isManagedEventType: boolean; }) => { const booking = { id: -101, uid: "DRY_RUN_UID", iCalUID: "DRY_RUN_ICAL_UID", status: BookingStatus.ACCEPTED, eventTypeId: eventTypeId, user: organizerUser, userId: organizerUser.id, title: eventName, startTime: new Date(startTime), endTime: new Date(endTime), createdAt: new Date(), updatedAt: new Date(), attendees: [], references: [], payment: [], oneTimePassword: null, smsReminderNumber: null, metadata: {}, idempotencyKey: null, userPrimaryEmail: null, description: null, customInputs: null, responses: null, location: null, paid: false, destinationCalendar: null, cancellationReason: null, rejectionReason: null, dynamicEventSlugRef: null, dynamicGroupSlugRef: null, rescheduledFrom: null, fromReschedule: null, recurringEventId: null, seatsReferences: [], workflowReminders: [], scheduledJobs: [], rescheduledTo: null, rescheduledBy: null, destinationCalendarId: null, reassignReason: null, reassignById: null, rescheduled: false, confirmed: false, isRecurringEvent: false, isRecorded: false, iCalSequence: 0, rating: null, ratingFeedback: null, noShowHost: null, cancelledBy: null, creationSource: CreationSource.WEBAPP, } as CreatedBooking; /** * Troubleshooting data */ const troubleshooterData = { organizerUserId: organizerUser.id, eventTypeId, askedContactOwnerEmail: contactOwnerFromReq, usedContactOwnerEmail: contactOwnerEmail, allHostUsers: allHostUsers.map((user) => user.id), isManagedEventType: isManagedEventType, }; return { booking, troubleshooterData, }; }; const buildDryRunEventManager = () => { return { create: async () => ({ results: [], referencesToCreate: [] }), reschedule: async () => ({ results: [], referencesToCreate: [] }), }; }; function buildTroubleshooterData({ eventType, }: { eventType: { id: number; slug: string; }; }) { const troubleshooterData: { organizerUser: { id: number; } | null; eventType: { id: number; slug: string; }; allHostUsers: number[]; luckyUsers: number[]; luckyUserPool: number[]; fixedUsers: number[]; luckyUsersFromFirstBooking: number[]; usedContactOwnerEmail: string | null; askedContactOwnerEmail: string | null; isManagedEventType: boolean; } = { organizerUser: null, eventType: { id: eventType.id, slug: eventType.slug, }, luckyUsers: [], luckyUserPool: [], fixedUsers: [], luckyUsersFromFirstBooking: [], usedContactOwnerEmail: null, allHostUsers: [], askedContactOwnerEmail: null, isManagedEventType: false, }; return troubleshooterData; } async function handler( req: NextApiRequest & PlatformClientParams & { userId?: number | undefined; }, bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema ) { const { userId, platformClientId, platformCancelUrl, platformBookingUrl, platformRescheduleUrl, platformBookingLocation, } = req; const eventType = await monitorCallbackAsync(getEventType, { eventTypeId: req.body.eventTypeId, eventTypeSlug: req.body.eventTypeSlug, }); const bookingDataSchema = bookingDataSchemaGetter({ view: req.body?.rescheduleUid ? "reschedule" : "booking", bookingFields: eventType.bookingFields, }); const bookingData = await getBookingData({ req, eventType, schema: bookingDataSchema, }); const { recurringCount, noEmail, eventTypeId, eventTypeSlug, hasHashedBookingLink, language, appsStatus: reqAppsStatus, name: bookerName, attendeePhoneNumber: bookerPhoneNumber, email: bookerEmail, guests: reqGuests, location, notes: additionalNotes, smsReminderNumber, rescheduleReason, luckyUsers, routedTeamMemberIds, reroutingFormResponses, routingFormResponseId, _isDryRun: isDryRun = false, _shouldServeCache, ...reqBody } = bookingData; let troubleshooterData = buildTroubleshooterData({ eventType, }); const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { logger.settings.minLevel = 0; } const fullName = getFullName(bookerName); // Why are we only using "en" locale const tGuests = await getTranslation("en", "common"); const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user); if (!eventType) throw new HttpError({ statusCode: 404, message: "event_type_not_found" }); const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id); const isTeamEventType = !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType); const paymentAppData = getPaymentAppData({ ...eventType, metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata), }); loggerWithEventDetails.info( `Booking eventType ${eventTypeId} started`, safeStringify({ reqBody: { user: reqBody.user, eventTypeId, eventTypeSlug, startTime: reqBody.start, endTime: reqBody.end, rescheduleUid: reqBody.rescheduleUid, location: location, timeZone: reqBody.timeZone, }, isTeamEventType, eventType: getPiiFreeEventType(eventType), dynamicUserList, paymentAppData: { enabled: paymentAppData.enabled, price: paymentAppData.price, paymentOption: paymentAppData.paymentOption, currency: paymentAppData.currency, appId: paymentAppData.appId, }, }) ); const user = eventType.users.find((user) => user.id === eventType.userId); const userSchedule = user?.schedules.find((schedule) => schedule.id === user?.defaultScheduleId); const eventTimeZone = eventType.schedule?.timeZone ?? userSchedule?.timeZone; await validateBookingTimeIsNotOutOfBounds( reqBody.start, reqBody.timeZone, eventType, eventTimeZone, loggerWithEventDetails ); validateEventLength({ reqBodyStart: reqBody.start, reqBodyEnd: reqBody.end, eventTypeMultipleDuration: eventType.metadata?.multipleDuration, eventTypeLength: eventType.length, logger: loggerWithEventDetails, }); const contactOwnerFromReq = reqBody.teamMemberEmail ?? null; const isReroutingCase = isRerouting({ rescheduleUid: reqBody.rescheduleUid ?? null, routedTeamMemberIds: routedTeamMemberIds ?? null, }); const skipContactOwner = shouldIgnoreContactOwner({ skipContactOwner: reqBody.skipContactOwner ?? null, rescheduleUid: reqBody.rescheduleUid ?? null, routedTeamMemberIds: routedTeamMemberIds ?? null, }); const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq; let routingFormResponse = null; if (routedTeamMemberIds) { routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({ where: { id: routingFormResponseId, }, select: { response: true, form: { select: { routes: true, fields: true, }, }, chosenRouteId: true, }, }); } const { qualifiedRRUsers, additionalFallbackRRUsers, fixedUsers } = await monitorCallbackAsync( loadAndValidateUsers, { req, eventType, eventTypeId, dynamicUserList, logger: loggerWithEventDetails, routedTeamMemberIds: routedTeamMemberIds ?? null, contactOwnerEmail, rescheduleUid: reqBody.rescheduleUid || null, routingFormResponse, } ); // We filter out users but ensure allHostUsers remain same. let users = [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers]; let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb( dynamicUserList, users, location ); await monitorCallbackAsync(checkBookingAndDurationLimits, { eventType, reqBodyStart: reqBody.start, reqBodyRescheduleUid: reqBody.rescheduleUid, }); const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null; const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid; let originalRescheduledBooking = rescheduleUid ? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot) : null; let luckyUserResponse; let isFirstSeat = true; if (eventType.seatsPerTimeSlot) { const booking = await prisma.booking.findFirst({ where: { eventTypeId: eventType.id, startTime: new Date(dayjs(reqBody.start).utc().format()), status: BookingStatus.ACCEPTED, }, }); if (booking) isFirstSeat = false; } //checks what users are available if (isFirstSeat) { const eventTypeWithUsers: getEventTypeResponse & { users: IsFixedAwareUser[]; } = { ...eventType, users: users as IsFixedAwareUser[], ...(eventType.recurringEvent && { recurringEvent: { ...eventType.recurringEvent, count: recurringCount || eventType.recurringEvent.count, }, }), }; if (req.body.allRecurringDates && req.body.isFirstRecurringSlot) { const isTeamEvent = eventType.schedulingType === SchedulingType.COLLECTIVE || eventType.schedulingType === SchedulingType.ROUND_ROBIN; const fixedUsers = isTeamEvent ? eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed) : []; for ( let i = 0; i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; i++ ) { const start = req.body.allRecurringDates[i].start; const end = req.body.allRecurringDates[i].end; if (isTeamEvent) { // each fixed user must be available for (const key in fixedUsers) { await ensureAvailableUsers( { ...eventTypeWithUsers, users: [fixedUsers[key]] }, { dateFrom: dayjs(start).tz(reqBody.timeZone).format(), dateTo: dayjs(end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, shouldServeCache ); } } else { await ensureAvailableUsers( eventTypeWithUsers, { dateFrom: dayjs(start).tz(reqBody.timeZone).format(), dateTo: dayjs(end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, shouldServeCache ); } } } if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) { let availableUsers: IsFixedAwareUser[] = []; try { availableUsers = await ensureAvailableUsers( { ...eventTypeWithUsers, users: [...qualifiedRRUsers, ...fixedUsers] as IsFixedAwareUser[] }, { dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(), dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, shouldServeCache ); } catch { if (additionalFallbackRRUsers.length) { loggerWithEventDetails.debug( "Qualified users not available, check for fallback users", safeStringify({ qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id), additionalFallbackRRUsers: additionalFallbackRRUsers.map((user) => user.id), }) ); // can happen when contact owner not available for 2 weeks or fairness would block at least 2 weeks // use fallback instead availableUsers = await ensureAvailableUsers( { ...eventTypeWithUsers, users: [...additionalFallbackRRUsers, ...fixedUsers] as IsFixedAwareUser[], }, { dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(), dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, shouldServeCache ); } else { loggerWithEventDetails.debug( "Qualified users not available, no fallback users", safeStringify({ qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id), }) ); throw new Error(ErrorCode.NoAvailableUsersFound); } } const luckyUserPool: IsFixedAwareUser[] = []; const fixedUserPool: IsFixedAwareUser[] = []; availableUsers.forEach((user) => { user.isFixed ? fixedUserPool.push(user) : luckyUserPool.push(user); }); const notAvailableLuckyUsers: typeof users = []; loggerWithEventDetails.debug( "Computed available users", safeStringify({ availableUsers: availableUsers.map((user) => user.id), luckyUserPool: luckyUserPool.map((user) => user.id), }) ); const luckyUsers: typeof users = []; // loop through all non-fixed hosts and get the lucky users // This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1 while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { const freeUsers = luckyUserPool.filter( (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) ); // no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :( if (freeUsers.length === 0) break; assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown. // freeUsers is ensured const userIdsSet = new Set(users.map((user) => user.id)); const newLuckyUser = await getLuckyUser({ // find a lucky user that is not already in the luckyUsers array availableUsers: freeUsers, allRRHosts: eventTypeWithUsers.hosts.filter( (host) => !host.isFixed && userIdsSet.has(host.user.id) ), // users part of virtual queue eventType, routingFormResponse, }); if (!newLuckyUser) { break; // prevent infinite loop } if (req.body.isFirstRecurringSlot && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { // for recurring round robin events check if lucky user is available for next slots try { for ( let i = 0; i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; i++ ) { const start = req.body.allRecurringDates[i].start; const end = req.body.allRecurringDates[i].end; await ensureAvailableUsers( { ...eventTypeWithUsers, users: [newLuckyUser] }, { dateFrom: dayjs(start).tz(reqBody.timeZone).format(), dateTo: dayjs(end).tz(reqBody.timeZone).format(), timeZone: reqBody.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, shouldServeCache ); } // if no error, then lucky user is available for the next slots luckyUsers.push(newLuckyUser); } catch { notAvailableLuckyUsers.push(newLuckyUser); loggerWithEventDetails.info( `Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.` ); } } else { luckyUsers.push(newLuckyUser); } } // ALL fixed users must be available if (fixedUserPool.length !== users.filter((user) => user.isFixed).length) { throw new Error(ErrorCode.HostsUnavailableForBooking); } // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...fixedUserPool, ...luckyUsers]; luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) }; troubleshooterData = { ...troubleshooterData, luckyUsers: luckyUsers.map((u) => u.id), fixedUsers: fixedUserPool.map((u) => u.id), luckyUserPool: luckyUserPool.map((u) => u.id), }; } else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { // all recurring slots except the first one const luckyUsersFromFirstBooking = luckyUsers ? eventTypeWithUsers.users.filter((user) => luckyUsers.find((luckyUserId) => luckyUserId === user.id)) : []; const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed); users = [...fixedHosts, ...luckyUsersFromFirstBooking]; troubleshooterData = { ...troubleshooterData, luckyUsersFromFirstBooking: luckyUsersFromFirstBooking.map((u) => u.id), fixedUsers: fixedHosts.map((u) => u.id), }; } } if (users.length === 0 && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { loggerWithEventDetails.error(`No available users found for round robin event.`); throw new Error(ErrorCode.NoAvailableUsersFound); } // If the team member is requested then they should be the organizer const organizerUser = reqBody.teamMemberEmail ? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0] : users[0]; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); const allCredentials = await getAllCredentials(organizerUser, eventType); const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({ eventType, bookingStartTime: reqBody.start, userId, originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id, paymentAppData, bookerEmail, }); // If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale. const attendeeInfoOnReschedule = userReschedulingIsOwner && originalRescheduledBooking ? originalRescheduledBooking.attendees.find((attendee) => attendee.email === bookerEmail) : null; const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : language; const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); const isManagedEventType = !!eventType.parentId; // If location passed is empty , use default location of event // If location of event is not set , use host default if (locationBodyString.trim().length == 0) { if (eventType.locations.length > 0) { locationBodyString = eventType.locations[0].type; } else { locationBodyString = OrganizerDefaultConferencingAppType; } } // use host default if (locationBodyString == OrganizerDefaultConferencingAppType) { const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata); const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined; if (organizerMetadata?.defaultConferencingApp?.appSlug) { const app = getAppFromSlug(organizerMetadata?.defaultConferencingApp?.appSlug); locationBodyString = app?.appData?.location?.type || locationBodyString; if (isManagedEventType || isTeamEventType) { organizerOrFirstDynamicGroupMemberDefaultLocationUrl = organizerMetadata?.defaultConferencingApp?.appLink; } } else { locationBodyString = "integrations:daily"; } } const invitee: Invitee = [ { email: bookerEmail, name: fullName, phoneNumber: bookerPhoneNumber, firstName: (typeof bookerName === "object" && bookerName.firstName) || "", lastName: (typeof bookerName === "object" && bookerName.lastName) || "", timeZone: attendeeTimezone, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" }, }, ]; const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS ? process.env.BLACKLISTED_GUEST_EMAILS.split(",") : []; const guestsRemoved: string[] = []; const guests = (reqGuests || []).reduce((guestArray, guest) => { const baseGuestEmail = extractBaseEmail(guest).toLowerCase(); if (blacklistedGuestEmails.some((e) => e.toLowerCase() === baseGuestEmail)) { guestsRemoved.push(guest); return guestArray; } // If it's a team event, remove the team member from guests if (isTeamEventType && users.some((user) => user.email === guest)) { return guestArray; } guestArray.push({ email: guest, name: "", firstName: "", lastName: "", timeZone: attendeeTimezone, language: { translate: tGuests, locale: "en" }, }); return guestArray; }, [] as Invitee); if (guestsRemoved.length > 0) { log.info("Removed guests from the booking", guestsRemoved); } const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); // For static link based video apps, it would have the static URL value instead of it's type(e.g. integrations:campfire_video) // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, conferenceCredentialId: undefined, } : getLocationValueForDB(locationBodyString, eventType.locations); const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); const teamDestinationCalendars: DestinationCalendar[] = []; // Organizer or user owner of this event type it's not listed as a team member. const teamMemberPromises = users .filter((user) => user.email !== organizerUser.email) .map(async (user) => { // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 // push to teamDestinationCalendars if it's a team event but collective only if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) { teamDestinationCalendars.push({ ...user.destinationCalendar, externalId: processExternalId(user.destinationCalendar), }); } return { id: user.id, email: user.email ?? "", name: user.name ?? "", firstName: "", lastName: "", timeZone: user.timeZone, language: { translate: await getTranslation(user.locale ?? "en", "common"), locale: user.locale ?? "en", }, }; }); const teamMembers = await Promise.all(teamMemberPromises); const attendeesList = [...invitee, ...guests]; const responses = reqBody.responses || null; const evtName = !eventType?.isDynamic ? eventType.eventName : responses?.title; const eventNameObject = { //TODO: Can we have an unnamed attendee? If not, I would really like to throw an error here. attendeeName: fullName || "Nameless", eventType: eventType.title, eventName: evtName, // we send on behalf of team if >1 round robin attendee | collective teamName: eventType.schedulingType === "COLLECTIVE" || users.length > 1 ? eventType.team?.name : null, // TODO: Can we have an unnamed organizer? If not, I would really like to throw an error here. host: organizerUser.name || "Nameless", location: bookingLocation, eventDuration: eventType.length, bookingFields: { ...responses }, t: tOrganizer, }; const iCalUID = getICalUID({ event: { iCalUID: originalRescheduledBooking?.iCalUID, uid: originalRescheduledBooking?.uid }, uid, }); // For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0. const iCalSequence = getICalSequence(originalRescheduledBooking); const organizerOrganizationProfile = await prisma.profile.findFirst({ where: { userId: organizerUser.id, username: dynamicUserList[0], }, }); const organizerOrganizationId = organizerOrganizationProfile?.organizationId; const bookerUrl = eventType.team ? await getBookerBaseUrl(eventType.team.parentId) : await getBookerBaseUrl(organizerOrganizationId ?? null); const destinationCalendar = eventType.destinationCalendar ? [eventType.destinationCalendar] : organizerUser.destinationCalendar ? [organizerUser.destinationCalendar] : null; let organizerEmail = organizerUser.email || "Email-less"; if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { organizerEmail = destinationCalendar[0].primaryEmail; } else if (eventType.secondaryEmailId && eventType.secondaryEmail?.email) { organizerEmail = eventType.secondaryEmail.email; } //update cal event responses with latest location value , later used by webhook if (reqBody.calEventResponses) reqBody.calEventResponses["location"].value = { value: platformBookingLocation ?? bookingLocation, optionValue: "", }; const eventName = getEventName(eventNameObject); let evt: CalendarEvent = { bookerUrl, type: eventType.slug, title: eventName, //this needs to be either forced in english, or fetched for each attendee and organizer separately description: eventType.description, additionalNotes, customInputs, startTime: dayjs(reqBody.start).utc().format(), endTime: dayjs(reqBody.end).utc().format(), organizer: { id: organizerUser.id, name: organizerUser.name || "Nameless", email: organizerEmail, username: organizerUser.username || undefined, timeZone: organizerUser.timeZone, language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat), }, responses: reqBody.calEventResponses || null, userFieldsResponses: reqBody.calEventUserFieldsResponses || null, attendees: attendeesList, location: platformBookingLocation ?? bookingLocation, // Will be processed by the EventManager later. conferenceCredentialId, destinationCalendar, hideCalendarNotes: eventType.hideCalendarNotes, hideCalendarEventDetails: eventType.hideCalendarEventDetails, requiresConfirmation: !isConfirmedByDefault, eventTypeId: eventType.id, // if seats are not enabled we should default true seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true, seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true, schedulingType: eventType.schedulingType, iCalUID, iCalSequence, platformClientId, platformRescheduleUrl, platformCancelUrl, platformBookingUrl, oneTimePassword: isConfirmedByDefault ? null : undefined, }; if (req.body.thirdPartyRecurringEventId) { evt.existingRecurringEvent = { recurringEventId: req.body.thirdPartyRecurringEventId, }; } if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") { evt.destinationCalendar?.push(...teamDestinationCalendars); } // data needed for triggering webhooks const eventTypeInfo: EventTypeInfo = { eventTitle: eventType.title, eventDescription: eventType.description, price: paymentAppData.price, currency: eventType.currency, length: dayjs(reqBody.end).diff(dayjs(reqBody.start), "minutes"), }; const teamId = await getTeamIdFromEventType({ eventType }); const triggerForUser = !teamId || (teamId && eventType.parentId); const organizerUserId = triggerForUser ? organizerUser.id : null; const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId }); const subscriberOptions: GetSubscriberOptions = { userId: organizerUserId, eventTypeId, triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, teamId, orgId, oAuthClientId: platformClientId, }; const eventTrigger: WebhookTriggerEvents = rescheduleUid ? WebhookTriggerEvents.BOOKING_RESCHEDULED : WebhookTriggerEvents.BOOKING_CREATED; subscriberOptions.triggerEvent = eventTrigger; const subscriberOptionsMeetingEnded = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.MEETING_ENDED, teamId, orgId, oAuthClientId: platformClientId, }; const subscriberOptionsMeetingStarted = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.MEETING_STARTED, teamId, orgId, oAuthClientId: platformClientId, }; const workflows = await getAllWorkflowsFromEventType( { ...eventType, metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata), }, organizerUser.id ); if (isTeamEventType) { evt.team = { members: teamMembers, name: eventType.team?.name || "Nameless", id: eventType.team?.id ?? 0, }; } // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot) { const newBooking = await handleSeats({ rescheduleUid, reqBookingUid: reqBody.bookingUid, eventType, evt: { ...evt, bookerUrl }, invitee, allCredentials, organizerUser, originalRescheduledBooking, bookerEmail, bookerPhoneNumber, tAttendees, bookingSeat, reqUserId: req.userId, rescheduleReason, reqBodyUser: reqBody.user, noEmail, isConfirmedByDefault, additionalNotes, reqAppsStatus, attendeeLanguage, paymentAppData, fullName, smsReminderNumber, eventTypeInfo, uid, eventTypeId, reqBodyMetadata: reqBody.metadata, subscriberOptions, eventTrigger, responses, workflows, rescheduledBy: reqBody.rescheduledBy, isDryRun, }); if (newBooking) { req.statusCode = 201; const bookingResponse = { ...newBooking, user: { ...newBooking.user, email: null, }, paymentRequired: false, isDryRun: isDryRun, ...(isDryRun ? { troubleshooterData } : {}), }; return { ...bookingResponse, ...luckyUserResponse, }; } else { // Rescheduling logic for the original seated event was handled in handleSeats // We want to use new booking logic for the new time slot originalRescheduledBooking = null; evt.iCalUID = getICalUID({ attendeeId: bookingSeat?.attendeeId, }); } } if (reqBody.recurringEventId && eventType.recurringEvent) { // Overriding the recurring event configuration count to be the actual number of events booked for // the recurring event (equal or less than recurring event configuration count) eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount }); evt.recurringEvent = eventType.recurringEvent; } const changedOrganizer = !!originalRescheduledBooking && eventType.schedulingType === SchedulingType.ROUND_ROBIN && originalRescheduledBooking.userId !== evt.organizer.id; let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; let booking: CreatedBooking | null = null; loggerWithEventDetails.debug( "Going to create booking in DB now", safeStringify({ organizerUser: organizerUser.id, attendeesList: attendeesList.map((guest) => ({ timeZone: guest.timeZone })), requiresConfirmation: evt.requiresConfirmation, isConfirmedByDefault, userReschedulingIsOwner, }) ); try { if (!isDryRun) { booking = await monitorCallbackAsync(createBooking, { uid, rescheduledBy: reqBody.rescheduledBy, routingFormResponseId: routingFormResponseId, reroutingFormResponses: reroutingFormResponses ?? null, reqBody: { user: reqBody.user, metadata: reqBody.metadata, recurringEventId: reqBody.recurringEventId, }, eventType: { eventTypeData: eventType, id: eventTypeId, slug: eventTypeSlug, organizerUser, isConfirmedByDefault, paymentAppData, }, input: { bookerEmail, rescheduleReason, changedOrganizer, smsReminderNumber, responses, }, evt, originalRescheduledBooking, creationSource: req.body.creationSource, }); if (booking?.userId) { const usersRepository = new UsersRepository(); await usersRepository.updateLastActiveAt(booking.userId); } // If it's a round robin event, record the reason for the host assignment if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) { await monitorCallbackAsync(AssignmentReasonRecorder.CRMOwnership, { bookingId: booking.id, crmAppSlug: reqBody.crmAppSlug, teamMemberEmail: contactOwnerEmail, recordType: reqBody.crmOwnerRecordType, routingFormResponseId, }); } else if (routingFormResponseId && teamId) { await monitorCallbackAsync(AssignmentReasonRecorder.routingFormRoute, { bookingId: booking.id, routingFormResponseId, organizerId: organizerUser.id, teamId, }); } } evt.uid = booking.uid ?? null; evt.oneTimePassword = booking.oneTimePassword ?? null; if (booking && booking.id && eventType.seatsPerTimeSlot) { const currentAttendee = booking.attendees.find( (attendee) => attendee.email === req.body.responses.email || (req.body.responses.attendeePhoneNumber && attendee.phoneNumber === req.body.responses.attendeePhoneNumber) ); // Save description to bookingSeat const uniqueAttendeeId = uuid(); await prisma.bookingSeat.create({ data: { referenceUid: uniqueAttendeeId, data: { description: additionalNotes, responses, }, metadata: reqBody.metadata, booking: { connect: { id: booking.id, }, }, attendee: { connect: { id: currentAttendee?.id, }, }, }, }); evt.attendeeSeatId = uniqueAttendeeId; } } else { const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({ eventTypeId, organizerUser, eventName, startTime: reqBody.start, endTime: reqBody.end, contactOwnerFromReq, contactOwnerEmail, allHostUsers: [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers], isManagedEventType, }); booking = dryRunBooking; troubleshooterData = { ...troubleshooterData, ..._troubleshooterData, }; } } catch (_err) { const err = getErrorFromUnknown(_err); loggerWithEventDetails.error( `Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message ); if (err.code === "P2002") { throw new HttpError({ statusCode: 409, message: "booking_conflict" }); } throw err; } // After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again. const credentials = await monitorCallbackAsync(refreshCredentials, allCredentials); const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); const eventManager = !isDryRun ? new EventManager({ ...organizerUser, credentials }, apps) : buildDryRunEventManager(); let videoCallUrl; //this is the actual rescheduling logic if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) { log.silly("Rescheduling booking", originalRescheduledBooking.uid); // cancel workflow reminders from previous rescheduled booking await WorkflowRepository.deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders); evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); // If organizer is changed in RR event then we need to delete the previous host destination calendar events const previousHostDestinationCalendar = originalRescheduledBooking?.destinationCalendar ? [originalRescheduledBooking?.destinationCalendar] : []; if (changedOrganizer) { evt.title = getEventName(eventNameObject); // location might changed and will be new created in eventManager.create (organizer default location) evt.videoCallData = undefined; // To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID evt.iCalUID = undefined; } else { // In case of rescheduling, we need to keep the previous host destination calendar evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar ? [originalRescheduledBooking?.destinationCalendar] : evt.destinationCalendar; } const updateManager = await eventManager.reschedule( evt, originalRescheduledBooking.uid, undefined, changedOrganizer, previousHostDestinationCalendar ); // 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; results = updateManager.results; referencesToCreate = updateManager.referencesToCreate; videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null; // This gets overridden when creating 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 { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ results, }); let metadata: AdditionalInformation = {}; metadata = videoMetadata; videoCallUrl = _videoCallUrl; const isThereAnIntegrationError = results && results.some((res) => !res.success); if (isThereAnIntegrationError) { const error = { errorCode: "BookingReschedulingMeetingFailed", message: "Booking Rescheduling failed", }; loggerWithEventDetails.error( `EventManager.reschedule failure in some of the integrations ${organizerUser.username}`, safeStringify({ error, results }) ); } else { if (results.length) { // Handle Google Meet results // We use the original booking location since the evt location changes to daily if (bookingLocation === MeetLocationType) { const googleMeetResult = { appName: GoogleMeetMetadata.name, type: "conferencing", uid: results[0].uid, originalEvent: results[0].originalEvent, }; // Find index of google_calendar inside createManager.referencesToCreate const googleCalIndex = updateManager.referencesToCreate.findIndex( (ref) => ref.type === "google_calendar" ); const googleCalResult = results[googleCalIndex]; if (!googleCalResult) { loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location"); results.push({ ...googleMeetResult, success: false, calWarnings: [tOrganizer("google_meet_warning")], }); } const googleHangoutLink = Array.isArray(googleCalResult?.updatedEvent) ? googleCalResult.updatedEvent[0]?.hangoutLink : googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink; if (googleHangoutLink) { results.push({ ...googleMeetResult, success: true, }); // Add google_meet to referencesToCreate in the same index as google_calendar updateManager.referencesToCreate[googleCalIndex] = { ...updateManager.referencesToCreate[googleCalIndex], meetingUrl: googleHangoutLink, }; // Also create a new referenceToCreate with type video for google_meet updateManager.referencesToCreate.push({ type: "google_meet_video", meetingUrl: googleHangoutLink, uid: googleCalResult.uid, credentialId: updateManager.referencesToCreate[googleCalIndex].credentialId, }); } else if (googleCalResult && !googleHangoutLink) { results.push({ ...googleMeetResult, success: false, }); } } const createdOrUpdatedEvent = Array.isArray(results[0]?.updatedEvent) ? results[0]?.updatedEvent[0] : results[0]?.updatedEvent ?? results[0]?.createdEvent; metadata.hangoutLink = createdOrUpdatedEvent?.hangoutLink; metadata.conferenceData = createdOrUpdatedEvent?.conferenceData; metadata.entryPoints = createdOrUpdatedEvent?.entryPoints; evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = metadata.hangoutLink || createdOrUpdatedEvent?.url || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || getVideoCallUrlFromCalEvent(evt) || videoCallUrl; } const calendarResult = results.find((result) => result.type.includes("_calendar")); evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) ? calendarResult?.updatedEvent[0]?.iCalUID : calendarResult?.updatedEvent?.iCalUID || undefined; } evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); if (noEmail !== true && isConfirmedByDefault) { const copyEvent = cloneDeep(evt); const copyEventAdditionalInfo = { ...copyEvent, additionalInformation: metadata, 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 }; loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); /* handle emails for round robin - if booked rr host is the same, then rescheduling email - if new rr host is booked, then cancellation email to old host and confirmation email to new host */ if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { const originalBookingMemberEmails: Person[] = []; for (const user of originalRescheduledBooking.attendees) { const translate = await getTranslation(user.locale ?? "en", "common"); originalBookingMemberEmails.push({ name: user.name, email: user.email, timeZone: user.timeZone, phoneNumber: user.phoneNumber, language: { translate, locale: user.locale ?? "en" }, }); } if (originalRescheduledBooking.user) { const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); originalBookingMemberEmails.push({ ...originalRescheduledBooking.user, name: originalRescheduledBooking.user.name || "", language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, }); } const newBookingMemberEmails: Person[] = copyEvent.team?.members .map((member) => member) .concat(copyEvent.organizer) .concat(copyEvent.attendees) || []; const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => { return originalMember.email === newMember.email; }; // scheduled Emails const newBookedMembers = newBookingMemberEmails.filter( (member) => !originalBookingMemberEmails.find((originalMember) => matchOriginalMemberWithNewMember(originalMember, member) ) ); // cancelled Emails const cancelledMembers = originalBookingMemberEmails.filter( (member) => !newBookingMemberEmails.find((newMember) => matchOriginalMemberWithNewMember(member, newMember)) ); // rescheduled Emails const rescheduledMembers = newBookingMemberEmails.filter((member) => originalBookingMemberEmails.find((orignalMember) => matchOriginalMemberWithNewMember(orignalMember, member) ) ); if (!isDryRun) { sendRoundRobinRescheduledEmailsAndSMS( copyEventAdditionalInfo, rescheduledMembers, eventType.metadata ); sendRoundRobinScheduledEmailsAndSMS({ calEvent: copyEventAdditionalInfo, members: newBookedMembers, eventTypeMetadata: eventType.metadata, }); sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); } } else { if (!isDryRun) { // send normal rescheduled emails (non round robin event, where organizers stay the same) await sendRescheduledEmailsAndSMS( { ...copyEvent, additionalInformation: metadata, 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 }, eventType?.metadata ); } } } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking } else if (isConfirmedByDefault) { // Use EventManager to conditionally use all needed integrations. const createManager = await eventManager.create(evt); if (evt.location) { booking.location = evt.location; } // This gets overridden when creating 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; results = createManager.results; referencesToCreate = createManager.referencesToCreate; videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null; if (results.length > 0 && results.every((res) => !res.success)) { const error = { errorCode: "BookingCreatingMeetingFailed", message: "Booking failed", }; loggerWithEventDetails.error( `EventManager.create failure in some of the integrations ${organizerUser.username}`, safeStringify({ error, results }) ); } else { const additionalInformation: AdditionalInformation = {}; if (results.length) { // Handle Google Meet results // We use the original booking location since the evt location changes to daily if (bookingLocation === MeetLocationType) { const googleMeetResult = { appName: GoogleMeetMetadata.name, type: "conferencing", uid: results[0].uid, originalEvent: results[0].originalEvent, }; // Find index of google_calendar inside createManager.referencesToCreate const googleCalIndex = createManager.referencesToCreate.findIndex( (ref) => ref.type === "google_calendar" ); const googleCalResult = results[googleCalIndex]; if (!googleCalResult) { loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location"); results.push({ ...googleMeetResult, success: false, calWarnings: [tOrganizer("google_meet_warning")], }); } if (googleCalResult?.createdEvent?.hangoutLink) { results.push({ ...googleMeetResult, success: true, }); // Add google_meet to referencesToCreate in the same index as google_calendar createManager.referencesToCreate[googleCalIndex] = { ...createManager.referencesToCreate[googleCalIndex], meetingUrl: googleCalResult.createdEvent.hangoutLink, }; // Also create a new referenceToCreate with type video for google_meet createManager.referencesToCreate.push({ type: "google_meet_video", meetingUrl: googleCalResult.createdEvent.hangoutLink, uid: googleCalResult.uid, credentialId: createManager.referencesToCreate[googleCalIndex].credentialId, }); } else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) { results.push({ ...googleMeetResult, success: false, }); } } // TODO: Handle created event metadata more elegantly additionalInformation.hangoutLink = results[0].createdEvent?.hangoutLink; additionalInformation.conferenceData = results[0].createdEvent?.conferenceData; additionalInformation.entryPoints = results[0].createdEvent?.entryPoints; evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); videoCallUrl = additionalInformation.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; if (!isDryRun && evt.iCalUID !== booking.iCalUID) { // The eventManager could change the iCalUID. At this point we can update the DB record await prisma.booking.update({ where: { id: booking.id, }, data: { iCalUID: evt.iCalUID || booking.iCalUID, }, }); } } if (noEmail !== true) { let isHostConfirmationEmailsDisabled = false; let isAttendeeConfirmationEmailDisabled = false; isHostConfirmationEmailsDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.host || false; isAttendeeConfirmationEmailDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; if (isHostConfirmationEmailsDisabled) { isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); } if (isAttendeeConfirmationEmailDisabled) { isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); } loggerWithEventDetails.debug( "Emails: Sending scheduled emails for booking confirmation", safeStringify({ calEvent: getPiiFreeCalendarEvent(evt), }) ); if (!isDryRun) { await monitorCallbackAsync( sendScheduledEmailsAndSMS, { ...evt, additionalInformation, additionalNotes, customInputs, }, eventNameObject, isHostConfirmationEmailsDisabled, isAttendeeConfirmationEmailDisabled, eventType.metadata ); } } } } else { // If isConfirmedByDefault is false, then booking can't be considered ACCEPTED and thus EventManager has no role to play. Booking is created as PENDING loggerWithEventDetails.debug( `EventManager doesn't need to create or reschedule event for booking ${organizerUser.username}`, safeStringify({ calEvent: getPiiFreeCalendarEvent(evt), isConfirmedByDefault, paymentValue: paymentAppData.price, }) ); } const bookingRequiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !originalRescheduledBooking?.paid && !!booking; if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) { loggerWithEventDetails.debug( `Emails: Booking ${organizerUser.username} requires confirmation, sending request emails`, safeStringify({ calEvent: getPiiFreeCalendarEvent(evt), }) ); if (!isDryRun) { await monitorCallbackAsync(sendOrganizerRequestEmail, { ...evt, additionalNotes }, eventType.metadata); await monitorCallbackAsync( sendAttendeeRequestEmailAndSMS, { ...evt, additionalNotes }, attendeesList[0], eventType.metadata ); } } if (booking.location?.startsWith("http")) { videoCallUrl = booking.location; } const metadata = videoCallUrl ? { videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, } : undefined; const webhookData: EventPayloadType = { ...evt, ...eventTypeInfo, bookingId: booking?.id, rescheduleId: originalRescheduledBooking?.id || undefined, rescheduleUid, rescheduleStartTime: originalRescheduledBooking?.startTime ? dayjs(originalRescheduledBooking?.startTime).utc().format() : undefined, rescheduleEndTime: originalRescheduledBooking?.endTime ? dayjs(originalRescheduledBooking?.endTime).utc().format() : undefined, metadata: { ...metadata, ...reqBody.metadata }, eventTypeId, status: "ACCEPTED", smsReminderNumber: booking?.smsReminderNumber || undefined, rescheduledBy: reqBody.rescheduledBy, }; if (bookingRequiresPayment) { loggerWithEventDetails.debug(`Booking ${organizerUser.username} requires payment`); // Load credentials.app.categories 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" }); } // Convert type of eventTypePaymentAppCredential to appId: EventTypeAppList if (!booking.user) booking.user = organizerUser; const payment = await handlePayment({ evt, selectedEventType: eventType, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, booking, bookerName: fullName, bookerEmail, bookerPhoneNumber, isDryRun, }); const subscriberOptionsPaymentInitiated: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, teamId, orgId, oAuthClientId: platformClientId, }; await handleWebhookTrigger({ subscriberOptions: subscriberOptionsPaymentInitiated, eventTrigger: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, webhookData: { ...webhookData, paymentId: payment?.id, }, isDryRun, }); req.statusCode = 201; // TODO: Refactor better so this booking object is not passed // all around and instead the individual fields are sent as args. const bookingResponse = { ...booking, user: { ...booking.user, email: null, }, }; return { ...bookingResponse, ...luckyUserResponse, message: "Payment required", paymentRequired: true, paymentUid: payment?.uid, paymentId: payment?.id, isDryRun, ...(isDryRun ? { troubleshooterData } : {}), }; } loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`); // We are here so, booking doesn't require payment and booking is also created in DB already, through createBooking call if (isConfirmedByDefault) { const subscribersMeetingEnded = await monitorCallbackAsync(getWebhooks, subscriberOptionsMeetingEnded); const subscribersMeetingStarted = await monitorCallbackAsync( getWebhooks, subscriberOptionsMeetingStarted ); let deleteWebhookScheduledTriggerPromise: Promise = Promise.resolve(); const scheduleTriggerPromises = []; if (rescheduleUid && originalRescheduledBooking) { //delete all scheduled triggers for meeting ended and meeting started of booking deleteWebhookScheduledTriggerPromise = deleteWebhookScheduledTriggers({ booking: originalRescheduledBooking, isDryRun, }); } if (booking && booking.status === BookingStatus.ACCEPTED) { const bookingWithCalEventResponses = { ...booking, responses: reqBody.calEventResponses, }; for (const subscriber of subscribersMeetingEnded) { scheduleTriggerPromises.push( scheduleTrigger({ booking: bookingWithCalEventResponses, subscriberUrl: subscriber.subscriberUrl, subscriber, triggerEvent: WebhookTriggerEvents.MEETING_ENDED, isDryRun, }) ); } for (const subscriber of subscribersMeetingStarted) { scheduleTriggerPromises.push( scheduleTrigger({ booking: bookingWithCalEventResponses, subscriberUrl: subscriber.subscriberUrl, subscriber, triggerEvent: WebhookTriggerEvents.MEETING_STARTED, isDryRun, }) ); } } await Promise.all([deleteWebhookScheduledTriggerPromise, ...scheduleTriggerPromises]).catch((error) => { loggerWithEventDetails.error( "Error while scheduling or canceling webhook triggers", JSON.stringify({ error }) ); }); // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED await monitorCallbackAsync(handleWebhookTrigger, { subscriberOptions, eventTrigger, webhookData, isDryRun, }); } else { // if eventType requires confirmation we will trigger the BOOKING REQUESTED Webhook const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REQUESTED; subscriberOptions.triggerEvent = eventTrigger; webhookData.status = "PENDING"; await monitorCallbackAsync(handleWebhookTrigger, { subscriberOptions, eventTrigger, webhookData, isDryRun, }); } try { if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) { await prisma.hashedLink.delete({ where: { link: reqBody.hashedLink as string, }, }); } } catch (error) { loggerWithEventDetails.error("Error while updating hashed link", JSON.stringify({ error })); } if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" }); try { if (!isDryRun) { await prisma.booking.update({ where: { uid: booking.uid, }, data: { location: evt.location, metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata }, references: { createMany: { data: referencesToCreate, }, }, }, }); } } catch (error) { loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error })); } const evtWithMetadata = { ...evt, rescheduleReason, metadata, eventType: { slug: eventType.slug, schedulingType: eventType.schedulingType, hosts: eventType.hosts }, bookerUrl, }; if (!eventType.metadata?.disableStandardEmails?.all?.attendee) { await scheduleMandatoryReminder({ evt: evtWithMetadata, workflows, requiresConfirmation: !isConfirmedByDefault, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, isPlatformNoEmail: noEmail && Boolean(platformClientId), isDryRun, }); } try { await monitorCallbackAsync(scheduleWorkflowReminders, { workflows, smsReminderNumber: smsReminderNumber || null, calendarEvent: evtWithMetadata, isNotConfirmed: rescheduleUid ? false : !isConfirmedByDefault, isRescheduleEvent: !!rescheduleUid, isFirstRecurringEvent: req.body.allRecurringDates ? req.body.isFirstRecurringSlot : undefined, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, isDryRun, }); } catch (error) { loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); } try { if (isConfirmedByDefault) { await monitorCallbackAsync(scheduleNoShowTriggers, { booking: { startTime: booking.startTime, id: booking.id, location: booking.location }, triggerForUser, organizerUser: { id: organizerUser.id }, eventTypeId, teamId, orgId, isDryRun, }); } } catch (error) { loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error })); } // booking successful req.statusCode = 201; // TODO: Refactor better so this booking object is not passed // all around and instead the individual fields are sent as args. const bookingResponse = { ...booking, user: { ...booking.user, email: null, }, paymentRequired: false, }; return { ...bookingResponse, ...luckyUserResponse, isDryRun, ...(isDryRun ? { troubleshooterData } : {}), references: referencesToCreate, seatReferenceUid: evt.attendeeSeatId, }; } export default handler;