diff --git a/packages/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts index 5df5b123955ae0..672aac5a54465c 100644 --- a/packages/features/bookings/lib/dto/types.d.ts +++ b/packages/features/bookings/lib/dto/types.d.ts @@ -4,13 +4,32 @@ */ import type getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema"; import type getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { SchedulingType } from "@calcom/prisma/enums"; +import type { BookingCreateBody as BaseCreateBookingData } from "@calcom/prisma/zod/custom/booking"; import type { ExtendedBookingCreateBody } from "../bookingCreateBodySchema"; +import type { extendedBookingCreateBody } from "../bookingCreateBodySchema"; +import type { Booking } from "../handleNewBooking/createBooking"; import type { InstantBookingCreateService } from "../service/InstantBookingCreateService"; import type { RegularBookingService } from "../service/RegularBookingService"; export type { BookingCreateBody } from "../bookingCreateBodySchema"; + +// Use ReturnType from booking repository for type safety +type ExistingBooking = Awaited>; + +interface ExistingBookingResponse extends Omit, "user"> { + user: Omit["user"], "email"> & { email: null }; + paymentRequired: boolean; + seatReferenceUid: string; + luckyUsers: number[]; + isDryRun: boolean; + troubleshooterData?: Record; + paymentUid?: string; + paymentId?: number; +} +export type ExtendedBookingCreateData = z.input; export type BookingDataSchemaGetter = typeof getBookingDataSchema | typeof getBookingDataSchemaForApi; export type CreateRegularBookingData = ExtendedBookingCreateBody; @@ -21,7 +40,7 @@ export type CreateRecurringBookingData = (ExtendedBookingCreateBody & { schedulingType?: SchedulingType; })[]; -export type CreateSeatedBookingInput = BaseCreateBookingData & Pick; +export type CreateSeatedBookingInput = BaseCreateBookingData & Pick; export type PlatformParams = { platformClientId?: string; @@ -42,9 +61,129 @@ export type CreateBookingMeta = { export type BookingHandlerInput = { bookingData: CreateRegularBookingData; -} & CreateBookingMeta; + bookingMeta: CreateBookingMeta; +}; // TODO: In a followup PR, we working on defining the type here itself instead of inferring it. export type RegularBookingCreateResult = Awaited>; +export type CreateInstantBookingResponse = { + message: string; + meetingTokenId: number; + bookingId: number; + bookingUid: string; + expires: Date; + userId: number | null; +}; + +// TODO: Ideally we should define the types here instead of letting BookingCreateService send anything and keep using it +export type BookingCreateResult = Awaited>; + export type InstantBookingCreateResult = Awaited>; + +// Type for booking with additional fields from the creation process +export type CreatedBooking = Booking & { + appsStatus?: import("@calcom/types/Calendar").AppsStatus[]; + paymentUid?: string; + paymentId?: number; +}; + +// Base user type that ensures timeZone and name are always available +type BookingUser = { + id?: number; + name?: string | null; + username?: string | null; + email?: null; + timeZone?: string; +} | null; + +// Discriminated union for legacyHandler return types +export type LegacyHandlerResult = + // Early return case - existing booking found + | (Omit & { + _type: "existing"; + user: BookingUser; + paymentUid?: string; + }) + // Payment required case + | { + _type: "payment_required"; + id?: number; + uid?: string; + title?: string; + description?: string | null; + startTime?: Date; + endTime?: Date; + location?: string | null; + status?: import("@calcom/prisma/enums").BookingStatus; + metadata?: import("@prisma/client").Prisma.JsonValue | null; + user?: BookingUser; + attendees?: Array<{ + id: number; + name: string; + email: string; + timeZone: string; + phoneNumber: string | null; + }>; + eventType?: { + id?: number; + title?: string; + slug?: string; + } | null; + paymentRequired: true; + message: string; + paymentUid?: string; + paymentId?: number; + isDryRun?: boolean; + troubleshooterData?: Record; + luckyUsers?: number[]; + userPrimaryEmail?: string | null; + responses?: import("@prisma/client").Prisma.JsonValue | null; + references?: PartialReference[] | CreatedBooking["references"]; + seatReferenceUid?: string; + videoCallUrl?: string | null; + } + // Successful booking case + | { + _type: "success"; + id?: number; + uid?: string; + title?: string; + description?: string | null; + startTime?: Date; + endTime?: Date; + location?: string | null; + status?: import("@calcom/prisma/enums").BookingStatus; + metadata?: import("@prisma/client").Prisma.JsonValue | null; + user?: BookingUser; + attendees?: Array<{ + id: number; + name: string; + email: string; + timeZone: string; + phoneNumber: string | null; + }>; + eventType?: { + id?: number; + title?: string; + slug?: string; + } | null; + paymentRequired: false; + isDryRun?: boolean; + troubleshooterData?: Record; + luckyUsers?: number[]; + paymentUid?: string; + userPrimaryEmail?: string | null; + responses?: import("@prisma/client").Prisma.JsonValue | null; + references?: PartialReference[] | CreatedBooking["references"]; + seatReferenceUid?: string; + videoCallUrl?: string | null; + }; + +export type BookingFlowConfig = { + isDryRun: boolean; + useCacheIfEnabled: boolean; + noEmail: boolean; + hostname: string | null; + forcedSlug: string | null; +}; diff --git a/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts b/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts index 6b565ae4af96c7..2a59c9d1fee1c5 100644 --- a/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts +++ b/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts @@ -1,8 +1,7 @@ import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import prisma from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/enums"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; const log = logger.getSubLogger({ prefix: ["[checkActiveBookingsLimitForBooker]"] }); @@ -11,11 +10,13 @@ export const checkActiveBookingsLimitForBooker = async ({ maxActiveBookingsPerBooker, bookerEmail, offerToRescheduleLastBooking, + bookingRepository, }: { eventTypeId: number; maxActiveBookingsPerBooker: number | null; bookerEmail: string; offerToRescheduleLastBooking: boolean; + bookingRepository: BookingRepository; }) => { if (!maxActiveBookingsPerBooker) { return; @@ -26,9 +27,15 @@ export const checkActiveBookingsLimitForBooker = async ({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail, + bookingRepository, }); } else { - await checkActiveBookingsLimit({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail }); + await checkActiveBookingsLimit({ + eventTypeId, + maxActiveBookingsPerBooker, + bookerEmail, + bookingRepository, + }); } }; @@ -37,26 +44,16 @@ const checkActiveBookingsLimit = async ({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail, + bookingRepository, }: { eventTypeId: number; maxActiveBookingsPerBooker: number; bookerEmail: string; + bookingRepository: BookingRepository; }) => { - const bookingsCount = await prisma.booking.count({ - where: { - eventTypeId, - startTime: { - gte: new Date(), - }, - status: { - in: [BookingStatus.ACCEPTED], - }, - attendees: { - some: { - email: bookerEmail, - }, - }, - }, + const bookingsCount = await bookingRepository.countActiveBookingsForEventType({ + eventTypeId, + bookerEmail, }); if (bookingsCount >= maxActiveBookingsPerBooker) { @@ -69,40 +66,17 @@ const checkActiveBookingsLimitAndOfferReschedule = async ({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail, + bookingRepository, }: { eventTypeId: number; maxActiveBookingsPerBooker: number; bookerEmail: string; + bookingRepository: BookingRepository; }) => { - const bookingsCount = await prisma.booking.findMany({ - where: { - eventTypeId, - startTime: { - gte: new Date(), - }, - status: { - in: [BookingStatus.ACCEPTED], - }, - attendees: { - some: { - email: bookerEmail, - }, - }, - }, - orderBy: { - startTime: "desc", - }, - take: maxActiveBookingsPerBooker, - select: { - uid: true, - startTime: true, - attendees: { - select: { - name: true, - email: true, - }, - }, - }, + const bookingsCount = await bookingRepository.findActiveBookingsForEventType({ + eventTypeId, + bookerEmail, + limit: maxActiveBookingsPerBooker, }); const lastBooking = bookingsCount[bookingsCount.length - 1]; diff --git a/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts b/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts index 4f4aa17649c48c..5e051412a409b9 100644 --- a/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts +++ b/packages/features/bookings/lib/handleNewBooking/checkIfBookerEmailIsBlocked.ts @@ -1,13 +1,15 @@ import { extractBaseEmail } from "@calcom/lib/extract-base-email"; import { HttpError } from "@calcom/lib/http-error"; -import prisma from "@calcom/prisma"; +import type { UserRepository } from "@calcom/lib/server/repository/user"; export const checkIfBookerEmailIsBlocked = async ({ bookerEmail, loggedInUserId, + userRepository, }: { bookerEmail: string; loggedInUserId?: number; + userRepository: UserRepository; }) => { const baseEmail = extractBaseEmail(bookerEmail); const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS @@ -17,37 +19,11 @@ export const checkIfBookerEmailIsBlocked = async ({ const blacklistedEmail = blacklistedGuestEmails.find( (guestEmail: string) => guestEmail.toLowerCase() === baseEmail.toLowerCase() ); - if (!blacklistedEmail) { return false; } - const user = await prisma.user.findFirst({ - where: { - OR: [ - { - email: baseEmail, - emailVerified: { - not: null, - }, - }, - { - secondaryEmails: { - some: { - email: baseEmail, - emailVerified: { - not: null, - }, - }, - }, - }, - ], - }, - select: { - id: true, - email: true, - }, - }); + const user = await userRepository.findVerifiedUserByEmail({ email: baseEmail }); if (!user) { throw new HttpError({ statusCode: 403, message: "Cannot use this email to create the booking." }); diff --git a/packages/features/bookings/lib/handleNewBooking/test/getNewBookingHandler.ts b/packages/features/bookings/lib/handleNewBooking/test/getNewBookingHandler.ts index 4f037abc1c4e24..3ecae65c7cd575 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/getNewBookingHandler.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/getNewBookingHandler.ts @@ -1,7 +1,11 @@ // eslint-disable-next-line no-restricted-imports import type { BookingHandlerInput } from "@calcom/features/bookings/lib/dto/types"; -async function handler(input: BookingHandlerInput) { +async function handler( + input: { + bookingData: BookingHandlerInput["bookingData"]; + } & BookingHandlerInput["bookingMeta"] +) { const { getRegularBookingService } = await import( "@calcom/lib/di/bookings/containers/RegularBookingService.container" ); diff --git a/packages/features/bookings/lib/service/RecurringBookingService.ts b/packages/features/bookings/lib/service/RecurringBookingService.ts index 3a1cf759ba2c57..4a5b70127d8496 100644 --- a/packages/features/bookings/lib/service/RecurringBookingService.ts +++ b/packages/features/bookings/lib/service/RecurringBookingService.ts @@ -1,7 +1,6 @@ import type { CreateBookingMeta, CreateRecurringBookingData } from "@calcom/features/bookings/lib/dto/types"; import type { BookingResponse } from "@calcom/features/bookings/types"; import { SchedulingType } from "@calcom/prisma/enums"; -import type { AppsStatus } from "@calcom/types/Calendar"; import type { IBookingService } from "../interfaces/IBookingService"; import type { RegularBookingService } from "./RegularBookingService"; @@ -20,7 +19,6 @@ export const handleNewRecurringBooking = async ( const allRecurringDates: { start: string; end: string | undefined }[] = data.map((booking) => { return { start: booking.start, end: booking.end }; }); - const appsStatus: AppsStatus[] | undefined = undefined; const numSlotsToCheckForAvailability = 1; @@ -45,7 +43,6 @@ export const handleNewRecurringBooking = async ( if (isRoundRobin) { const recurringEventData = { ...firstBooking, - appsStatus, allRecurringDates, isFirstRecurringSlot: true, thirdPartyRecurringEventId, @@ -67,27 +64,8 @@ export const handleNewRecurringBooking = async ( for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) { const booking = data[key]; - // Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses - // It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email. - // It is more important to iterate forward and check for conflicts for only first few bookings defined by 'numSlotsToCheckForAvailability' - // if (key === 0) { - // const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings - // .flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : [])) - // .reduce((prev, curr) => { - // if (prev[curr.type]) { - // prev[curr.type].failures += curr.failures; - // prev[curr.type].success += curr.success; - // } else { - // prev[curr.type] = curr; - // } - // return prev; - // }, {} as { [key: string]: AppsStatus }); - // appsStatus = Object.values(calcAppsStatus); - // } - const recurringEventData = { ...booking, - appsStatus, allRecurringDates, isFirstRecurringSlot: key == 0, thirdPartyRecurringEventId, diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 47a08f37820334..f278a0633939b7 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -20,12 +20,6 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc import getICalUID from "@calcom/emails/lib/getICalUID"; import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager"; -import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types"; -import type { - CreateRegularBookingData, - CreateBookingMeta, - BookingHandlerInput, -} from "@calcom/features/bookings/lib/dto/types"; 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"; @@ -48,7 +42,6 @@ import { import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils"; -import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; import { enrichHostsWithDelegationCredentials, @@ -86,7 +79,6 @@ import { CreationSource, } from "@calcom/prisma/enums"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; -import { verifyCodeUnAuthenticated } from "@calcom/trpc/server/routers/viewer/auth/util"; import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { AdditionalInformation, @@ -98,18 +90,20 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import { BookingActionMap, BookingEmailSmsHandler } from "../BookingEmailSmsHandler"; +import type { + CreateRegularBookingData, + BookingHandlerInput, + CreateBookingMeta, + BookingDataSchemaGetter, +} from "../dto/types"; import { getAllCredentialsIncludeServiceAccountKey } from "../getAllCredentialsForUsersOnEvent/getAllCredentials"; import { refreshCredentials } from "../getAllCredentialsForUsersOnEvent/refreshCredentials"; import getBookingDataSchema from "../getBookingDataSchema"; import { addVideoCallDataToEvent } from "../handleNewBooking/addVideoCallDataToEvent"; -import { checkActiveBookingsLimitForBooker } from "../handleNewBooking/checkActiveBookingsLimitForBooker"; -import { checkIfBookerEmailIsBlocked } from "../handleNewBooking/checkIfBookerEmailIsBlocked"; import { createBooking } from "../handleNewBooking/createBooking"; import type { Booking } from "../handleNewBooking/createBooking"; import { ensureAvailableUsers } from "../handleNewBooking/ensureAvailableUsers"; -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"; @@ -122,10 +116,9 @@ import { getOriginalRescheduledBooking } from "../handleNewBooking/originalResch import type { BookingType } 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 { BookingValidationService } from "../utils/BookingValidationService"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); @@ -430,118 +423,90 @@ async function handler( deps: IBookingServiceDependencies, bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema ) { - const { - bookingData: rawBookingData, - userId, - platformClientId, - platformCancelUrl, - platformBookingUrl, - platformRescheduleUrl, - platformBookingLocation, - hostname, - forcedSlug, - areCalendarEventsEnabled = true, - } = input; + const { bookingData: rawBookingData, bookingMeta: rawBookingMeta } = input; const { prismaClient: prisma, - bookingRepository, cacheService, checkBookingAndDurationLimitsService, luckyUserService, + bookingRepository, } = deps; - const isPlatformBooking = !!platformClientId; - - const eventType = await getEventType({ - eventTypeId: rawBookingData.eventTypeId, - eventTypeSlug: rawBookingData.eventTypeSlug, - }); + const loggerWithEventDetails = createLoggerWithEventDetails( + rawBookingData.eventTypeId, + rawBookingData.user, + rawBookingData.eventTypeSlug || "" + ); - const bookingDataSchema = bookingDataSchemaGetter({ - view: rawBookingData.rescheduleUid ? "reschedule" : "booking", - bookingFields: eventType.bookingFields, + const bookingValidationService = new BookingValidationService({ + log: loggerWithEventDetails, + bookingRepository: deps.bookingRepository, + userRepository: deps.userRepository, }); - const bookingData = await getBookingData({ - reqBody: rawBookingData, + const { eventType, - schema: bookingDataSchema, - }); + bookingFormData, + recurringBookingData, + config: bookingFlowConfig, + loggedInUser, + bookingMeta, + hashedBookingLinkData, + teamOrUserSlug, + routingData, + seatsData, + } = await bookingValidationService.validate( + { + rawBookingData, + rawBookingMeta, + eventType: { + id: rawBookingData.eventTypeId, + slug: rawBookingData.eventTypeSlug || "", + }, + loggedInUserId: rawBookingMeta.userId ?? null, + }, + bookingDataSchemaGetter + ); + + const { isDryRun, useCacheIfEnabled, hostname, forcedSlug, noEmail } = bookingFlowConfig; 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; + booker, + rawBookingLocation, + additionalNotes: additionalNotes, + startTime: startTime, + endTime: endTime, + rawGuests: reqGuests, + rescheduleData, + } = bookingFormData; + + const luckyUsers = recurringBookingData.luckyUsers; + const isPlatformBooking = !!bookingMeta.platform?.clientId; + const { id: eventTypeId, slug: eventTypeSlug } = eventType; + // Hardcoded to null because it isn't being passed to _handler from anywhere + const reqAppsStatus = undefined; let troubleshooterData = buildTroubleshooterData({ eventType, }); - const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: loggerWithEventDetails }); - await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); - - if (!rawBookingData.rescheduleUid) { - await checkActiveBookingsLimitForBooker({ - eventTypeId, - maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker, - bookerEmail, - offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule, - }); - } - - if (eventType.requiresBookerEmailVerification) { - const verificationCode = reqBody.verificationCode; - if (!verificationCode) { - throw new HttpError({ - statusCode: 400, - message: "email_verification_required", - }); - } - - try { - await verifyCodeUnAuthenticated(bookerEmail, verificationCode); - } catch (error) { - throw new HttpError({ - statusCode: 400, - message: "invalid_verification_code", - }); - } - } - - if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { + if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: teamOrUserSlug ?? "" })) { logger.settings.minLevel = 0; } - const fullName = getFullName(bookerName); + const fullName = getFullName(booker.name); // Why are we only using "en" locale const tGuests = await getTranslation("en", "common"); - const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user); + const dynamicUserList = Array.isArray(teamOrUserSlug) + ? teamOrUserSlug + : getUsernameList(teamOrUserSlug ?? ""); if (!eventType) throw new HttpError({ statusCode: 404, message: "event_type_not_found" }); + // This is purely eventType validation and should ideally be done when creating the eventType. if (eventType.seatsPerTimeSlot && eventType.recurringEvent) { throw new HttpError({ statusCode: 400, @@ -549,10 +514,10 @@ async function handler( }); } - 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 + const bookingSeat = rescheduleData.rawUid ? await getSeatedBooking(rescheduleData.rawUid) : null; + const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : rescheduleData.rawUid ?? undefined; + const isNormalBookingOrFirstRecurringSlot = recurringBookingData.allRecurringDates + ? !!recurringBookingData.isFirstRecurringSlot : true; let originalRescheduledBooking = rescheduleUid @@ -566,11 +531,11 @@ async function handler( const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({ eventType, - bookingStartTime: reqBody.start, - userId, + bookingStartTime: startTime, + userId: loggedInUser.id ?? undefined, originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id, paymentAppData, - bookerEmail, + bookerEmail: booker.email, }); // For unconfirmed bookings or round robin bookings with the same attendee and timeslot, return the original booking @@ -582,9 +547,9 @@ async function handler( const existingBooking = await bookingRepository.getValidBookingFromEventTypeForAttendee({ eventTypeId, - bookerEmail, - bookerPhoneNumber, - startTime: new Date(dayjs(reqBody.start).utc().format()), + bookerEmail: booker.email, + bookerPhoneNumber: booker.phoneNumber ?? undefined, + startTime: new Date(dayjs(startTime).utc().format()), filterForUnconfirmed: !isConfirmedByDefault, }); @@ -607,6 +572,7 @@ async function handler( }; return { + _type: "existing", ...bookingResponse, luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [], isDryRun, @@ -617,25 +583,22 @@ async function handler( } } - const shouldServeCache = await cacheService.getShouldServeCache(_shouldServeCache, eventType.team?.id); - - const isTeamEventType = - !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType); + const shouldServeCache = await cacheService.getShouldServeCache(useCacheIfEnabled, eventType.team?.id); loggerWithEventDetails.info( `Booking eventType ${eventTypeId} started`, safeStringify({ reqBody: { - user: reqBody.user, + user: teamOrUserSlug, eventTypeId, eventTypeSlug, - startTime: reqBody.start, - endTime: reqBody.end, - rescheduleUid: reqBody.rescheduleUid, - location: location, - timeZone: reqBody.timeZone, + startTime, + endTime, + rescheduleUid: rescheduleData.rawUid, + location: rawBookingLocation, + timeZone: booker.timeZone, }, - isTeamEventType, + isTeamEventType: eventType.isTeamEventType, eventType: getPiiFreeEventType(eventType), dynamicUserList, paymentAppData: { @@ -648,47 +611,18 @@ async function handler( }) ); - 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 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; + const crmRecordId: string | undefined = routingData.crmRecordId ?? undefined; let routingFormResponse = null; - if (routedTeamMemberIds) { + if (routingData.routedTeamMemberIds) { //routingFormResponseId could be 0 for dry run. So, we just avoid undefined value - if (routingFormResponseId === undefined) { + if (routingData.routingFormResponseId === null) { throw new HttpError({ statusCode: 400, message: "Missing routingFormResponseId" }); } routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({ where: { - id: routingFormResponseId, + id: routingData.routingFormResponseId, }, select: { response: true, @@ -704,16 +638,16 @@ async function handler( } const { qualifiedRRUsers, additionalFallbackRRUsers, fixedUsers } = await loadAndValidateUsers({ - hostname, - forcedSlug, + hostname: hostname ?? undefined, + forcedSlug: forcedSlug ?? undefined, isPlatform: isPlatformBooking, eventType, eventTypeId, dynamicUserList, logger: loggerWithEventDetails, - routedTeamMemberIds: routedTeamMemberIds ?? null, - contactOwnerEmail, - rescheduleUid: reqBody.rescheduleUid || null, + routedTeamMemberIds: routingData.routedTeamMemberIds ?? null, + contactOwnerEmail: routingData.contactOwnerEmail ?? null, + rescheduleUid: rescheduleUid ?? null, routingFormResponse, }); @@ -725,13 +659,14 @@ async function handler( let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb({ dynamicUserList, users, - location, + location: rawBookingLocation, }); + // PhasesRefactor: This one fetches a lot of bookings to determine the limits being crossed, so it can't be part of validation phase await checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({ eventType, - reqBodyStart: reqBody.start, - reqBodyRescheduleUid: reqBody.rescheduleUid, + reqBodyStart: startTime, + reqBodyRescheduleUid: rescheduleUid, }); let luckyUserResponse; @@ -742,7 +677,7 @@ async function handler( const booking = await prisma.booking.findFirst({ where: { eventTypeId: eventType.id, - startTime: new Date(dayjs(reqBody.start).utc().format()), + startTime: new Date(dayjs(startTime).utc().format()), status: BookingStatus.ACCEPTED, }, select: { @@ -782,14 +717,14 @@ async function handler( ...(eventType.recurringEvent && { recurringEvent: { ...eventType.recurringEvent, - count: recurringCount || eventType.recurringEvent.count, + count: recurringBookingData.recurringCount || eventType.recurringEvent.count, }, }), }; if ( - input.bookingData.allRecurringDates && - input.bookingData.isFirstRecurringSlot && - input.bookingData.numSlotsToCheckForAvailability + recurringBookingData.allRecurringDates && + recurringBookingData.isFirstRecurringSlot && + recurringBookingData.numSlotsToCheckForAvailability ) { const isTeamEvent = eventType.schedulingType === SchedulingType.COLLECTIVE || @@ -801,21 +736,21 @@ async function handler( for ( let i = 0; - i < input.bookingData.allRecurringDates.length && - i < input.bookingData.numSlotsToCheckForAvailability; + i < recurringBookingData.allRecurringDates.length && + i < recurringBookingData.numSlotsToCheckForAvailability; i++ ) { - const start = input.bookingData.allRecurringDates[i].start; - const end = input.bookingData.allRecurringDates[i].end; + const start = recurringBookingData.allRecurringDates[i].start; + const end = recurringBookingData.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, + dateFrom: dayjs(start).tz(booker.timeZone).format(), + dateTo: dayjs(end).tz(booker.timeZone).format(), + timeZone: booker.timeZone, originalRescheduledBooking: originalRescheduledBooking ?? null, }, loggerWithEventDetails, @@ -827,9 +762,9 @@ async function handler( await ensureAvailableUsers( eventTypeWithUsers, { - dateFrom: dayjs(start).tz(reqBody.timeZone).format(), - dateTo: dayjs(end).tz(reqBody.timeZone).format(), - timeZone: reqBody.timeZone, + dateFrom: dayjs(start).tz(booker.timeZone).format(), + dateTo: dayjs(end).tz(booker.timeZone).format(), + timeZone: booker.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, @@ -839,14 +774,14 @@ async function handler( } } - if (!input.bookingData.allRecurringDates || input.bookingData.isFirstRecurringSlot) { + if (!recurringBookingData.allRecurringDates || recurringBookingData.isFirstRecurringSlot) { 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, + dateFrom: dayjs(startTime).tz(booker.timeZone).format(), + dateTo: dayjs(endTime).tz(booker.timeZone).format(), + timeZone: booker.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, @@ -869,9 +804,9 @@ async function handler( users: [...additionalFallbackRRUsers, ...fixedUsers] as IsFixedAwareUser[], }, { - dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(), - dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(), - timeZone: reqBody.timeZone, + dateFrom: dayjs(startTime).tz(booker.timeZone).format(), + dateTo: dayjs(endTime).tz(booker.timeZone).format(), + timeZone: booker.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, @@ -949,34 +884,34 @@ async function handler( ), eventType, routingFormResponse, - meetingStartTime: new Date(reqBody.start), + meetingStartTime: new Date(startTime), }); if (!newLuckyUser) { break; // prevent infinite loop } if ( - input.bookingData.isFirstRecurringSlot && + recurringBookingData.isFirstRecurringSlot && eventType.schedulingType === SchedulingType.ROUND_ROBIN && - input.bookingData.numSlotsToCheckForAvailability && - input.bookingData.allRecurringDates + recurringBookingData.numSlotsToCheckForAvailability && + recurringBookingData.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 < recurringBookingData.allRecurringDates.length && + i < recurringBookingData.numSlotsToCheckForAvailability; i++ ) { - const start = input.bookingData.allRecurringDates[i].start; - const end = input.bookingData.allRecurringDates[i].end; + const start = recurringBookingData.allRecurringDates[i].start; + const end = recurringBookingData.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, + dateFrom: dayjs(start).tz(booker.timeZone).format(), + dateTo: dayjs(end).tz(booker.timeZone).format(), + timeZone: booker.timeZone, originalRescheduledBooking, }, loggerWithEventDetails, @@ -1035,7 +970,7 @@ async function handler( .map((u) => u.id), }; } else if ( - input.bookingData.allRecurringDates && + recurringBookingData.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN ) { // all recurring slots except the first one @@ -1058,8 +993,8 @@ async function handler( } // 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] + const organizerUser = routingData.rawTeamMemberEmail + ? users.find((user) => user.email === routingData.rawTeamMemberEmail) ?? users[0] : users[0]; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); @@ -1068,11 +1003,11 @@ async function handler( // 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) + ? originalRescheduledBooking.attendees.find((attendee) => attendee.email === booker.email) : null; - const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : language; - const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone; + const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : booker.language; + const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : booker.timeZone; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); @@ -1099,7 +1034,7 @@ async function handler( if (organizerMetadata?.defaultConferencingApp?.appSlug) { const app = getAppFromSlug(organizerMetadata?.defaultConferencingApp?.appSlug); locationBodyString = app?.appData?.location?.type || locationBodyString; - if (isManagedEventType || isTeamEventType) { + if (isManagedEventType || eventType.isTeamEventType) { organizerOrFirstDynamicGroupMemberDefaultLocationUrl = organizerMetadata?.defaultConferencingApp?.appLink; } @@ -1112,29 +1047,29 @@ async function handler( const invitee: Invitee = [ { - email: bookerEmail, + email: booker.email, name: fullName, - phoneNumber: bookerPhoneNumber, - firstName: (typeof bookerName === "object" && bookerName.firstName) || "", - lastName: (typeof bookerName === "object" && bookerName.lastName) || "", + phoneNumber: booker.phoneNumber ?? undefined, + firstName: (typeof booker.name === "object" && booker.name.firstName) || "", + lastName: (typeof booker.name === "object" && booker.name.lastName) || "", timeZone: attendeeTimezone, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" }, }, ]; + // Guests blacklisted validation moved to quickValidation.ts const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS ? process.env.BLACKLISTED_GUEST_EMAILS.split(",") : []; - const guestsRemoved: string[] = []; - const guests = (reqGuests || []).reduce((guestArray, guest) => { + const guests = (reqGuests || []).reduce((guestArray: Invitee, guest: string) => { 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)) { + if (eventType.isTeamEventType && users.some((user) => user.email === guest)) { return guestArray; } guestArray.push({ @@ -1152,7 +1087,7 @@ async function handler( log.info("Removed guests from the booking", guestsRemoved); } - const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; + const seed = `${organizerUser.username}:${dayjs(startTime).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) @@ -1167,10 +1102,10 @@ async function handler( log.info("locationBodyString", locationBodyString); log.info("event type locations", eventType.locations); - const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); + const customInputs = getCustomInputsResponses(bookingFormData, eventType.customInputs); const attendeesList = [...invitee, ...guests]; - const responses = reqBody.responses || null; + const responses = bookingFormData.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. @@ -1182,7 +1117,7 @@ async function handler( // 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"), + eventDuration: dayjs(endTime).diff(startTime, "minutes"), bookingFields: { ...responses }, t: tOrganizer, }; @@ -1219,9 +1154,9 @@ async function handler( } //update cal event responses with latest location value , later used by webhook - if (reqBody.calEventResponses) - reqBody.calEventResponses["location"].value = { - value: platformBookingLocation ?? bookingLocation, + if (bookingFormData.calEventResponses) + bookingFormData.calEventResponses["location"].value = { + value: bookingMeta.platform?.bookingLocation ?? bookingLocation, optionValue: "", }; @@ -1231,8 +1166,8 @@ async function handler( .withBasicDetails({ bookerUrl, title: eventName, - startTime: dayjs(reqBody.start).utc().format(), - endTime: dayjs(reqBody.end).utc().format(), + startTime: dayjs(startTime).utc().format(), + endTime: dayjs(endTime).utc().format(), additionalNotes, }) .withEventType({ @@ -1264,11 +1199,11 @@ async function handler( .withMetadataAndResponses({ additionalNotes, customInputs, - responses: reqBody.calEventResponses || null, - userFieldsResponses: reqBody.calEventUserFieldsResponses || null, + responses: bookingFormData.calEventResponses || null, + userFieldsResponses: bookingFormData.calEventUserFieldsResponses || null, }) .withLocation({ - location: platformBookingLocation ?? bookingLocation, // Will be processed by the EventManager later. + location: bookingMeta.platform?.bookingLocation ?? bookingLocation, // Will be processed by the EventManager later. conferenceCredentialId, }) .withDestinationCalendar(destinationCalendar) @@ -1278,10 +1213,10 @@ async function handler( isConfirmedByDefault, }) .withPlatformVariables({ - platformClientId, - platformRescheduleUrl, - platformCancelUrl, - platformBookingUrl, + platformClientId: bookingMeta.platform?.clientId, + platformRescheduleUrl: bookingMeta.platform?.rescheduleUrl, + platformCancelUrl: bookingMeta.platform?.cancelUrl, + platformBookingUrl: bookingMeta.platform?.bookingUrl, }) .build(); @@ -1294,9 +1229,9 @@ async function handler( let evt: CalendarEvent = builtEvt; - if (input.bookingData.thirdPartyRecurringEventId) { + if (recurringBookingData?.thirdPartyRecurringEventId) { const updatedEvt = CalendarEventBuilder.fromEvent(evt) - ?.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId) + ?.withRecurringEventId(recurringBookingData?.thirdPartyRecurringEventId) .build(); if (!updatedEvt) { @@ -1309,7 +1244,7 @@ async function handler( evt = updatedEvt; } - if (isTeamEventType) { + if (eventType.isTeamEventType) { const teamEvt = await buildEventForTeamEventType({ existingEvent: evt, schedulingType: eventType.schedulingType, @@ -1331,7 +1266,7 @@ async function handler( eventDescription: eventType.description, price: paymentAppData.price, currency: eventType.currency, - length: dayjs(reqBody.end).diff(dayjs(reqBody.start), "minutes"), + length: dayjs(endTime).diff(startTime, "minutes"), }; const teamId = await getTeamIdFromEventType({ eventType }); @@ -1348,7 +1283,7 @@ async function handler( triggerEvent: WebhookTriggerEvents.BOOKING_CREATED, teamId, orgId, - oAuthClientId: platformClientId, + oAuthClientId: bookingMeta.platform?.clientId, }; const eventTrigger: WebhookTriggerEvents = rescheduleUid @@ -1363,7 +1298,7 @@ async function handler( triggerEvent: WebhookTriggerEvents.MEETING_ENDED, teamId, orgId, - oAuthClientId: platformClientId, + oAuthClientId: bookingMeta.platform?.clientId, }; const subscriberOptionsMeetingStarted = { @@ -1372,7 +1307,7 @@ async function handler( triggerEvent: WebhookTriggerEvents.MEETING_STARTED, teamId, orgId, - oAuthClientId: platformClientId, + oAuthClientId: bookingMeta.platform?.clientId, }; const workflows = await getAllWorkflowsFromEventType( @@ -1386,21 +1321,21 @@ async function handler( // 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, + rescheduleUid: rescheduleUid, + reqBookingUid: seatsData.bookingUid ?? undefined, eventType, evt: { ...evt, bookerUrl }, invitee, allCredentials, organizerUser, originalRescheduledBooking, - bookerEmail, - bookerPhoneNumber, + bookerEmail: booker.email, + bookerPhoneNumber: booker.phoneNumber, tAttendees, bookingSeat, - reqUserId: input.userId, - rescheduleReason, - reqBodyUser: reqBody.user, + reqUserId: loggedInUser.id ?? undefined, + rescheduleReason: rescheduleData.reason, + reqBodyUser: teamOrUserSlug ?? undefined, noEmail, isConfirmedByDefault, additionalNotes, @@ -1408,16 +1343,16 @@ async function handler( attendeeLanguage, paymentAppData, fullName, - smsReminderNumber, + smsReminderNumber: booker.smsReminderNumber ?? undefined, eventTypeInfo, uid, eventTypeId, - reqBodyMetadata: reqBody.metadata, + reqBodyMetadata: bookingFormData.metadata, subscriberOptions, eventTrigger, responses, workflows, - rescheduledBy: reqBody.rescheduledBy, + rescheduledBy: rescheduleData.rescheduledBy ?? undefined, isDryRun, }); @@ -1433,8 +1368,14 @@ async function handler( ...(isDryRun ? { troubleshooterData } : {}), }; return { + _type: "success" as const, ...bookingResponse, ...luckyUserResponse, + paymentRequired: false as const, + references: newBooking.references || [], + seatReferenceUid: evt.attendeeSeatId ?? "", + luckyUsers: luckyUserResponse?.luckyUsers || [], + status: newBooking.status || BookingStatus.ACCEPTED, }; } else { // Rescheduling logic for the original seated event was handled in handleSeats @@ -1459,10 +1400,12 @@ async function handler( } } - if (reqBody.recurringEventId && eventType.recurringEvent) { + if (recurringBookingData?.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 }); + eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { + count: recurringBookingData.recurringCount, + }); evt.recurringEvent = eventType.recurringEvent; } @@ -1513,13 +1456,13 @@ async function handler( if (!isDryRun) { booking = await createBooking({ uid, - rescheduledBy: reqBody.rescheduledBy, - routingFormResponseId: routingFormResponseId, - reroutingFormResponses: reroutingFormResponses ?? null, + rescheduledBy: rescheduleData.rescheduledBy ?? undefined, + routingFormResponseId: routingData.routingFormResponseId ?? undefined, + reroutingFormResponses: routingData.reroutingFormResponses ?? null, reqBody: { - user: reqBody.user, - metadata: reqBody.metadata, - recurringEventId: reqBody.recurringEventId, + user: teamOrUserSlug ?? undefined, + metadata: bookingFormData.metadata, + recurringEventId: recurringBookingData.recurringEventId ?? undefined, }, eventType: { eventTypeData: eventType, @@ -1530,15 +1473,15 @@ async function handler( paymentAppData, }, input: { - bookerEmail, - rescheduleReason, - smsReminderNumber, + bookerEmail: booker.email, + rescheduleReason: rescheduleData.reason ?? undefined, + smsReminderNumber: booker.smsReminderNumber ?? undefined, responses, }, evt, originalRescheduledBooking, - creationSource: input.bookingData.creationSource, - tracking: reqBody.tracking, + creationSource: bookingFormData.creationSource, + tracking: bookingFormData.tracking, }); if (booking?.userId) { @@ -1557,23 +1500,28 @@ async function handler( // 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) { + if ( + routingData.crmOwnerRecordType && + routingData.crmAppSlug && + routingData.contactOwnerEmail && + routingData.routingFormResponseId + ) { assignmentReason = await AssignmentReasonRecorder.CRMOwnership({ bookingId: booking.id, - crmAppSlug: reqBody.crmAppSlug, - teamMemberEmail: contactOwnerEmail, - recordType: reqBody.crmOwnerRecordType, - routingFormResponseId, + crmAppSlug: routingData.crmAppSlug, + teamMemberEmail: routingData.contactOwnerEmail, + recordType: routingData.crmOwnerRecordType, + routingFormResponseId: routingData.routingFormResponseId, recordId: crmRecordId, }); - } else if (routingFormResponseId && teamId) { + } else if (routingData.routingFormResponseId && teamId) { assignmentReason = await AssignmentReasonRecorder.routingFormRoute({ bookingId: booking.id, - routingFormResponseId, + routingFormResponseId: routingData.routingFormResponseId, organizerId: organizerUser.id, teamId, - isRerouting: !!reroutingFormResponses, - reroutedByEmail: reqBody.rescheduledBy, + isRerouting: !!routingData.reroutingFormResponses, + reroutedByEmail: rescheduleData.rescheduledBy ?? undefined, }); } } @@ -1607,9 +1555,9 @@ async function handler( 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) + attendee.email === bookingFormData.responses.email || + (bookingFormData.responses.attendeePhoneNumber && + attendee.phoneNumber === bookingFormData.responses.attendeePhoneNumber) ); // Save description to bookingSeat @@ -1619,9 +1567,9 @@ async function handler( referenceUid: uniqueAttendeeId, data: { description: additionalNotes, - responses, + responses: bookingFormData.responses, }, - metadata: reqBody.metadata, + metadata: bookingFormData.metadata, booking: { connect: { id: booking.id, @@ -1641,10 +1589,10 @@ async function handler( eventTypeId, organizerUser, eventName, - startTime: reqBody.start, - endTime: reqBody.end, - contactOwnerFromReq, - contactOwnerEmail, + startTime: startTime, + endTime: endTime, + contactOwnerFromReq: routingData.rawTeamMemberEmail ?? null, + contactOwnerEmail: routingData.contactOwnerEmail ?? null, allHostUsers: users, isManagedEventType, }); @@ -1684,7 +1632,7 @@ async function handler( await WorkflowRepository.deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders); evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); - evt.rescheduledBy = reqBody.rescheduledBy; + evt.rescheduledBy = rescheduleData.rescheduledBy ?? undefined; // If organizer is changed in RR event then we need to delete the previous host destination calendar events const previousHostDestinationCalendar = originalRescheduledBooking?.destinationCalendar @@ -1872,8 +1820,8 @@ async function handler( additionalNotes, iCalUID, originalRescheduledBooking, - rescheduleReason, - isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail, + rescheduleReason: rescheduleData.reason ?? undefined, + isRescheduledByBooker: rescheduleData.rescheduledBy === booker.email, users, changedOrganizer, }, @@ -1883,7 +1831,9 @@ async function handler( // Create a booking } else if (isConfirmedByDefault) { // Use EventManager to conditionally use all needed integrations. - const createManager = areCalendarEventsEnabled ? await eventManager.create(evt) : placeholderCreatedEvent; + const createManager = bookingMeta.areCalendarEventsEnabled + ? await eventManager.create(evt) + : placeholderCreatedEvent; if (evt.location) { booking.location = evt.location; } @@ -2057,11 +2007,11 @@ async function handler( rescheduleEndTime: originalRescheduledBooking?.endTime ? dayjs(originalRescheduledBooking?.endTime).utc().format() : undefined, - metadata: { ...metadata, ...reqBody.metadata }, + metadata: { ...metadata, ...bookingFormData.metadata }, eventTypeId, status: "ACCEPTED", smsReminderNumber: booking?.smsReminderNumber || undefined, - rescheduledBy: reqBody.rescheduledBy, + rescheduledBy: rescheduleData.rescheduledBy ?? undefined, ...(assignmentReason ? { assignmentReason: [assignmentReason] } : {}), }; @@ -2112,11 +2062,11 @@ async function handler( paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, booking, bookerName: fullName, - bookerEmail, - bookerPhoneNumber, + bookerEmail: booker.email, + bookerPhoneNumber: booker.phoneNumber, isDryRun, bookingFields: eventType.bookingFields, - locale: language, + locale: booker.language ?? undefined, }); const subscriberOptionsPaymentInitiated: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, @@ -2124,7 +2074,7 @@ async function handler( triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, teamId, orgId, - oAuthClientId: platformClientId, + oAuthClientId: bookingMeta.platform?.clientId, }; await handleWebhookTrigger({ subscriberOptions: subscriberOptionsPaymentInitiated, @@ -2139,7 +2089,7 @@ async function handler( try { const calendarEventForWorkflow = { ...evt, - rescheduleReason, + rescheduleReason: rescheduleData.reason ?? undefined, metadata, eventType: { slug: eventType.slug, @@ -2152,7 +2102,7 @@ async function handler( if (isNormalBookingOrFirstRecurringSlot) { await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ workflows, - smsReminderNumber: smsReminderNumber || null, + smsReminderNumber: booker.smsReminderNumber, calendarEvent: calendarEventForWorkflow, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, @@ -2176,11 +2126,12 @@ async function handler( email: null, }, videoCallUrl: metadata?.videoCallUrl, - // Ensure seatReferenceUid is properly typed as string | null - seatReferenceUid: evt.attendeeSeatId, + // Ensure seatReferenceUid is properly typed as string + seatReferenceUid: evt.attendeeSeatId ?? "", }; return { + _type: "payment_required" as const, ...bookingResponse, ...luckyUserResponse, message: "Payment required", @@ -2189,6 +2140,7 @@ async function handler( paymentId: payment?.id, isDryRun, ...(isDryRun ? { troubleshooterData } : {}), + luckyUsers: luckyUserResponse?.luckyUsers || [], }; } @@ -2220,7 +2172,7 @@ async function handler( if (booking && booking.status === BookingStatus.ACCEPTED) { const bookingWithCalEventResponses = { ...booking, - responses: reqBody.calEventResponses, + responses: bookingFormData.calEventResponses, }; for (const subscriber of subscribersMeetingEnded) { scheduleTriggerPromises.push( @@ -2284,8 +2236,8 @@ async function handler( try { const hashedLinkService = new HashedLinkService(); - if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) { - await hashedLinkService.validateAndIncrementUsage(reqBody.hashedLink as string); + if (hashedBookingLinkData?.hasHashedBookingLink && hashedBookingLinkData.hashedLink && !isDryRun) { + await hashedLinkService.validateAndIncrementUsage(hashedBookingLinkData.hashedLink as string); } } catch (error) { loggerWithEventDetails.error("Error while updating hashed link", JSON.stringify({ error })); @@ -2324,7 +2276,7 @@ async function handler( const evtWithMetadata = { ...evt, - rescheduleReason, + rescheduleReason: rescheduleData.reason ?? undefined, metadata, eventType: { slug: eventType.slug, schedulingType: eventType.schedulingType, hosts: eventType.hosts }, bookerUrl, @@ -2337,7 +2289,7 @@ async function handler( requiresConfirmation: !isConfirmedByDefault, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, - isPlatformNoEmail: noEmail && Boolean(platformClientId), + isPlatformNoEmail: noEmail && Boolean(bookingMeta.platform?.clientId), isDryRun, }); } @@ -2345,7 +2297,7 @@ async function handler( try { await WorkflowService.scheduleWorkflowsForNewBooking({ workflows, - smsReminderNumber: smsReminderNumber || null, + smsReminderNumber: booker.smsReminderNumber, calendarEvent: evtWithMetadata, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, @@ -2385,10 +2337,10 @@ async function handler( rawBookingData, bookingInfo: { name: fullName, - email: bookerEmail, + email: booker.email, eventName: "Cal.com lead", }, - isTeamEventType, + isTeamEventType: eventType.isTeamEventType, }); } @@ -2404,16 +2356,30 @@ async function handler( }; return { + _type: "success" as const, ...bookingResponse, ...luckyUserResponse, + paymentRequired: false as const, isDryRun, ...(isDryRun ? { troubleshooterData } : {}), references: referencesToCreate, - seatReferenceUid: evt.attendeeSeatId, + seatReferenceUid: evt.attendeeSeatId ?? "", videoCallUrl: metadata?.videoCallUrl, + luckyUsers: luckyUserResponse?.luckyUsers || [], }; } +export interface IBookingServiceDependencies { + cacheService: CacheService; + checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService; + prismaClient: PrismaClient; + bookingRepository: BookingRepository; + luckyUserService: LuckyUserService; + hostRepository: HostRepository; + oooRepository: OooRepository; + userRepository: UserRepository; + attributeRepository: AttributeRepository; +} /** * 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. @@ -2423,11 +2389,11 @@ export class RegularBookingService implements IBookingService { constructor(private readonly deps: IBookingServiceDependencies) {} async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { - return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); + return handler({ bookingData: input.bookingData, bookingMeta: input.bookingMeta ?? {} }, this.deps); } async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { - return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); + return handler({ bookingData: input.bookingData, bookingMeta: input.bookingMeta ?? {} }, this.deps); } /** @@ -2438,11 +2404,10 @@ export class RegularBookingService implements IBookingService { bookingMeta?: CreateBookingMeta; bookingDataSchemaGetter: BookingDataSchemaGetter; }) { - const bookingMeta = input.bookingMeta ?? {}; return handler( { bookingData: input.bookingData, - ...bookingMeta, + bookingMeta: input.bookingMeta ?? {}, }, this.deps, input.bookingDataSchemaGetter diff --git a/packages/features/bookings/lib/utils/BookingValidationService.test.ts b/packages/features/bookings/lib/utils/BookingValidationService.test.ts new file mode 100644 index 00000000000000..91e85db95c8268 --- /dev/null +++ b/packages/features/bookings/lib/utils/BookingValidationService.test.ts @@ -0,0 +1,930 @@ +import mockLogger from "@calcom/lib/__mocks__/logger"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import type { CreateBookingMeta, CreateRegularBookingData } from "../dto/types"; +import getBookingDataSchema from "../getBookingDataSchema"; +import { getEventType } from "../handleNewBooking/getEventType"; +import type { getEventTypeResponse } from "../handleNewBooking/getEventTypesFromDB"; +import { BookingValidationService } from "./BookingValidationService"; + +vi.mock("../handleNewBooking/getEventType", () => ({ + getEventType: vi.fn(), +})); + +vi.mock("../handleNewBooking/logger", () => ({ + createLoggerWithEventDetails: vi.fn().mockReturnValue(mockLogger.getSubLogger()), +})); + +// Test Data Builders +const createStandardBookingFields = () => [ + { + type: "name" as const, + name: "name", + editable: "system" as const, + defaultLabel: "your_name", + required: true, + sources: [ + { + label: "Default", + id: "default", + type: "default" as const, + }, + ], + }, + { + type: "email" as const, + name: "email", + defaultLabel: "email_address", + required: true, + editable: "system-but-optional" as const, + sources: [ + { + label: "Default", + id: "default", + type: "default" as const, + }, + ], + }, + { + type: "phone" as const, + name: "attendeePhoneNumber", + defaultLabel: "phone_number", + required: false, + hidden: true, + editable: "system-but-optional" as const, + sources: [ + { + label: "Default", + id: "default", + type: "default" as const, + }, + ], + }, + { + type: "radioInput" as const, + name: "location", + defaultLabel: "location", + editable: "system" as const, + hideWhenJustOneOption: true, + required: false, + getOptionsAt: "locations" as const, + optionsInputs: { + attendeeInPerson: { + type: "address" as const, + required: true, + placeholder: "", + }, + phone: { + type: "phone" as const, + required: true, + placeholder: "", + }, + }, + sources: [ + { + label: "Default", + id: "default", + type: "default" as const, + }, + ], + }, + { + type: "textarea" as const, + name: "notes", + defaultLabel: "additional_notes", + required: false, + editable: "system-but-optional" as const, + sources: [ + { + label: "Default", + id: "default", + type: "default" as const, + }, + ], + }, + { + type: "multiemail" as const, + name: "guests", + defaultLabel: "additional_guests", + required: false, + hidden: false, + editable: "system-but-optional" as const, + sources: [ + { + label: "Default", + id: "default", + type: "default" as const, + }, + ], + }, +]; + +const createMockEventType = (overrides?: Partial): getEventTypeResponse => { + const timestamp = Date.now(); + return { + id: 1, + slug: "test-event", + title: "Test Event", + length: 30, + userId: 101, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingFields: createStandardBookingFields() as any, // Proper booking fields structure + users: [ + { + id: 101, + email: `organizer-${timestamp}@example.com`, + name: "Test Organizer", + username: "organizer", + timeZone: "UTC", + defaultScheduleId: 1, + schedules: [ + { + id: 1, + timeZone: "UTC", + availability: [], + }, + ], + credentials: [], + allSelectedCalendars: [], + destinationCalendar: null, + locale: "en", + }, + ], + teamId: null, + team: null, + metadata: null, + locations: [], + customInputs: [], + schedule: { + id: 1, + timeZone: "UTC", + availability: [], + }, + timeZone: "UTC", + hosts: [], + owner: null, + workflows: [], + recurringEvent: null, + price: 0, + currency: "USD", + seatsPerTimeSlot: null, + seatsShowAttendees: false, + seatsShowAvailabilityCount: true, + periodType: "UNLIMITED", + periodStartDate: null, + periodEndDate: null, + periodDays: null, + periodCountCalendarDays: false, + requiresConfirmation: false, + requiresConfirmationForFreeEmail: false, + requiresBookerEmailVerification: false, + disableGuests: false, + minimumBookingNotice: 120, + maxActiveBookingsPerBooker: null, + maxActiveBookingPerBookerOfferReschedule: false, + beforeEventBuffer: 0, + afterEventBuffer: 0, + schedulingType: null, + bookingLimits: null, + durationLimits: null, + hideCalendarNotes: false, + hideCalendarEventDetails: false, + lockTimeZoneToggleOnBookingPage: false, + eventName: null, + successRedirectUrl: null, + description: "Test event description", + isDynamic: false, + assignAllTeamMembers: false, + isRRWeightsEnabled: false, + rescheduleWithSameRoundRobinHost: false, + parentId: null, + parent: null, + destinationCalendar: null, + useEventTypeDestinationCalendarEmail: false, + secondaryEmailId: null, + secondaryEmail: null, + availability: [], + lockedTimeZone: null, + useBookerTimezone: false, + assignRRMembersUsingSegment: false, + rrSegmentQueryValue: null, + useEventLevelSelectedCalendars: false, + hostGroups: [], + disableRescheduling: false, + disableCancelling: false, + restrictionScheduleId: null, + profile: { + organizationId: null, + }, + maxLeadThreshold: null, + includeNoShowInRRCalculation: false, + ...overrides, + }; +}; + +const createMockBookingData = (overrides?: Partial): CreateRegularBookingData => { + const timestamp = Date.now(); + const startTime = dayjs().add(1, "day").startOf("hour").add(10, "hours"); + const endTime = startTime.add(30, "minutes"); + + return { + eventTypeId: 1, + eventTypeSlug: "test-event", + timeZone: "UTC", + language: "en", + start: startTime.toISOString(), + end: endTime.toISOString(), + user: "organizer", + responses: { + name: "Test Booker", + email: `booker-${timestamp}@example.com`, + }, + metadata: {}, + ...overrides, + }; +}; + +const createMockBookingMeta = (overrides?: Partial): CreateBookingMeta => ({ + userId: 101, + hostname: "localhost", + ...overrides, +}); + +const createEventTypeWithTimezone = (timeZone: string): getEventTypeResponse => + createMockEventType({ + schedule: { + id: 1, + timeZone, + availability: [], + }, + }); + +const createEventTypeWithUserScheduleTimezone = (timeZone: string): getEventTypeResponse => + createMockEventType({ + schedule: null, + users: [ + { + ...createMockEventType().users[0], + schedules: [ + { + id: 1, + timeZone, + availability: [], + }, + ], + defaultScheduleId: 1, + }, + ], + }); + +const createEventTypeWithBookingLimits = ( + maxActiveBookingsPerBooker: number, + offerToRescheduleLastBooking = false +): getEventTypeResponse => + createMockEventType({ + maxActiveBookingsPerBooker, + maxActiveBookingPerBookerOfferReschedule: offerToRescheduleLastBooking, + }); + +const createPlatformBookingMeta = (): CreateBookingMeta => + createMockBookingMeta({ + platformClientId: "platform-123", + platformBookingUrl: "https://platform.example.com/booking", + platformRescheduleUrl: "https://platform.example.com/reschedule", + platformCancelUrl: "https://platform.example.com/cancel", + areCalendarEventsEnabled: true, + }); + +const createBookingDataWithTime = (startTime: dayjs.Dayjs, durationMinutes = 30): CreateRegularBookingData => + createMockBookingData({ + start: startTime.toISOString(), + end: startTime.add(durationMinutes, "minutes").toISOString(), + }); + +const createBookingDataWithGuests = (guests: string[]): CreateRegularBookingData => + createMockBookingData({ + responses: { + ...createMockBookingData().responses, + guests, + }, + }); + +const createBookingDataWithNotes = (notes: string): CreateRegularBookingData => + createMockBookingData({ + responses: { + ...createMockBookingData().responses, + notes, + }, + }); + +const createBookingDataWithRouting = ( + routedTeamMemberIds: number[], + teamMemberEmail: string +): CreateRegularBookingData => + createMockBookingData({ + routedTeamMemberIds, + teamMemberEmail, + }); + +const createRescheduleBookingData = (rescheduleUid: string): CreateRegularBookingData => + createMockBookingData({ + rescheduleUid, + }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createEventTypeWithCustomBookingFields = (customFields: any[] = []): getEventTypeResponse => + createMockEventType({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingFields: [...createStandardBookingFields(), ...customFields] as any, + }); + +const createEventTypeWithOptionalEmail = (): getEventTypeResponse => { + const bookingFields = createStandardBookingFields(); + // Make email field optional + const emailField = bookingFields.find((field) => field.name === "email"); + if (emailField) { + emailField.required = false; + } + return createMockEventType({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingFields: bookingFields as any, + }); +}; + +const createEventTypeWithRequiredNotes = (): getEventTypeResponse => { + const bookingFields = createStandardBookingFields(); + // Make notes field required + const notesField = bookingFields.find((field) => field.name === "notes"); + if (notesField) { + notesField.required = true; + } + return createMockEventType({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingFields: bookingFields as any, + }); +}; + +// Date helpers +const daysFromNow = (days: number) => dayjs().add(days, "days"); +const hoursAgo = (hours: number) => dayjs().subtract(hours, "hours"); + +// Assertion helpers +const expectValidationResult = (result: unknown) => { + expect(result).toHaveProperty("eventType"); + expect(result).toHaveProperty("bookingFormData"); + expect(result).toHaveProperty("loggedInUser"); + expect(result).toHaveProperty("routingData"); + expect(result).toHaveProperty("bookingMeta"); + expect(result).toHaveProperty("config"); + expect(result).toHaveProperty("recurringBookingData"); + expect(result).toHaveProperty("seatsData"); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const expectBookerDetails = (result: any, expectedName: string, expectedEmail: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any).bookingFormData.booker.name).toBe(expectedName); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any).bookingFormData.booker.email).toBe(expectedEmail); +}; + +const expectRepositoryCallsForValidation = ( + eventTypeId: number, + eventTypeSlug: string, + bookerEmail: string, + loggedInUserId?: number +) => { + expect(mockGetEventType).toHaveBeenCalledWith({ + eventTypeId, + eventTypeSlug, + }); + + // Email blocking only checks repository if email is blacklisted + // For regular test emails, this won't be called +}; + +const expectActiveBookingsLimitCheck = ( + eventTypeId: number, + bookerEmail: string, + offerToRescheduleLastBooking = true +) => { + if (offerToRescheduleLastBooking) { + // When offering to reschedule, it calls findActiveBookingsForEventType + expect(mockBookingRepository.findActiveBookingsForEventType).toHaveBeenCalledWith({ + eventTypeId, + bookerEmail, + limit: expect.any(Number), + }); + } else { + // Otherwise it calls countActiveBookingsForEventType + expect(mockBookingRepository.countActiveBookingsForEventType).toHaveBeenCalledWith({ + eventTypeId, + bookerEmail, + }); + } +}; + +const expectNoActiveBookingsLimitCheck = () => { + expect(mockBookingRepository.countActiveBookingsForEventType).not.toHaveBeenCalled(); +}; + +// Mock Repositories +const mockBookingRepository = { + countActiveBookingsForEventType: vi.fn(), + findActiveBookingsForEventType: vi.fn(), +} as any; + +const mockUserRepository = { + findVerifiedUserByEmail: vi.fn(), +} as any; + +const mockGetEventType = vi.mocked(getEventType); + +describe("BookingValidationService", () => { + let bookingValidationService: BookingValidationService; + + let bookingTestData: { + standardEventType: getEventTypeResponse; + bookingData: CreateRegularBookingData; + bookingMeta: CreateBookingMeta; + }; + + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingValidationService = new BookingValidationService({ + log: mockLogger as any, + bookingRepository: mockBookingRepository, + userRepository: mockUserRepository, + }); + + bookingTestData = { + standardEventType: createMockEventType(), + bookingData: createMockBookingData(), + bookingMeta: createMockBookingMeta(), + }; + + mockGetEventType.mockResolvedValue(bookingTestData.standardEventType); + mockUserRepository.findVerifiedUserByEmail.mockResolvedValue(null); // No blocked user found + mockBookingRepository.countActiveBookingsForEventType.mockResolvedValue(0); // No active bookings + mockBookingRepository.findActiveBookingsForEventType.mockResolvedValue([]); // No active bookings for reschedule + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("when validating a standard booking", () => { + it("should successfully validate and return complete booking details", async () => { + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expectValidationResult(result); + expectBookerDetails(result, "Test Booker", bookingTestData.bookingData.responses.email); + expectRepositoryCallsForValidation(1, "test-event", bookingTestData.bookingData.responses.email); + expect(result.eventType.id).toBe(1); + expect(result.loggedInUser.id).toBeNull(); + }); + + it("should validate booking with logged in user context", async () => { + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: 123, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.loggedInUser.id).toBe(123); + expectRepositoryCallsForValidation(1, "test-event", bookingTestData.bookingData.responses.email, 123); + }); + + it("should handle booking with guest attendees", async () => { + const bookingDataWithGuests = createBookingDataWithGuests(["guest1@example.com", "guest2@example.com"]); + const context = { + rawBookingData: bookingDataWithGuests, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.bookingFormData.rawGuests).toEqual(["guest1@example.com", "guest2@example.com"]); + }); + + it("should handle booking with additional notes", async () => { + const bookingDataWithNotes = createBookingDataWithNotes( + "This is a test booking with special requirements" + ); + const context = { + rawBookingData: bookingDataWithNotes, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.bookingFormData.additionalNotes).toBe("This is a test booking with special requirements"); + }); + + it("should handle booking with team routing data", async () => { + const bookingDataWithRouting = createBookingDataWithRouting([101, 102], "team@example.com"); + const context = { + rawBookingData: bookingDataWithRouting, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.routingData.routedTeamMemberIds).toEqual([101, 102]); + expect(result.routingData.rawTeamMemberEmail).toBe("team@example.com"); + }); + }); + + describe("when validating platform bookings", () => { + it("should properly map platform booking metadata", async () => { + const platformMeta = createPlatformBookingMeta(); + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: platformMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.bookingMeta.areCalendarEventsEnabled).toBe(true); + expect(result.bookingMeta.platform).toEqual({ + clientId: "platform-123", + bookingUrl: "https://platform.example.com/booking", + rescheduleUrl: "https://platform.example.com/reschedule", + cancelUrl: "https://platform.example.com/cancel", + bookingLocation: null, + }); + }); + }); + + describe("when handling timezone configurations", () => { + it("should use event type schedule timezone when available", async () => { + const eventTypeWithTimezone = createEventTypeWithTimezone("America/New_York"); + mockGetEventType.mockResolvedValue(eventTypeWithTimezone); + + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.eventType.timeZone).toBe("America/New_York"); + }); + + it("should fall back to user default schedule timezone when event type has no schedule", async () => { + const eventTypeWithUserTimezone = createEventTypeWithUserScheduleTimezone("Europe/London"); + mockGetEventType.mockResolvedValue(eventTypeWithUserTimezone); + + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + + expect(result.eventType.timeZone).toBe("Europe/London"); + }); + }); + + describe("when checking active booking limits", () => { + it("should check active booking limits for new bookings when limits are configured", async () => { + const eventTypeWithLimits = createEventTypeWithBookingLimits(2, true); + mockGetEventType.mockResolvedValue(eventTypeWithLimits); + + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await bookingValidationService.validate(context, getBookingDataSchema); + + expectActiveBookingsLimitCheck(1, bookingTestData.bookingData.responses.email); + }); + + it("should skip active booking limit check for rescheduled bookings", async () => { + const eventTypeWithLimits = createEventTypeWithBookingLimits(2, true); + mockGetEventType.mockResolvedValue(eventTypeWithLimits); + + const rescheduleBookingData = createRescheduleBookingData("existing-booking-uid"); + const context = { + rawBookingData: rescheduleBookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await bookingValidationService.validate(context, getBookingDataSchema); + + expectNoActiveBookingsLimitCheck(); + }); + }); + + describe("when validating booking time constraints", () => { + it("should reject bookings scheduled in the past", async () => { + const pastBookingData = createBookingDataWithTime(hoursAgo(1)); + const context = { + rawBookingData: pastBookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow(); + }); + + it("should reject bookings with invalid duration", async () => { + const invalidDurationBookingData = createBookingDataWithTime(daysFromNow(1), 90); // 90 minutes for 30-minute event + const context = { + rawBookingData: invalidDurationBookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow( + "Invalid event length" + ); + }); + }); + + describe("when encountering validation errors", () => { + it("should throw error when event type is not found", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGetEventType.mockResolvedValue(null as any); + + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow( + "Event type not found" + ); + }); + + it("should propagate repository connection errors", async () => { + const repositoryError = new Error("Database connection failed"); + mockGetEventType.mockRejectedValue(repositoryError); + + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow( + "Database connection failed" + ); + }); + + it("should handle blocked email validation errors", async () => { + // Set up environment to simulate blacklisted email + const originalBlacklistedEmails = process.env.BLACKLISTED_GUEST_EMAILS; + process.env.BLACKLISTED_GUEST_EMAILS = bookingTestData.bookingData.responses.email; + + // Mock user repository to return null (user not found, should block) + mockUserRepository.findVerifiedUserByEmail.mockResolvedValue(null); + + const context = { + rawBookingData: bookingTestData.bookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow( + "Cannot use this email to create the booking." + ); + + // Restore original environment + if (originalBlacklistedEmails) { + process.env.BLACKLISTED_GUEST_EMAILS = originalBlacklistedEmails; + } else { + delete process.env.BLACKLISTED_GUEST_EMAILS; + } + }); + + it("should handle booking limit exceeded errors", async () => { + // Set up event type with booking limits + const eventTypeWithLimits = createEventTypeWithBookingLimits(1, false); // Max 1 booking + mockGetEventType.mockResolvedValue(eventTypeWithLimits); + + // Mock repository to return that limit is already reached + mockBookingRepository.countActiveBookingsForEventType.mockResolvedValue(1); + + const context = { + rawBookingData: { + ...bookingTestData.bookingData, + rescheduleUid: undefined, // Ensure it's a new booking to trigger limit check + }, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow(); + }); + + it("should reject booking data missing required fields", async () => { + const incompleteBookingData = createMockBookingData({ + responses: { + email: "test@example.com", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }); + + const context = { + rawBookingData: incompleteBookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow(); + }); + + it("should reject invalid email format with proper booking fields", async () => { + // Now that we have proper booking fields, email validation should work + const invalidBookingData = createMockBookingData({ + responses: { + name: "Test Booker", + email: "invalid-email-format", + }, + }); + + const context = { + rawBookingData: invalidBookingData, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow(); + }); + }); + + describe("when handling different booking field configurations", () => { + it("should validate booking with optional email field when phone is provided", async () => { + const eventTypeWithOptionalEmail = createEventTypeWithOptionalEmail(); + mockGetEventType.mockResolvedValue(eventTypeWithOptionalEmail); + + // Cal.com requires at least one contact method (email OR phone) + // When email is optional and empty, we need to provide a phone number + const bookingDataWithOptionalEmail = createMockBookingData({ + responses: { + name: "Test Booker", + email: "", // Empty email when it's optional + attendeePhoneNumber: "+1234567890", // But phone number is provided + }, + }); + + const context = { + rawBookingData: bookingDataWithOptionalEmail, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + // Should succeed because we have phone as contact method when email is optional + const result = await bookingValidationService.validate(context, getBookingDataSchema); + expectValidationResult(result); + + // Cal.com automatically generates an email from phone number when email is empty + expect(result.bookingFormData.booker.email).toBe("1234567890@sms.cal.com"); + expect(result.bookingFormData.booker.phoneNumber).toBe("+1234567890"); + }); + + it("should validate booking with required notes field", async () => { + const eventTypeWithRequiredNotes = createEventTypeWithRequiredNotes(); + mockGetEventType.mockResolvedValue(eventTypeWithRequiredNotes); + + const bookingDataWithNotes = createMockBookingData({ + responses: { + name: "Test Booker", + email: "test@example.com", + notes: "This is a required note", + }, + }); + + const context = { + rawBookingData: bookingDataWithNotes, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + expectValidationResult(result); + expect(result.bookingFormData.additionalNotes).toBe("This is a required note"); + }); + + it("should reject booking missing required notes field", async () => { + const eventTypeWithRequiredNotes = createEventTypeWithRequiredNotes(); + mockGetEventType.mockResolvedValue(eventTypeWithRequiredNotes); + + const bookingDataWithoutNotes = createMockBookingData({ + responses: { + name: "Test Booker", + email: "test@example.com", + // Missing required notes + }, + }); + + const context = { + rawBookingData: bookingDataWithoutNotes, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow(); + }); + + it("should reject booking when both email and phone are missing", async () => { + const eventTypeWithOptionalEmail = createEventTypeWithOptionalEmail(); + mockGetEventType.mockResolvedValue(eventTypeWithOptionalEmail); + + // Cal.com business rule: at least one contact method (email OR phone) must be provided + const bookingDataWithNoContact = createMockBookingData({ + responses: { + name: "Test Booker", + email: "", // Empty email + // No phone number provided either + }, + }); + + const context = { + rawBookingData: bookingDataWithNoContact, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + await expect(bookingValidationService.validate(context, getBookingDataSchema)).rejects.toThrow( + "Both Phone and Email are missing" + ); + }); + + it("should handle event type with custom booking fields", async () => { + const customField = { + type: "text", + name: "customField", + label: "Custom Field", + required: true, + sources: [{ id: "default", type: "default", label: "Default" }], + }; + + const eventTypeWithCustomFields = createEventTypeWithCustomBookingFields([customField]); + mockGetEventType.mockResolvedValue(eventTypeWithCustomFields); + + const bookingDataWithCustomField = createMockBookingData({ + responses: { + name: "Test Booker", + email: "test@example.com", + customField: "Custom field value", + }, + }); + + const context = { + rawBookingData: bookingDataWithCustomField, + rawBookingMeta: bookingTestData.bookingMeta, + eventType: { id: 1, slug: "test-event" }, + loggedInUserId: null, + }; + + const result = await bookingValidationService.validate(context, getBookingDataSchema); + expectValidationResult(result); + }); + }); +}); diff --git a/packages/features/bookings/lib/utils/BookingValidationService.ts b/packages/features/bookings/lib/utils/BookingValidationService.ts new file mode 100644 index 00000000000000..4cd72c3fcd8e41 --- /dev/null +++ b/packages/features/bookings/lib/utils/BookingValidationService.ts @@ -0,0 +1,340 @@ +import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; +import { HttpError } from "@calcom/lib/http-error"; +import type logger from "@calcom/lib/logger"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; +import type { UserRepository } from "@calcom/lib/server/repository/user"; +import { verifyCodeUnAuthenticated } from "@calcom/trpc/server/routers/viewer/auth/util"; + +import type { + BookingDataSchemaGetter, + BookingFlowConfig, + CreateBookingMeta, + CreateRegularBookingData, +} from "../dto/types"; +import { checkActiveBookingsLimitForBooker } from "../handleNewBooking/checkActiveBookingsLimitForBooker"; +import { checkIfBookerEmailIsBlocked } from "../handleNewBooking/checkIfBookerEmailIsBlocked"; +import { getBookingData } from "../handleNewBooking/getBookingData"; +import { getEventType } from "../handleNewBooking/getEventType"; +import type { getEventTypeResponse } from "../handleNewBooking/getEventTypesFromDB"; +import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validateBookingTimeIsNotOutOfBounds"; +import { validateEventLength } from "../handleNewBooking/validateEventLength"; + +type BookingData = Awaited>; +export type EnrichmentInput = { + eventTypeId: number; + eventTypeSlug: string; +}; + +type EnrichedEventType = getEventTypeResponse & { + isTeamEventType: boolean; +}; + +export type EnrichmentOutput = { + eventType: EnrichedEventType; +}; + +export type ValidationInputContext = EnrichmentInput & { + rawBookingData: CreateRegularBookingData; + userId?: number; + eventTimeZone?: string; +}; + +export type BookingFormData = { + booker: { + name: string | { firstName: string; lastName?: string }; + phoneNumber: string | null; + email: string; + timeZone: string; + smsReminderNumber: string | null; + language: string | null; + }; + rawBookingLocation: string; + additionalNotes: string; + startTime: string; + endTime: string; + rawGuests: string[] | null; + responses: BookingData["responses"]; + rescheduleData: { + reason: string | null; + rawUid: string | null; + rescheduledBy: string | null; + }; + customInputs: CreateRegularBookingData["customInputs"]; + calEventResponses: BookingData["calEventResponses"]; + calEventUserFieldsResponses: BookingData["calEventUserFieldsResponses"]; + metadata: CreateRegularBookingData["metadata"]; + creationSource: CreateRegularBookingData["creationSource"]; + tracking: CreateRegularBookingData["tracking"]; +}; +export type ValidationOutput = { + eventType: EnrichedEventType; + bookingFormData: BookingFormData; + loggedInUser: { + id: number | null; + }; + routingData: { + routedTeamMemberIds: number[] | null; + reroutingFormResponses: CreateRegularBookingData["reroutingFormResponses"] | null; + routingFormResponseId: number | null; + rawTeamMemberEmail: string | null; + crmRecordId: string | null; + crmOwnerRecordType: string | null; + crmAppSlug: string | null; + skipContactOwner: boolean | null; + contactOwnerEmail: string | null; + }; + bookingMeta: { + areCalendarEventsEnabled: boolean; + platform: { + clientId: string | null; + rescheduleUrl: string | null; + cancelUrl: string | null; + bookingUrl: string | null; + bookingLocation: string | null; + } | null; + }; + config: BookingFlowConfig; + hashedBookingLinkData: { + hasHashedBookingLink: boolean; + hashedLink: string | null; + } | null; + recurringBookingData: { + luckyUsers: number[]; + recurringCount: number; + allRecurringDates: { start: string; end?: string }[] | null; + isFirstRecurringSlot: boolean; + numSlotsToCheckForAvailability: number; + recurringEventId: string | null; + thirdPartyRecurringEventId: string | null; + }; + teamOrUserSlug: string | string[] | null; + seatsData: { + bookingUid: string | null; + }; +}; + +export interface IBookingValidationServiceDependencies { + log: typeof logger; + bookingRepository: BookingRepository; + userRepository: UserRepository; +} + +export class BookingValidationService { + private readonly log: typeof logger; + private readonly bookingRepository: BookingRepository; + private readonly userRepository: UserRepository; + + constructor(deps: IBookingValidationServiceDependencies) { + this.log = deps.log; + this.bookingRepository = deps.bookingRepository; + this.userRepository = deps.userRepository; + } + + private async enrich({ eventTypeId, eventTypeSlug }: EnrichmentInput): Promise { + const eventType = await getEventType({ + eventTypeId, + eventTypeSlug, + }); + + if (!eventType) { + throw new Error("Event type not found"); + } + + 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 ?? null; + + return { + eventType: { + ...eventType, + isTeamEventType: + !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType), + timeZone: eventTimeZone, + }, + }; + } + + async validate( + context: { + rawBookingData: CreateRegularBookingData; + rawBookingMeta: CreateBookingMeta; + eventType: { + id: number; + slug: string; + }; + loggedInUserId: number | null; + }, + bookingDataSchemaGetter: BookingDataSchemaGetter + ): Promise { + const { rawBookingData, eventType: _eventType, loggedInUserId, rawBookingMeta } = context; + const { eventType } = await this.enrich({ + eventTypeId: _eventType.id, + eventTypeSlug: _eventType.slug, + }); + + const bookingDataSchema = bookingDataSchemaGetter({ + view: rawBookingData.rescheduleUid ? "reschedule" : "booking", + bookingFields: eventType.bookingFields, + }); + + const bookingData = await getBookingData({ + reqBody: rawBookingData, + eventType, + schema: bookingDataSchema, + }); + + const bookingFormData: BookingFormData = { + booker: { + name: bookingData.name, + email: bookingData.email, + phoneNumber: bookingData.attendeePhoneNumber ?? null, + timeZone: bookingData.timeZone, + smsReminderNumber: bookingData.smsReminderNumber ?? null, + language: bookingData.language ?? null, + }, + rawBookingLocation: bookingData.location, + additionalNotes: bookingData.notes ?? "", + startTime: bookingData.start, + endTime: bookingData.end, + rawGuests: bookingData.guests ?? null, + rescheduleData: { + reason: bookingData.rescheduleReason ?? null, + rawUid: bookingData.rescheduleUid ?? null, + rescheduledBy: bookingData.rescheduledBy ?? null, + }, + responses: bookingData.responses, + calEventResponses: bookingData.calEventResponses, + calEventUserFieldsResponses: bookingData.calEventUserFieldsResponses, + customInputs: bookingData.customInputs, + metadata: bookingData.metadata, + creationSource: bookingData.creationSource, + tracking: bookingData.tracking, + }; + + const bookingEventUserOrTeamSlug = bookingData.user; + + const { + booker: { email: bookerEmail, timeZone: bookerTimeZone }, + startTime, + endTime, + } = bookingFormData; + + await validateBookingTimeIsNotOutOfBounds( + startTime, + bookerTimeZone, + eventType, + eventType.timeZone, + this.log + ); + + validateEventLength({ + reqBodyStart: startTime, + reqBodyEnd: endTime, + eventTypeMultipleDuration: eventType.metadata?.multipleDuration, + eventTypeLength: eventType.length, + logger: this.log, + }); + + await checkIfBookerEmailIsBlocked({ + loggedInUserId: loggedInUserId ?? undefined, + bookerEmail, + userRepository: this.userRepository, + }); + + if (!rawBookingData.rescheduleUid) { + await checkActiveBookingsLimitForBooker({ + eventTypeId: eventType.id, + maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker, + bookerEmail, + offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule, + bookingRepository: this.bookingRepository, + }); + } + + if (eventType.requiresBookerEmailVerification) { + const verificationCode = rawBookingData.verificationCode; + if (!verificationCode) { + throw new HttpError({ + statusCode: 400, + message: "email_verification_required", + }); + } + + try { + await verifyCodeUnAuthenticated(bookerEmail, verificationCode); + } catch (error) { + throw new HttpError({ + statusCode: 400, + message: "invalid_verification_code", + }); + } + } + + const _shouldIgnoreContactOwner = shouldIgnoreContactOwner({ + skipContactOwner: bookingData.skipContactOwner ?? null, + rescheduleUid: bookingData.rescheduleUid ?? null, + routedTeamMemberIds: bookingData.routedTeamMemberIds ?? null, + }); + const contactOwnerEmail = _shouldIgnoreContactOwner ? null : bookingData.teamMemberEmail; + + return { + eventType, + bookingFormData, + loggedInUser: { + id: loggedInUserId, + }, + routingData: { + routedTeamMemberIds: bookingData.routedTeamMemberIds ?? null, + reroutingFormResponses: bookingData.reroutingFormResponses, + routingFormResponseId: bookingData.routingFormResponseId ?? null, + rawTeamMemberEmail: bookingData.teamMemberEmail ?? null, + crmRecordId: bookingData.crmRecordId ?? null, + crmOwnerRecordType: bookingData.crmOwnerRecordType ?? null, + crmAppSlug: bookingData.crmAppSlug ?? null, + skipContactOwner: bookingData.skipContactOwner ?? null, + contactOwnerEmail: contactOwnerEmail ?? null, + }, + bookingMeta: { + areCalendarEventsEnabled: rawBookingMeta.areCalendarEventsEnabled ?? true, + platform: + rawBookingMeta.platformClientId || + rawBookingMeta.platformRescheduleUrl || + rawBookingMeta.platformCancelUrl || + rawBookingMeta.platformBookingUrl || + rawBookingMeta.platformBookingLocation + ? { + clientId: rawBookingMeta.platformClientId ?? null, + rescheduleUrl: rawBookingMeta.platformRescheduleUrl ?? null, + cancelUrl: rawBookingMeta.platformCancelUrl ?? null, + bookingUrl: rawBookingMeta.platformBookingUrl ?? null, + bookingLocation: rawBookingMeta.platformBookingLocation ?? null, + } + : null, + }, + config: { + isDryRun: !!bookingData._isDryRun, + noEmail: !!rawBookingMeta.noEmail, + hostname: rawBookingMeta.hostname ?? null, + forcedSlug: rawBookingMeta.forcedSlug ?? null, + useCacheIfEnabled: bookingData._shouldServeCache ?? false, + }, + hashedBookingLinkData: { + hasHashedBookingLink: bookingData.hasHashedBookingLink ?? false, + hashedLink: bookingData.hashedLink ?? null, + }, + recurringBookingData: { + luckyUsers: bookingData.luckyUsers ?? [], + allRecurringDates: bookingData.allRecurringDates ?? null, + recurringCount: bookingData.recurringCount ?? 0, + isFirstRecurringSlot: bookingData.isFirstRecurringSlot ?? false, + numSlotsToCheckForAvailability: bookingData.numSlotsToCheckForAvailability ?? 0, + recurringEventId: bookingData.recurringEventId ?? null, + thirdPartyRecurringEventId: bookingData.thirdPartyRecurringEventId ?? null, + }, + seatsData: { + bookingUid: bookingData.bookingUid ?? null, + }, + teamOrUserSlug: bookingEventUserOrTeamSlug ?? null, + }; + } +} diff --git a/packages/lib/di/tokens.ts b/packages/lib/di/tokens.ts index 9936078b304d5a..db03b32dc955c1 100644 --- a/packages/lib/di/tokens.ts +++ b/packages/lib/di/tokens.ts @@ -55,4 +55,9 @@ export const DI_TOKENS = { ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"), // Booking service tokens ...BOOKING_DI_TOKENS, + BOOKING_CREATE_FACTORY: Symbol("BookingCreateFactory"), + BOOKING_CREATE_FACTORY_MODULE: Symbol("BookingCreateFactoryModule"), + QUICK_ENRICHMENT_SERVICE: Symbol("QuickEnrichmentService"), + QUICK_VALIDATION_SERVICE: Symbol("QuickValidationService"), + BOOKING_DATA_SCHEMA_GETTER: Symbol("BookingDataSchemaGetter"), }; diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 67adbf554c9e12..fa9d617f9b52b3 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -132,6 +132,78 @@ export class BookingRepository { }); } + /** + * Counts active bookings for an event type by booker email + */ + async countActiveBookingsForEventType({ + eventTypeId, + bookerEmail, + }: { + eventTypeId: number; + bookerEmail: string; + }) { + return await this.prismaClient.booking.count({ + where: { + eventTypeId, + startTime: { + gte: new Date(), + }, + status: { + in: [BookingStatus.ACCEPTED], + }, + attendees: { + some: { + email: bookerEmail, + }, + }, + }, + }); + } + + /** + * Finds active bookings for an event type by booker email with ordering + */ + async findActiveBookingsForEventType({ + eventTypeId, + bookerEmail, + limit, + }: { + eventTypeId: number; + bookerEmail: string; + limit: number; + }) { + return await this.prismaClient.booking.findMany({ + where: { + eventTypeId, + startTime: { + gte: new Date(), + }, + status: { + in: [BookingStatus.ACCEPTED], + }, + attendees: { + some: { + email: bookerEmail, + }, + }, + }, + orderBy: { + startTime: "desc", + }, + take: limit, + select: { + uid: true, + startTime: true, + attendees: { + select: { + name: true, + email: true, + }, + }, + }, + }); + } + /** Determines if the user is the organizer, team admin, or org admin that the booking was created under */ async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) { const booking = await this.prismaClient.booking.findUnique({ diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index af3fb019b865b3..fac3f6ad5c403c 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -162,6 +162,38 @@ export class UserRepository { }; } + /** + * Finds a verified user by email, checking both primary and secondary emails + */ + async findVerifiedUserByEmail({ email }: { email: string }) { + return await this.prismaClient.user.findFirst({ + where: { + OR: [ + { + email, + emailVerified: { + not: null, + }, + }, + { + secondaryEmails: { + some: { + email, + emailVerified: { + not: null, + }, + }, + }, + }, + ], + }, + select: { + id: true, + email: true, + }, + }); + } + /** * It is aware of the fact that a user can be part of multiple organizations. */