Files
cal.diy/packages/features/bookings/lib/service/RegularBookingService.ts
T
f2a44279fe refactor: evt calendar event builder (#27203)
* refactor: evt creation

* refactor: evt creation

* refactor: evt creation

* fix: type

* test: update unit tests

* chore: add comments

* fix: handle undefined in withConditional to match NonNullable<T> type

Co-Authored-By: unknown <>

* refactor: evt

* fix: type err

* refactor: remove add videoCallData

* refactor: use error with code

* test: update unit test

* refactor: feedback

* refactor: remove conditional

* refactor: more strict types

* fix: type err

* chor: remove un used

* refactor:  improve recurring event handling in RegularBookingService

Removed unnecessary whitespace and clarified comments regarding the attachment of recurring configurations in the booking handler. Adjusted logic to ensure that recurring settings are only applied when relevant.

* refactor: test

* fix: type error

* refactor: improve code

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-03-19 03:50:48 +09:00

3040 lines
105 KiB
TypeScript

import process from "node:process";
import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import {
enrichHostsWithDelegationCredentials,
getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/app-store/delegationCredential";
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 {
eventTypeAppMetadataOptionalSchema,
eventTypeMetaDataSchemaWithTypedApps,
} from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import getICalUID from "@calcom/emails/lib/getICalUID";
import { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeUnAuthenticated";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import type {
BookingDataSchemaGetter,
BookingHandlerInput,
CreateBookingMeta,
CreateRegularBookingData,
} from "@calcom/features/bookings/lib/dto/types";
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory";
import type { CheckBookingAndDurationLimitsService } from "@calcom/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits";
import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d";
import type { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
import type { BuiltCalendarEvent } from "@calcom/features/CalendarEventBuilder";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import {
type EventTypeBrandingData,
getEventTypeService,
} from "@calcom/features/eventtypes/di/EventTypeService.container";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getFullName } from "@calcom/features/form-builder/utils";
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
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 type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
import {
cancelNoShowTasksForBooking,
deleteWebhookScheduledTriggers,
scheduleTrigger,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { DEFAULT_GROUP_ID, ENABLE_ASYNC_TASKER } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import { criticalLogger } from "@calcom/lib/logger.server";
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
import { getTranslation } from "@calcom/i18n/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import type { PrismaClient } from "@calcom/prisma";
import type { AssignmentReasonEnum, DestinationCalendar, Prisma, User } from "@calcom/prisma/client";
import {
BookingStatus,
CreationSource,
SchedulingType,
WebhookTriggerEvents,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type {
AdditionalInformation,
AppsStatus,
CalEventResponses,
CalendarEvent,
} from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import short, { uuid } from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import type { BookingRepository } from "../../repositories/BookingRepository";
import { BookingActionMap, type BookingActionType, BookingEmailSmsHandler } from "../BookingEmailSmsHandler";
import { getAllCredentialsIncludeServiceAccountKey } from "../getAllCredentialsForUsersOnEvent/getAllCredentials";
import { refreshCredentials } from "../getAllCredentialsForUsersOnEvent/refreshCredentials";
import getBookingDataSchema from "../getBookingDataSchema";
import type { LuckyUserService } from "../getLuckyUser";
import {
buildBookingCreatedAuditData,
buildBookingRescheduledAuditData,
} from "../handleNewBooking/buildBookingEventAuditData";
import { checkActiveBookingsLimitForBooker } from "../handleNewBooking/checkActiveBookingsLimitForBooker";
import { checkIfBookerEmailIsBlocked } from "../handleNewBooking/checkIfBookerEmailIsBlocked";
import type { Booking } from "../handleNewBooking/createBooking";
import { createBooking } from "../handleNewBooking/createBooking";
import { ensureAvailableUsers } from "../handleNewBooking/ensureAvailableUsers";
import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource";
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
import { getBookingData } from "../handleNewBooking/getBookingData";
import { getCustomInputsResponses } from "../handleNewBooking/getCustomInputsResponses";
import { getEventType } from "../handleNewBooking/getEventType";
import type { getEventTypeResponse } from "../handleNewBooking/getEventTypesFromDB";
import { getLocationValuesForDb } from "../handleNewBooking/getLocationValuesForDb";
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 type { BookingType } from "../handleNewBooking/originalRescheduledBookingUtils";
import { getOriginalRescheduledBooking } from "../handleNewBooking/originalRescheduledBookingUtils";
import { scheduleNoShowTriggers } from "../handleNewBooking/scheduleNoShowTriggers";
import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from "../handleNewBooking/types";
import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validateBookingTimeIsNotOutOfBounds";
import { validateEventLength } from "../handleNewBooking/validateEventLength";
import handleSeats from "../handleSeats/handleSeats";
import type { IBookingService } from "../interfaces/IBookingService";
import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice";
const translator = short();
type IsFixedAwareUserWithCredentials = Omit<IsFixedAwareUser, "credentials"> & {
credentials: CredentialForCalendarService[];
};
function assertNonEmptyArray<T>(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;
}
type CreatedBooking = Booking & {
isShortCircuitedBooking?: boolean;
} & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
type ReturnTypeCreateBooking = Awaited<ReturnType<typeof createBooking>>;
export const buildDryRunBooking = ({
eventTypeId,
organizerUser,
eventName,
startTime,
endTime,
contactOwnerFromReq,
contactOwnerEmail,
allHostUsers,
isManagedEventType,
}: {
eventTypeId: number;
organizerUser: {
id: number;
uuid: string;
name: string | null;
username: string | null;
email: string;
timeZone: string;
isPlatformManaged?: boolean;
};
eventName: string;
startTime: string;
endTime: string;
contactOwnerFromReq: string | null;
contactOwnerEmail: string | null;
allHostUsers: { id: number }[];
isManagedEventType: boolean;
}) => {
const sanitizedOrganizerUser = {
id: organizerUser.id,
uuid: organizerUser.uuid,
name: organizerUser.name,
username: organizerUser.username,
email: organizerUser.email,
timeZone: organizerUser.timeZone,
isPlatformManaged: organizerUser.isPlatformManaged ?? false,
};
const booking = {
id: -101,
uid: "DRY_RUN_UID",
iCalUID: "DRY_RUN_ICAL_UID",
status: BookingStatus.ACCEPTED,
eventTypeId: eventTypeId,
user: sanitizedOrganizerUser,
userId: sanitizedOrganizerUser.id,
userUuid: sanitizedOrganizerUser.uuid,
title: eventName,
startTime: new Date(startTime),
endTime: new Date(endTime),
createdAt: new Date(),
updatedAt: new Date(),
attendees: [],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
idempotencyKey: null,
userPrimaryEmail: null,
description: null,
customInputs: null,
responses: null,
location: null,
paid: false,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
fromReschedule: null,
recurringEventId: null,
scheduledJobs: [],
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
ratingFeedback: null,
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
references: [],
payment: [],
} satisfies ReturnTypeCreateBooking;
/**
* 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: [] }),
};
};
type TeamMember = {
id: number;
email: string;
name: string;
firstName: string;
lastName: string;
timeZone: string;
language: { translate: Awaited<ReturnType<typeof getTranslation>>; locale: string };
};
export async function computeTeamData({
isTeamEventType,
schedulingType,
users,
organizerEmail,
}: {
isTeamEventType: boolean;
schedulingType: SchedulingType | null;
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
destinationCalendar: DestinationCalendar | null;
isFixed?: boolean;
})[];
organizerEmail: string;
}): Promise<{ teamMembers: TeamMember[]; teamDestinationCalendars: DestinationCalendar[] }> {
if (!isTeamEventType || !schedulingType) {
return { teamMembers: [], teamDestinationCalendars: [] };
}
const teamDestinationCalendars: DestinationCalendar[] = [];
const fixedUsers = users.filter((user) => user.isFixed);
const nonFixedUsers = users.filter((user) => !user.isFixed);
const filteredUsers =
schedulingType === SchedulingType.ROUND_ROBIN ? [...fixedUsers, ...nonFixedUsers] : users;
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = filteredUsers
.filter((user) => user.email !== organizerEmail)
.map(async (user) => {
// push to teamDestinationCalendars if it's a team event but collective only
if (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);
return { teamMembers, teamDestinationCalendars };
}
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;
}
function formatAvailabilitySnapshot(data: {
dateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
oooExcludedDateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
}) {
return {
...data,
dateRanges: data.dateRanges.map(({ start, end }) => ({
start: start.toISOString(),
end: end.toISOString(),
})),
oooExcludedDateRanges: data.oooExcludedDateRanges.map(({ start, end }) => ({
start: start.toISOString(),
end: end.toISOString(),
})),
};
}
function buildBookingCreatedPayload({
booking,
organizerUserId,
organizerUserUuid,
hashedLink,
isDryRun,
organizationId,
}: {
booking: {
id: number;
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
userId: number | null;
};
organizerUserId: number;
organizerUserUuid: string | null;
hashedLink: string | null;
isDryRun: boolean;
organizationId: number | null;
}) {
return {
config: {
isDryRun,
},
bookingFormData: {
hashedLink,
},
booking: {
id: booking.id,
uid: booking.uid,
startTime: booking.startTime,
endTime: booking.endTime,
status: booking.status,
userId: booking.userId,
userUuid: organizerUserUuid,
user: {
id: organizerUserId,
},
},
organizationId,
};
}
export interface IBookingServiceDependencies {
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService;
prismaClient: PrismaClient;
bookingRepository: BookingRepository;
luckyUserService: LuckyUserService;
userRepository: UserRepository;
hashedLinkService: HashedLinkService;
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker;
featuresRepository: FeaturesRepository;
bookingEventHandler: BookingEventHandlerService;
webhookProducer: IWebhookProducerService;
}
async function validateRescheduleRestrictions({
rescheduleUid,
userId,
eventType,
}: {
rescheduleUid: string | null | undefined;
userId: number | null;
eventType: { seatsPerTimeSlot: number | null; minimumRescheduleNotice: number | null } | null;
}): Promise<void> {
if (!rescheduleUid || !eventType) {
return; // Not a reschedule, skip validation
}
const bookingSeat = rescheduleUid ? await getSeatedBooking(rescheduleUid) : null;
const actualRescheduleUid = bookingSeat ? bookingSeat.booking.uid : rescheduleUid;
if (!actualRescheduleUid) {
return; // No valid reschedule UID
}
try {
const originalRescheduledBooking = await getOriginalRescheduledBooking(
actualRescheduleUid,
!!eventType.seatsPerTimeSlot
);
// Check if user is the organizer
const isUserOrganizer =
userId && originalRescheduledBooking.userId && userId === originalRescheduledBooking.userId;
// Check minimum reschedule notice (only for non-organizers)
const { minimumRescheduleNotice } = originalRescheduledBooking.eventType || {};
if (
!isUserOrganizer &&
isWithinMinimumRescheduleNotice(originalRescheduledBooking.startTime, minimumRescheduleNotice ?? null)
) {
throw new HttpError({
statusCode: 403,
message: "Rescheduling is not allowed within the minimum notice period before the event",
});
}
} catch (error) {
// Re-throw HttpError (including our 403 validation error)
if (error instanceof HttpError) {
throw error;
}
// For other errors (like booking not found), let the service handle it later
// We don't want to fail early validation for these cases
}
}
/**
* TODO: Ideally we should send organizationId directly to handleNewBooking.
* webapp can derive from domain and API V2 knows it already through its endpoint URL
*/
async function getEventOrganizationId({
eventType,
}: {
eventType: {
userId: number | null;
team: {
parentId: number | null;
} | null;
parent: {
team: {
parentId: number | null;
} | null;
} | null;
};
}) {
let eventOrganizationId: number | null = null;
const team = eventType.team ?? eventType.parent?.team ?? null;
eventOrganizationId = team?.parentId ?? null;
if (eventOrganizationId) {
return eventOrganizationId;
}
if (eventType.userId) {
// TODO: Moving it to instance based access through DI in a followup
const profile = await ProfileRepository.findFirstForUserId({
userId: eventType.userId,
});
eventOrganizationId = profile?.organizationId ?? null;
return eventOrganizationId;
}
return eventOrganizationId;
}
async function handler(
this: RegularBookingService,
input: BookingHandlerInput,
deps: IBookingServiceDependencies,
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
) {
const {
bookingData: rawBookingData,
userId,
userUuid,
platformClientId,
platformCancelUrl,
platformBookingUrl,
platformRescheduleUrl,
platformBookingLocation,
hostname,
forcedSlug,
areCalendarEventsEnabled = true,
skipAvailabilityCheck = false,
skipEventLimitsCheck = false,
skipCalendarSyncTaskCreation = false,
traceContext: passedTraceContext,
impersonatedByUserUuid,
} = input;
let bookingEmailsAndSmsTaskerAction: BookingActionType = BookingActionMap.requested;
const traceContext = passedTraceContext
? passedTraceContext
: distributedTracing.createTrace("booking_creation");
const tracingLogger = distributedTracing.getTracingLogger(traceContext, {
eventTypeId: rawBookingData.eventTypeId,
userId: userId,
eventTypeSlug: rawBookingData.eventTypeSlug,
});
const isPlatformBooking = !!platformClientId;
const eventType = await getEventType({
eventTypeId: rawBookingData.eventTypeId,
eventTypeSlug: rawBookingData.eventTypeSlug,
});
// Early validation: Check reschedule restrictions if rescheduling
await validateRescheduleRestrictions({
rescheduleUid: rawBookingData.rescheduleUid,
userId: userId ?? null,
eventType: eventType
? {
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
}
: null,
});
const bookingDataSchema = bookingDataSchemaGetter({
view: rawBookingData.rescheduleUid ? "reschedule" : "booking",
bookingFields: eventType.bookingFields,
});
const bookingData = await getBookingData({
reqBody: rawBookingData,
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,
rrHostSubsetIds,
_isDryRun: isDryRun = false,
...reqBody
} = bookingData;
let troubleshooterData = buildTroubleshooterData({
eventType,
});
const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: tracingLogger });
try {
await checkIfBookerEmailIsBlocked({
loggedInUserId: userId,
bookerEmail,
verificationCode: reqBody.verificationCode,
isReschedule: !!rawBookingData.rescheduleUid,
});
} catch (error) {
if (error instanceof ErrorWithCode) {
throw new HttpError({ statusCode: 403, message: error.message });
}
throw error;
}
const spamCheckService = getSpamCheckService();
const eventOrganizationId = await getEventOrganizationId({
eventType,
});
spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId });
if (!rawBookingData.rescheduleUid) {
await checkActiveBookingsLimitForBooker({
eventTypeId,
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
bookerEmail,
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
});
}
if (eventType.requiresBookerEmailVerification && !rawBookingData.rescheduleUid) {
const verificationCode = reqBody.verificationCode;
if (!verificationCode) {
throw new HttpError({
statusCode: 400,
message: "email_verification_required",
});
}
try {
await verifyCodeUnAuthenticated(bookerEmail, verificationCode);
} catch {
throw new HttpError({
statusCode: 400,
message: "invalid_verification_code",
});
}
}
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
tracingLogger.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",
});
if (eventType.seatsPerTimeSlot && eventType.recurringEvent) {
throw new HttpError({
statusCode: 400,
message: "recurring_event_seats_error",
});
}
const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null;
const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid;
const isNormalBookingOrFirstRecurringSlot = input.bookingData.allRecurringDates
? !!input.bookingData.isFirstRecurringSlot
: true;
let originalRescheduledBooking = rescheduleUid
? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot)
: null;
const paymentAppData = getPaymentAppData({
...eventType,
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata),
});
const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({
eventType,
bookingStartTime: reqBody.start,
userId,
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
paymentAppData,
bookerEmail,
});
// For unconfirmed bookings or round robin bookings with the same attendee and timeslot, return the original booking
if (
(!isConfirmedByDefault && !userReschedulingIsOwner) ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
const existingBooking = await deps.bookingRepository.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
bookerPhoneNumber,
startTime: new Date(dayjs(reqBody.start).utc().format()),
filterForUnconfirmed: !isConfirmedByDefault,
});
if (existingBooking) {
const hasPayments = existingBooking.payment.length > 0;
const isPaidBooking = existingBooking.paid || !hasPayments;
const shouldShowPaymentForm = requiresPayment && !isPaidBooking;
const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined;
const bookingResponse = {
...existingBooking,
user: {
...existingBooking.user,
email: null,
},
paymentRequired: shouldShowPaymentForm,
seatReferenceUid: "",
};
return {
...bookingResponse,
luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [],
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
paymentUid: firstPayment?.uid,
paymentId: firstPayment?.id,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
}
const isTeamEventType =
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
// Use "booking" mode to bypass cache for booking confirmation
const calendarFetchMode = "booking" as const;
tracingLogger.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<typeof eventType>(
reqBody.start,
reqBody.timeZone,
eventType,
eventTimeZone,
tracingLogger
);
validateEventLength({
reqBodyStart: reqBody.start,
reqBodyEnd: reqBody.end,
eventTypeMultipleDuration: eventType.metadata?.multipleDuration,
eventTypeLength: eventType.length,
logger: tracingLogger,
});
const contactOwnerFromReq = reqBody.teamMemberEmail ?? null;
const skipContactOwner = shouldIgnoreContactOwner({
skipContactOwner: reqBody.skipContactOwner ?? null,
rescheduleUid: reqBody.rescheduleUid ?? null,
routedTeamMemberIds: routedTeamMemberIds ?? null,
});
const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq;
const crmRecordId: string | undefined = reqBody.crmRecordId ?? undefined;
let routingFormResponse = null;
if (routedTeamMemberIds) {
//routingFormResponseId could be 0 for dry run. So, we just avoid undefined value
if (routingFormResponseId === undefined) {
throw new HttpError({
statusCode: 400,
message: "Missing routingFormResponseId",
});
}
routingFormResponse = await deps.prismaClient.app_RoutingForms_FormResponse.findUnique({
where: {
id: routingFormResponseId,
},
select: {
response: true,
form: {
select: {
routes: true,
fields: true,
},
},
chosenRouteId: true,
},
});
}
const { qualifiedRRUsers, additionalFallbackRRUsers, fixedUsers } = await loadAndValidateUsers({
hostname,
forcedSlug,
isPlatform: isPlatformBooking,
eventType,
eventTypeId,
dynamicUserList,
logger: tracingLogger,
routedTeamMemberIds: routedTeamMemberIds ?? null,
contactOwnerEmail,
rescheduleUid: reqBody.rescheduleUid || null,
routingFormResponse,
rrHostSubsetIds: rrHostSubsetIds ?? undefined,
});
// We filter out users but ensure allHostUsers remain same.
let users = [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers];
const firstUser = users[0];
let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb({
dynamicUserList,
users,
location,
});
if (!skipEventLimitsCheck) {
await deps.checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({
eventType,
reqBodyStart: reqBody.start,
reqBodyRescheduleUid: reqBody.rescheduleUid,
});
}
let luckyUserResponse;
let isFirstSeat = true;
let availableUsers: IsFixedAwareUser[] = [];
if (eventType.seatsPerTimeSlot) {
const booking = await deps.prismaClient.booking.findFirst({
where: {
eventTypeId: eventType.id,
startTime: new Date(dayjs(reqBody.start).utc().format()),
status: BookingStatus.ACCEPTED,
},
select: {
userId: true,
attendees: { select: { email: true } },
},
});
if (booking) {
isFirstSeat = false;
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
const fixedHosts = users.filter((user) => user.isFixed);
const originalNonFixedHost = users.find((user) => !user.isFixed && user.id === booking.userId);
if (originalNonFixedHost) {
users = [...fixedHosts, originalNonFixedHost];
} else {
const attendeeEmailSet = new Set(booking.attendees.map((attendee) => attendee.email));
// In this case, the first booking user is a fixed host, so the chosen non-fixed host is added as an attendee of the booking
const nonFixedAttendeeHost = users.find(
(user) => !user.isFixed && attendeeEmailSet.has(user.email)
);
users = [...fixedHosts, ...(nonFixedAttendeeHost ? [nonFixedAttendeeHost] : [])];
}
}
}
}
//checks what users are available
if (isFirstSeat) {
const eventTypeWithUsers: Omit<getEventTypeResponse, "users"> & {
users: IsFixedAwareUserWithCredentials[];
} = {
...eventType,
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
users: users as IsFixedAwareUserWithCredentials[],
...(eventType.recurringEvent && {
recurringEvent: {
...eventType.recurringEvent,
count: recurringCount || eventType.recurringEvent.count,
},
}),
};
if (
input.bookingData.allRecurringDates &&
input.bookingData.isFirstRecurringSlot &&
input.bookingData.numSlotsToCheckForAvailability
) {
const isTeamEvent =
eventType.schedulingType === SchedulingType.COLLECTIVE ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN;
const fixedUsers = isTeamEvent
? eventTypeWithUsers.users.filter((user: IsFixedAwareUserWithCredentials) => user.isFixed)
: [];
for (
let i = 0;
i < input.bookingData.allRecurringDates.length &&
i < input.bookingData.numSlotsToCheckForAvailability;
i++
) {
const start = input.bookingData.allRecurringDates[i].start;
const end = input.bookingData.allRecurringDates[i].end;
if (isTeamEvent) {
// each fixed user must be available
for (const key in fixedUsers) {
if (!skipAvailabilityCheck) {
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [fixedUsers[key]] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking: originalRescheduledBooking ?? null,
},
tracingLogger,
calendarFetchMode
);
}
}
} else {
if (!skipAvailabilityCheck) {
await ensureAvailableUsers(
eventTypeWithUsers,
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
tracingLogger,
calendarFetchMode
);
}
}
}
}
if (!input.bookingData.allRecurringDates || input.bookingData.isFirstRecurringSlot) {
try {
if (!skipAvailabilityCheck) {
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,
},
tracingLogger,
calendarFetchMode
);
} else {
availableUsers = [...qualifiedRRUsers, ...fixedUsers] as IsFixedAwareUser[];
}
} catch {
if (additionalFallbackRRUsers.length) {
tracingLogger.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
if (!skipAvailabilityCheck) {
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,
},
tracingLogger,
calendarFetchMode
);
} else {
availableUsers = [...additionalFallbackRRUsers, ...fixedUsers] as IsFixedAwareUser[];
}
} else {
tracingLogger.debug(
"Qualified users not available, no fallback users",
safeStringify({
qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id),
})
);
throw new Error(ErrorCode.NoAvailableUsersFound);
}
}
const fixedUserPool: IsFixedAwareUser[] = [];
const nonFixedUsers: IsFixedAwareUser[] = [];
availableUsers.forEach((user) => {
if (user.isFixed) {
fixedUserPool.push(user);
} else {
nonFixedUsers.push(user);
}
});
// Group non-fixed users by their group IDs
const luckyUserPools = groupHostsByGroupId({
hosts: nonFixedUsers,
hostGroups: eventType.hostGroups,
});
const notAvailableLuckyUsers: typeof users = [];
tracingLogger.debug(
"Computed available users",
safeStringify({
availableUsers: availableUsers.map((user) => user.id),
luckyUserPools: Object.fromEntries(
Object.entries(luckyUserPools).map(([groupId, users]) => [groupId, users.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
for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) {
let luckUserFound = false;
while (luckyUserPool.length > 0 && !luckUserFound) {
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 firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: eventTypeWithUsers.users[0].id ?? null,
teamId: eventType.teamId,
});
const newLuckyUser = await deps.luckyUserService.getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
// only hosts from the same group
allRRHosts: (
await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: eventTypeWithUsers.hosts,
})
).filter(
(host) =>
!host.isFixed &&
userIdsSet.has(host.user.id) &&
(host.groupId === groupId || (!host.groupId && groupId === DEFAULT_GROUP_ID))
),
eventType,
routingFormResponse,
meetingStartTime: new Date(reqBody.start),
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
if (
input.bookingData.isFirstRecurringSlot &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
input.bookingData.numSlotsToCheckForAvailability &&
input.bookingData.allRecurringDates
) {
// for recurring round robin events check if lucky user is available for next slots
try {
for (
let i = 0;
i < input.bookingData.allRecurringDates.length &&
i < input.bookingData.numSlotsToCheckForAvailability;
i++
) {
const start = input.bookingData.allRecurringDates[i].start;
const end = input.bookingData.allRecurringDates[i].end;
if (!skipAvailabilityCheck) {
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [newLuckyUser] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
tracingLogger,
calendarFetchMode
);
}
}
// if no error, then lucky user is available for the next slots
luckyUsers.push(newLuckyUser);
luckUserFound = true;
} catch {
notAvailableLuckyUsers.push(newLuckyUser);
tracingLogger.info(
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
);
}
} else {
luckyUsers.push(newLuckyUser);
luckUserFound = true;
}
}
}
// ALL fixed users must be available
if (fixedUserPool.length !== users.filter((user) => user.isFixed).length) {
throw new Error(ErrorCode.FixedHostsUnavailableForBooking);
}
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
const hostGroups = groupHostsByGroupId({
hosts: roundRobinHosts,
hostGroups: eventType.hostGroups,
});
// Filter out host groups that have no hosts in them
const nonEmptyHostGroups = Object.fromEntries(
Object.entries(hostGroups).filter(([, hosts]) => hosts.length > 0)
);
// If there are RR hosts, we need to find a lucky user
if (
[...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 &&
luckyUsers.length !== (Object.keys(nonEmptyHostGroups).length || 1)
) {
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
}
// 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: Object.values(luckyUserPools)
.flat()
.map((u) => u.id),
};
} else if (
input.bookingData.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) {
tracingLogger.error(`No available users found for round robin event.`);
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
}
// 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 getAllCredentialsIncludeServiceAccountKey(organizerUser, eventType);
// 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;
// Track credential ID for per-host locations
let perHostCredentialId: number | undefined;
// Handle per-host custom locations for round-robin events
if (
eventType.enablePerHostLocations &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
organizerUser
) {
const organizerHost = eventType.hosts.find((host) => host.user.id === organizerUser.id);
if (organizerHost?.location) {
const result = await BookingLocationService.getPerHostLocation({
hostLocation: organizerHost.location,
allCredentials,
eventTypeId: eventType.id,
userId: organizerUser.id,
prismaClient: deps.prismaClient,
});
locationBodyString = result.locationBodyString;
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = result.organizerDefaultLocationUrl;
perHostCredentialId = result.perHostCredentialId;
tracingLogger.info("Using per-host location", {
userId: organizerUser.id,
locationType: result.locationBodyString,
credentialId: result.perHostCredentialId,
});
}
}
// 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;
}
}
const organizationDefaultLocation = getFirstDelegationConferencingCredentialAppLocation({
credentials: firstUser.credentials,
});
// use host default
if (locationBodyString === OrganizerDefaultConferencingAppType) {
const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata);
const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined;
const defaultApp = organizerMetadata?.defaultConferencingApp;
if (defaultApp?.appSlug) {
const app = getAppFromSlug(defaultApp.appSlug);
locationBodyString = app?.appData?.location?.type || locationBodyString;
const mainHostCalendar = eventType.destinationCalendar || organizerUser.destinationCalendar;
if (locationBodyString === MeetLocationType && mainHostCalendar?.integration !== "google_calendar") {
locationBodyString = "integrations:daily";
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = undefined;
} else if (isManagedEventType || isTeamEventType) {
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = defaultApp?.appLink;
}
} else {
locationBodyString = organizationDefaultLocation || "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 guestEmails = (reqGuests || []).map((email) => extractBaseEmail(email).toLowerCase());
const guestUsers = await deps.userRepository.findManyByEmailsWithEmailVerificationSettings({
emails: guestEmails,
});
const emailToRequiresVerification = new Map<string, boolean>();
for (const user of guestUsers) {
const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase();
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
}
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 (emailToRequiresVerification.get(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) {
tracingLogger.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: eventTypeCredentialId } =
organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? {
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
: getLocationValueForDB(locationBodyString, eventType.locations);
// Use per-host credential if available, otherwise fall back to event type credential
const conferenceCredentialId = perHostCredentialId ?? eventTypeCredentialId;
tracingLogger.info("locationBodyString", locationBodyString);
tracingLogger.info("event type locations", eventType.locations);
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
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: dayjs(reqBody.end).diff(reqBody.start, "minutes"),
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 deps.prismaClient.profile.findFirst({
where: {
userId: organizerUser.id,
},
select: {
organizationId: true,
username: true,
organization: { select: { hideBranding: true } },
},
});
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: "",
};
// Only attach recurring config when this booking belongs to a recurring series.
const computedRecurringEvent =
reqBody.recurringEventId && eventType.recurringEvent
? { ...eventType.recurringEvent, count: recurringCount ?? eventType.recurringEvent.count }
: undefined;
const { teamMembers, teamDestinationCalendars } = await computeTeamData({
isTeamEventType,
schedulingType: eventType.schedulingType,
users,
organizerEmail: organizerUser.email,
});
const teamInfo = eventType.team;
const eventName = getEventName(eventNameObject);
let evt: BuiltCalendarEvent = new CalendarEventBuilder({
bookerUrl,
title: eventName,
startTime: dayjs(reqBody.start).utc().format(),
endTime: dayjs(reqBody.end).utc().format(),
type: eventType.slug,
organizer: {
id: organizerUser.id,
name: organizerUser.name || "Nameless",
email: organizerEmail,
username: organizerUser.username || undefined,
usernameInOrg: organizerOrganizationProfile?.username || undefined,
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat),
},
attendees: attendeesList,
additionalNotes,
})
.withEventType({
description: eventType.description,
id: eventType.id,
hideCalendarNotes: eventType.hideCalendarNotes,
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
hideOrganizerEmail: eventType.hideOrganizerEmail,
schedulingType: eventType.schedulingType,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
// if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true,
customReplyToEmail: eventType.customReplyToEmail,
disableRescheduling: eventType.disableRescheduling ?? false,
disableCancelling: eventType.disableCancelling ?? false,
})
.withMetadataAndResponses({
additionalNotes,
customInputs,
responses: reqBody.calEventResponses || null,
userFieldsResponses: reqBody.calEventUserFieldsResponses || null,
})
.withLocation({
location: platformBookingLocation ?? bookingLocation, // Will be processed by the EventManager later.
conferenceCredentialId,
})
.withDestinationCalendar(
teamDestinationCalendars.length > 0
? [...(destinationCalendar ?? []), ...teamDestinationCalendars]
: destinationCalendar
)
.withIdentifiers({ iCalUID, iCalSequence })
.withConfirmation({
requiresConfirmation: !isConfirmedByDefault,
isConfirmedByDefault,
})
.withPlatformVariables({
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
})
.withOrganization(organizerOrganizationId)
.withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null)
.withRecurring(computedRecurringEvent ?? undefined)
.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId)
.withTeam(
isTeamEventType
? {
members: teamMembers,
name: teamInfo?.name || "Nameless",
id: teamInfo?.id ?? 0,
}
: undefined
)
.withHideBranding(
await getEventTypeService().shouldHideBrandingForEventType(eventType.id, {
team: eventType.team
? { hideBranding: eventType.team.hideBranding, parent: eventType.team.parent }
: null,
owner: {
id: organizerUser.id,
hideBranding: organizerUser.hideBranding,
profiles: organizerOrganizationProfile
? [{ organization: organizerOrganizationProfile.organization }]
: [],
},
} satisfies EventTypeBrandingData)
)
.build();
// 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
);
const spamCheckResult = await spamCheckService.waitForCheck();
if (spamCheckResult.isBlocked) {
const DECOY_ORGANIZER_NAMES = ["Alex Smith", "Jordan Taylor", "Sam Johnson", "Chris Morgan"];
const randomOrganizerName =
DECOY_ORGANIZER_NAMES[Math.floor(Math.random() * DECOY_ORGANIZER_NAMES.length)];
const eventName = getEventName({
...eventNameObject,
host: randomOrganizerName,
});
return {
id: 0,
uid,
iCalUID: "",
status: BookingStatus.ACCEPTED,
eventTypeId: eventType.id,
user: {
name: randomOrganizerName,
timeZone: "UTC",
email: null,
},
userId: null,
userUuid: null,
title: eventName,
startTime: new Date(reqBody.start),
endTime: new Date(reqBody.end),
createdAt: new Date(),
updatedAt: new Date(),
attendees: [
{
id: 0,
email: bookerEmail,
name: fullName,
timeZone: reqBody.timeZone,
locale: null,
phoneNumber: null,
bookingId: null,
noShow: null,
},
],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
idempotencyKey: null,
userPrimaryEmail: null,
description: eventType.description || null,
customInputs: null,
responses: null,
location: bookingLocation,
paid: false,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
fromReschedule: null,
recurringEventId: null,
scheduledJobs: [],
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
ratingFeedback: null,
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
references: [],
payment: [],
isDryRun: false,
paymentRequired: false,
paymentUid: undefined,
luckyUsers: [],
paymentId: undefined,
seatReferenceUid: undefined,
isShortCircuitedBooking: true,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
const actionSource = getAuditActionSource({
creationSource: input.bookingData.creationSource,
eventTypeId,
rescheduleUid: originalRescheduledBooking?.uid ?? null,
});
// 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,
invitee,
allCredentials,
organizerUser,
originalRescheduledBooking,
bookerEmail,
bookerPhoneNumber,
tAttendees,
bookingSeat,
reqUserId: input.userId,
reqUserUuid: userUuid,
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,
organizationId: eventOrganizationId,
actionSource,
traceContext,
impersonatedByUserUuid: impersonatedByUserUuid ?? null,
deps,
},
deps.featuresRepository
);
if (newBooking) {
const bookingResponse = {
...newBooking,
user: {
...newBooking.user,
email: null,
},
paymentRequired: false,
isDryRun: isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
};
return {
...bookingResponse,
...luckyUserResponse,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
} 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;
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
?.withIdentifiers({
iCalUID: getICalUID({
attendeeId: bookingSeat?.attendeeId,
}),
})
.build();
evt = updatedEvt;
}
}
const changedOrganizer =
!!originalRescheduledBooking &&
(eventType.schedulingType === SchedulingType.ROUND_ROBIN ||
eventType.schedulingType === SchedulingType.COLLECTIVE) &&
originalRescheduledBooking.userId !== evt.organizer.id;
const skipDeleteEventsAndMeetings = changedOrganizer;
const isBookingRequestedReschedule =
!!originalRescheduledBooking &&
!!originalRescheduledBooking.rescheduled &&
originalRescheduledBooking.status === BookingStatus.CANCELLED;
if (
changedOrganizer &&
originalRescheduledBooking &&
originalRescheduledBooking?.user?.name &&
organizerUser?.name
) {
evt.title = updateHostInEventName(
originalRescheduledBooking.title,
originalRescheduledBooking.user.name,
organizerUser.name
);
}
let results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
let referencesToCreate: PartialReference[] = [];
let booking: CreatedBooking | null = null;
tracingLogger.debug(
"Going to create booking in DB now",
safeStringify({
organizerUser: organizerUser.id,
attendeesList: attendeesList.map((guest) => ({ timeZone: guest.timeZone })),
requiresConfirmation: evt.requiresConfirmation,
isConfirmedByDefault,
userReschedulingIsOwner,
})
);
let assignmentReason: { reasonEnum: AssignmentReasonEnum; reasonString: string } | undefined;
try {
if (!isDryRun) {
booking = await 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,
smsReminderNumber,
responses,
},
evt,
originalRescheduledBooking,
creationSource: input.bookingData.creationSource,
tracking: reqBody.tracking,
});
if (booking?.userId) {
const usersRepository = new UsersRepository();
await usersRepository.updateLastActiveAt(booking.userId);
const organizerUserAvailability = availableUsers.find((user) => user.id === booking?.userId);
criticalLogger.info(`Booking created`, {
bookingUid: booking.uid,
selectedCalendarIds: organizerUser.allSelectedCalendars?.map((c) => c.id) ?? [],
availabilitySnapshot: organizerUserAvailability?.availabilityData
? formatAvailabilitySnapshot(organizerUserAvailability.availabilityData)
: null,
});
}
// If it's a round robin event, record the reason for the host assignment
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && routingFormResponseId) {
try {
const routingTraceService = getRoutingTraceService();
const result = await routingTraceService.processForBooking({
formResponseId: routingFormResponseId,
bookingId: booking.id,
bookingUid: booking.uid,
organizerEmail: organizerUser.email,
isRerouting: !!reroutingFormResponses,
reroutedByEmail: reqBody.rescheduledBy,
});
if (result?.assignmentReason) {
assignmentReason = result.assignmentReason;
}
} catch (error) {
criticalLogger.warn("Failed to process routing trace", { error });
}
}
evt = CalendarEventBuilder.fromEvent(evt)
.withUid(booking.uid ?? null)
.build();
evt = CalendarEventBuilder.fromEvent(evt)
.withOneTimePassword(booking.oneTimePassword ?? null)
.build();
// Add assignment reason to evt for emails
if (assignmentReason) {
evt = CalendarEventBuilder.fromEvent(evt)
.withAssignmentReason({
category: getAssignmentReasonCategory(assignmentReason.reasonEnum),
details: assignmentReason.reasonString ?? null,
})
.build();
}
if (booking && booking.id && eventType.seatsPerTimeSlot) {
const currentAttendee = booking.attendees.find(
(attendee) =>
attendee.email === bookingData.responses.email ||
(bookingData.responses.attendeePhoneNumber &&
attendee.phoneNumber === bookingData.responses.attendeePhoneNumber)
);
// Save description to bookingSeat
const uniqueAttendeeId = uuid();
await deps.prismaClient.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: users,
isManagedEventType,
});
booking = dryRunBooking;
troubleshooterData = {
...troubleshooterData,
..._troubleshooterData,
};
}
} catch (_err) {
const err = getServerErrorFromUnknown(_err);
tracingLogger.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.cause && typeof err.cause === "object" && "code" in err.cause && err.cause.code === "P2002") {
throw new HttpError({
statusCode: 409,
message: ErrorCode.BookingConflict,
});
}
throw err;
}
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
const credentials = await refreshCredentials(allCredentials);
const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps);
const eventManager =
!isDryRun && !skipCalendarSyncTaskCreation
? new EventManager({ ...organizerUser, credentials }, apps)
: buildDryRunEventManager();
let videoCallUrl;
// this is the actual rescheduling logic
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
tracingLogger.silly("Rescheduling booking", originalRescheduledBooking.uid);
// cancel workflow reminders from previous rescheduled booking
await WorkflowRepository.deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders);
evt = CalendarEventBuilder.fromEvent(evt)
.withVideoCallDataFromReferences(originalRescheduledBooking.references)
.build();
evt.rescheduledBy = reqBody.rescheduledBy;
// 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) {
// 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;
evt.hasOrganizerChanged = true;
}
if (changedOrganizer && originalRescheduledBooking?.user) {
const originalHostCredentials = await getAllCredentialsIncludeServiceAccountKey(
originalRescheduledBooking.user,
eventType
);
const refreshedOriginalHostCredentials = await refreshCredentials(originalHostCredentials);
// Create EventManager with original host's credentials for deletion operations
const originalHostEventManager = new EventManager(
{ ...originalRescheduledBooking.user, credentials: refreshedOriginalHostCredentials },
apps
);
tracingLogger.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
// Create deletion event with original host's organizer info and original booking properties
const deletionEvent = {
...evt,
organizer: {
id: originalRescheduledBooking.user.id,
name: originalRescheduledBooking.user.name || "",
email: originalRescheduledBooking.user.email,
username: originalRescheduledBooking.user.username || undefined,
timeZone: originalRescheduledBooking.user.timeZone,
language: { translate: tOrganizer, locale: originalRescheduledBooking.user.locale ?? "en" },
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
},
destinationCalendar: previousHostDestinationCalendar,
// Override with original booking properties used by deletion operations
startTime: originalRescheduledBooking.startTime.toISOString(),
endTime: originalRescheduledBooking.endTime.toISOString(),
uid: originalRescheduledBooking.uid,
location: originalRescheduledBooking.location,
responses: originalRescheduledBooking.responses
? (originalRescheduledBooking.responses as CalEventResponses)
: evt.responses,
};
if (!skipCalendarSyncTaskCreation) {
await originalHostEventManager.deleteEventsAndMeetings({
event: deletionEvent,
bookingReferences: originalRescheduledBooking.references,
});
}
}
// 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 updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
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",
};
tracingLogger.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) {
tracingLogger.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 && isConfirmedByDefault && !isDryRun) {
await emailsAndSmsHandler.send({
action: BookingActionMap.rescheduled,
data: {
evt,
eventType,
additionalInformation: metadata,
additionalNotes,
iCalUID,
originalRescheduledBooking,
rescheduleReason,
isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail,
users,
changedOrganizer,
},
});
bookingEmailsAndSmsTaskerAction = BookingActionMap.rescheduled;
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (isConfirmedByDefault) {
const shouldSkipCalendarEvents = !areCalendarEventsEnabled || skipCalendarSyncTaskCreation;
const createManager = await eventManager.create(evt, { skipCalendarEvent: shouldSkipCalendarEvents });
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",
};
tracingLogger.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) {
tracingLogger.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 deps.prismaClient.booking.update({
where: {
id: booking.id,
},
data: {
iCalUID: evt.iCalUID || booking.iCalUID,
},
});
}
}
if (!noEmail) {
if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) {
await emailsAndSmsHandler.send({
action: BookingActionMap.confirmed,
data: {
eventType: {
metadata: eventType.metadata,
schedulingType: eventType.schedulingType,
},
eventNameObject,
workflows,
evt,
additionalInformation,
additionalNotes,
customInputs,
},
});
bookingEmailsAndSmsTaskerAction = BookingActionMap.confirmed;
}
}
}
} 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
tracingLogger.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) {
tracingLogger.debug(
`Emails: Booking ${organizerUser.username} requires confirmation, sending request emails`,
safeStringify({
calEvent: getPiiFreeCalendarEvent(evt),
})
);
if (!isDryRun) {
await emailsAndSmsHandler.send({
action: BookingActionMap.requested,
data: { evt, attendees: attendeesList, eventType, additionalNotes },
});
bookingEmailsAndSmsTaskerAction = BookingActionMap.requested;
}
}
if (booking.location?.startsWith("http")) {
videoCallUrl = booking.location;
}
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
: undefined;
// Query feature flags in parallel before firing booking events
// TODO: We should support checkIfOrgHasFeatures - Bulk plus orgId check, that would be more efficient.
const [isBookingEmailSmsTaskerEnabled, isBookingAuditEnabled] = orgId
? await Promise.all([
deps.featuresRepository.checkIfTeamHasFeature(orgId, "booking-email-sms-tasker"),
deps.featuresRepository.checkIfTeamHasFeature(orgId, "booking-audit"),
])
: [false, false];
await this.fireBookingEvents({
booking: {
...booking,
userEmail: booking.user?.email ?? null,
},
organizerUser,
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
hashedLink: hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null,
isDryRun,
eventOrganizationId,
bookerEmail,
bookerName: fullName,
actorUserUuid: userUuid ?? null,
originalRescheduledBooking,
rescheduledBy: reqBody.rescheduledBy ?? null,
actionSource,
isRecurringBooking: !!input.bookingData.allRecurringDates,
attendeeSeatId: evt.attendeeSeatId ?? null,
tracingLogger,
impersonatedByUserUuid: impersonatedByUserUuid ?? null,
isBookingAuditEnabled,
});
const webhookLocation= metadata?.videoCallUrl || evt.location;
const { assignmentReason: _emailAssignmentReason, ...evtWithoutAssignmentReason } = evt;
const webhookData: EventPayloadType = {
...evtWithoutAssignmentReason,
...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,
location: webhookLocation,
...(assignmentReason ? { assignmentReason: [assignmentReason] } : {}),
};
if (bookingRequiresPayment) {
tracingLogger.debug(`Booking ${organizerUser.username} requires payment`);
// Load credentials.app.categories
const credentialPaymentAppCategories = await deps.prismaClient.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,
metadata: eventType.metadata
? {
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
: {},
},
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
booking,
bookerName: fullName,
bookerEmail,
bookerPhoneNumber,
isDryRun,
bookingFields: eventType.bookingFields,
locale: language,
});
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,
traceContext,
});
try {
const calendarEventForWorkflow = {
...evt,
rescheduleReason,
metadata,
eventType: {
slug: eventType.slug,
schedulingType: eventType.schedulingType,
hosts: eventType.hosts,
},
bookerUrl,
};
if (isNormalBookingOrFirstRecurringSlot) {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: calendarEventForWorkflow,
hideBranding: evt.hideBranding || !!platformClientId,
seatReferenceUid: evt.attendeeSeatId,
isDryRun,
triggers: [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
} catch (error) {
tracingLogger.error(
"Error while scheduling workflow reminders for booking payment initiated",
JSON.stringify({ error })
);
}
// 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,
},
videoCallUrl: metadata?.videoCallUrl,
// Ensure seatReferenceUid is properly typed as string | null
seatReferenceUid: evt.attendeeSeatId,
};
return {
...bookingResponse,
...luckyUserResponse,
message: "Payment required",
paymentRequired: true,
paymentUid: payment?.uid,
paymentId: payment?.id,
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
tracingLogger.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 getWebhooks(subscriberOptionsMeetingEnded);
const subscribersMeetingStarted = await getWebhooks(subscriberOptionsMeetingStarted);
const deleteWebhookScheduledTriggerPromises: Promise<unknown>[] = [];
const scheduleTriggerPromises = [];
if (rescheduleUid && originalRescheduledBooking) {
//delete all scheduled triggers for meeting ended and meeting started of booking
deleteWebhookScheduledTriggerPromises.push(
deleteWebhookScheduledTriggers({
booking: originalRescheduledBooking,
isDryRun,
})
);
deleteWebhookScheduledTriggerPromises.push(
cancelNoShowTasksForBooking({
bookingUid: originalRescheduledBooking.uid,
})
);
}
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,
})
);
}
}
const scheduledTriggerResults = await Promise.allSettled([
...deleteWebhookScheduledTriggerPromises,
...scheduleTriggerPromises,
]);
const failures = scheduledTriggerResults.filter((result) => result.status === "rejected");
if (failures.length > 0) {
tracingLogger.error(
"Error while scheduling or canceling webhook triggers",
safeStringify({
errors: failures.map((f) => f.reason),
})
);
}
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
await handleWebhookTrigger({
subscriberOptions,
eventTrigger,
webhookData,
isDryRun,
traceContext,
});
}
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
try {
if (!isDryRun) {
await deps.prismaClient.booking.update({
where: {
uid: booking.uid,
},
data: {
location: evt.location,
metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata },
references: {
createMany: {
data: referencesToCreate,
},
},
},
});
}
} catch (error) {
tracingLogger.error("Error while creating booking references", JSON.stringify({ error }));
}
// Queue BOOKING_REQUESTED webhook after booking update so consumer fetches booking with location, metadata, references
if (booking && booking.status === BookingStatus.PENDING && !isDryRun) {
try {
await deps.webhookProducer.queueBookingRequestedWebhook({
bookingUid: booking.uid,
userId: subscriberOptions.userId ?? undefined,
eventTypeId: subscriberOptions.eventTypeId ?? undefined,
teamId: Array.isArray(subscriberOptions.teamId)
? subscriberOptions.teamId[0]
: (subscriberOptions.teamId ?? undefined),
orgId: subscriberOptions.orgId ?? undefined,
oAuthClientId: platformClientId ?? undefined,
});
} catch (webhookError) {
tracingLogger.error(
`Error queueing BOOKING_REQUESTED webhook: bookingId: ${booking.id}, bookingUid: ${booking.uid}`,
safeStringify(webhookError)
);
}
}
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: (evt.hideBranding ?? false) || !!platformClientId,
seatReferenceUid: evt.attendeeSeatId,
isPlatformNoEmail: noEmail && Boolean(platformClientId),
isDryRun,
traceContext,
});
}
try {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsForNewBooking({
workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: evtWithMetadata,
hideBranding: evt.hideBranding || !!platformClientId,
seatReferenceUid: evt.attendeeSeatId,
isDryRun,
isConfirmedByDefault,
isNormalBookingOrFirstRecurringSlot,
isRescheduleEvent: !!rescheduleUid,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
tracingLogger.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}
try {
if (isConfirmedByDefault) {
await scheduleNoShowTriggers({
booking: {
startTime: booking.startTime,
id: booking.id,
location: booking.location,
uid: booking.uid,
},
triggerForUser,
organizerUser: { id: organizerUser.id },
eventTypeId,
teamId,
orgId,
isDryRun,
});
}
} catch (error) {
tracingLogger.error("Error while scheduling no show triggers", JSON.stringify({ error }));
}
if (!isDryRun) {
await handleAnalyticsEvents({
credentials: allCredentials,
rawBookingData,
bookingInfo: {
name: fullName,
email: bookerEmail,
eventName: "Cal.com lead",
},
isTeamEventType,
});
// Unused until we deploy to trigger.dev production
// for now we only enable for cal.com org and we keep our current email system
// cal.com org members will see emails in double while we test
if (ENABLE_ASYNC_TASKER && !noEmail && isBookingEmailSmsTaskerEnabled) {
try {
await deps.bookingEmailAndSmsTasker.send({
action: bookingEmailsAndSmsTaskerAction,
schedulingType: evtWithMetadata.eventType.schedulingType,
payload: {
bookingId: booking.id,
conferenceCredentialId,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail,
},
});
} catch (err) {
tracingLogger.error("bookingEmailAndSmsTasker error:", err);
}
}
}
// 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,
videoCallUrl: metadata?.videoCallUrl,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
/**
* Takes care of creating/rescheduling non-recurring, non-instant bookings. Such bookings could be TeamBooking, UserBooking, SeatedUserBooking, SeatedTeamBooking, etc.
* We can't name it CoreBookingService because non-instant booking also creates a booking but it is entirely different from the regular booking.
* We are open to renaming it to something more descriptive.
*/
export class RegularBookingService implements IBookingService {
constructor(private readonly deps: IBookingServiceDependencies) {}
async fireBookingEvents({
booking,
organizerUser,
hashedLink,
isDryRun,
eventOrganizationId,
bookerEmail,
bookerName,
actorUserUuid,
originalRescheduledBooking,
rescheduledBy,
actionSource,
isRecurringBooking,
attendeeSeatId,
tracingLogger,
impersonatedByUserUuid,
isBookingAuditEnabled,
}: {
booking: {
id: number;
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
userId: number | null;
attendees?: Array<{ id: number; email: string }>;
userUuid: string | null;
userEmail: string | null;
};
organizerUser: { id: number; uuid: string };
hashedLink: string | null;
isDryRun: boolean;
eventOrganizationId: number | null;
bookerEmail: string;
bookerName: string;
rescheduledBy: string | null;
actorUserUuid: string | null;
originalRescheduledBooking: BookingType | null;
actionSource: ActionSource;
isRecurringBooking: boolean;
tracingLogger: ReturnType<typeof distributedTracing.getTracingLogger>;
attendeeSeatId: string | null;
impersonatedByUserUuid: string | null;
isBookingAuditEnabled: boolean;
}) {
try {
const bookingCreatedPayload = buildBookingCreatedPayload({
booking,
organizerUserId: organizerUser.id,
organizerUserUuid: organizerUser.uuid,
hashedLink,
isDryRun,
organizationId: eventOrganizationId,
});
const bookingEventHandler = this.deps.bookingEventHandler;
const bookerAttendeeId = booking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id;
const rescheduledByAttendeeId = booking.attendees?.find(
(attendee) => attendee.email === rescheduledBy
)?.id;
const rescheduledByUserUuid = booking.userEmail === rescheduledBy ? booking.userUuid : null;
const auditActor = getBookingAuditActorForNewBooking({
bookerAttendeeId: bookerAttendeeId ?? null,
actorUserUuid,
bookerEmail,
bookerName,
rescheduledBy: rescheduledBy
? {
attendeeId: rescheduledByAttendeeId ?? null,
userUuid: rescheduledByUserUuid ?? null,
email: rescheduledBy,
}
: null,
logger: tracingLogger,
});
const auditContext = impersonatedByUserUuid ? { impersonatedBy: impersonatedByUserUuid } : undefined;
// For recurring bookings we fire the events in the RecurringBookingService
if (!isRecurringBooking) {
if (originalRescheduledBooking) {
const bookingRescheduledPayload: BookingRescheduledPayload = {
...bookingCreatedPayload,
oldBooking: {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
},
};
await bookingEventHandler.onBookingRescheduled({
payload: bookingRescheduledPayload,
actor: auditActor,
auditData: buildBookingRescheduledAuditData({
oldBooking: originalRescheduledBooking,
newBooking: booking,
}),
source: actionSource,
operationId: null,
context: auditContext,
isBookingAuditEnabled,
});
} else {
await bookingEventHandler.onBookingCreated({
payload: bookingCreatedPayload,
actor: auditActor,
auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId }),
source: actionSource,
operationId: null,
context: auditContext,
isBookingAuditEnabled,
});
}
}
} catch (error) {
tracingLogger.error("Error while firing booking events", safeStringify(error));
}
}
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler.bind(this)(
{
bookingData: input.bookingData,
...input.bookingMeta,
impersonatedByUserUuid: input.bookingMeta?.impersonatedByUserUuid ?? null,
},
this.deps
);
}
async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler.bind(this)(
{
bookingData: input.bookingData,
...input.bookingMeta,
impersonatedByUserUuid: input.bookingMeta?.impersonatedByUserUuid ?? null,
},
this.deps
);
}
/**
* @deprecated Exists only till API v1 is removed.
*/
async createBookingForApiV1(input: {
bookingData: CreateRegularBookingData;
bookingMeta?: CreateBookingMeta;
bookingDataSchemaGetter: BookingDataSchemaGetter;
}) {
return handler.bind(this)(
{
bookingData: input.bookingData,
...input.bookingMeta,
impersonatedByUserUuid: input.bookingMeta?.impersonatedByUserUuid ?? null,
},
this.deps,
input.bookingDataSchemaGetter
);
}
}