From 8b24995d5286195335058612430b5d021707eef6 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 29 Nov 2023 10:11:09 -0500 Subject: [PATCH] refactor: Abstract `createBooking` in `handleNewBooking` [CAL-2619] (#11959) ## What does this PR do? In anticipation of refactoring `handleSeats` we need to abstract `createBooking` in order to get the return type. This PR is purposely aiming to do one thing so nothing is missed while refactoring `handleNewBooking` Fixes # (issue) ## Requirement/Documentation - If there is a requirement document, please, share it here. - If there is ab UI/UX design document, please, share it here. ## Type of change - [x] Chore (refactoring code, technical debt, workflow improvements) ## How should this be tested? - Are there environment variables that should be set? - What are the minimal test data to have? - What is expected (happy path) to have (input and output)? - Any other important info that could help to test that PR ## Mandatory Tasks - [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected. ## Checklist - I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code doesn't follow the style guidelines of this project - I haven't commented my code, particularly in hard-to-understand areas - I haven't checked if my PR needs changes to the documentation - I haven't checked if my changes generate no new warnings - I haven't added tests that prove my fix is effective or that my feature works - I haven't checked if new and existing unit tests pass locally with my changes --- .../bookings/lib/getBookingDataSchema.ts | 80 +++ .../features/bookings/lib/handleNewBooking.ts | 504 +++++++++--------- 2 files changed, 333 insertions(+), 251 deletions(-) create mode 100644 packages/features/bookings/lib/getBookingDataSchema.ts diff --git a/packages/features/bookings/lib/getBookingDataSchema.ts b/packages/features/bookings/lib/getBookingDataSchema.ts new file mode 100644 index 00000000000000..cef2a72dc673f7 --- /dev/null +++ b/packages/features/bookings/lib/getBookingDataSchema.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { + bookingCreateSchemaLegacyPropsForApi, + bookingCreateBodySchemaForApi, + extendedBookingCreateBody, +} from "@calcom/prisma/zod-utils"; + +import getBookingResponsesSchema from "./getBookingResponsesSchema"; +import type { getEventTypesFromDB } from "./handleNewBooking"; + +const getBookingDataSchema = ( + rescheduleUid: string | undefined, + isNotAnApiCall: boolean, + eventType: Awaited> +) => { + const responsesSchema = getBookingResponsesSchema({ + eventType: { + bookingFields: eventType.bookingFields, + }, + view: rescheduleUid ? "reschedule" : "booking", + }); + const bookingDataSchema = isNotAnApiCall + ? extendedBookingCreateBody.merge( + z.object({ + responses: responsesSchema, + }) + ) + : bookingCreateBodySchemaForApi + .merge( + z.object({ + responses: responsesSchema.optional(), + }) + ) + .superRefine((val, ctx) => { + if (val.responses && val.customInputs) { + ctx.addIssue({ + code: "custom", + message: + "Don't use both customInputs and responses. `customInputs` is only there for legacy support.", + }); + return; + } + const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape); + + if (val.responses) { + const unwantedProps: string[] = []; + legacyProps.forEach((legacyProp) => { + if (typeof val[legacyProp as keyof typeof val] !== "undefined") { + console.error( + `Deprecated: Unexpected falsy value for: ${unwantedProps.join( + "," + )}. They can't be used with \`responses\`. This will become a 400 error in the future.` + ); + } + if (val[legacyProp as keyof typeof val]) { + unwantedProps.push(legacyProp); + } + }); + if (unwantedProps.length) { + ctx.addIssue({ + code: "custom", + message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``, + }); + return; + } + } else if (val.customInputs) { + const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val); + if (!success) { + ctx.addIssue({ + code: "custom", + message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`, + }); + } + } + }); + return bookingDataSchema; +}; + +export default getBookingDataSchema; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7165a6f67b51e4..b3a4f5643c1a65 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -77,11 +77,9 @@ import type { BookingReference } from "@calcom/prisma/client"; import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { - bookingCreateBodySchemaForApi, bookingCreateSchemaLegacyPropsForApi, customInputSchema, EventTypeMetaDataSchema, - extendedBookingCreateBody, userMetadata as userMetadataSchema, } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; @@ -96,7 +94,7 @@ import type { CredentialPayload } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventTypeInfo } from "../../webhooks/lib/sendPayload"; -import getBookingResponsesSchema from "./getBookingResponsesSchema"; +import getBookingDataSchema from "./getBookingDataSchema"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); @@ -104,6 +102,14 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; type BookingType = Prisma.PromiseReturnType; +type Booking = Prisma.PromiseReturnType; +export type NewBookingEventType = + | Awaited> + | Awaited>; + +// Work with Typescript to require reqBody.end +type ReqBodyWithoutEnd = z.infer>; +type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -244,7 +250,7 @@ function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, return false; } -const getEventTypesFromDB = async (eventTypeId: number) => { +export const getEventTypesFromDB = async (eventTypeId: number) => { const eventType = await prisma.eventType.findUniqueOrThrow({ where: { id: eventTypeId, @@ -363,6 +369,53 @@ type IsFixedAwareUser = User & { organization: { slug: string }; }; +const loadUsers = async ( + eventType: NewBookingEventType, + dynamicUserList: string[], + reqHeadersHost: string | undefined +) => { + try { + if (!eventType.id) { + if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { + throw new Error("dynamicUserList is not properly defined or empty."); + } + + const users = await prisma.user.findMany({ + where: { + username: { in: dynamicUserList }, + organization: userOrgQuery(reqHeadersHost ? reqHeadersHost.replace(/^https?:\/\//, "") : ""), + }, + select: { + ...userSelect.select, + credentials: { + select: credentialForCalendarServiceSelect, + }, + metadata: true, + }, + }); + + return users; + } + const hosts = eventType.hosts || []; + + if (!Array.isArray(hosts)) { + throw new Error("eventType.hosts is not properly defined."); + } + + const users = hosts.map(({ user, isFixed }) => ({ + ...user, + isFixed, + })); + + return users.length ? users : eventType.users; + } catch (error) { + if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { + throw new HttpError({ statusCode: 400, message: error.message }); + } + throw new HttpError({ statusCode: 500, message: "Unable to load users" }); + } +}; + async function ensureAvailableUsers( eventType: Awaited> & { users: IsFixedAwareUser[]; @@ -506,73 +559,10 @@ async function getBookingData({ isNotAnApiCall: boolean; eventType: Awaited>; }) { - const responsesSchema = getBookingResponsesSchema({ - eventType: { - bookingFields: eventType.bookingFields, - }, - view: req.body.rescheduleUid ? "reschedule" : "booking", - }); - const bookingDataSchema = isNotAnApiCall - ? extendedBookingCreateBody.merge( - z.object({ - responses: responsesSchema, - }) - ) - : bookingCreateBodySchemaForApi - .merge( - z.object({ - responses: responsesSchema.optional(), - }) - ) - .superRefine((val, ctx) => { - if (val.responses && val.customInputs) { - ctx.addIssue({ - code: "custom", - message: - "Don't use both customInputs and responses. `customInputs` is only there for legacy support.", - }); - return; - } - const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape); - - if (val.responses) { - const unwantedProps: string[] = []; - legacyProps.forEach((legacyProp) => { - if (typeof val[legacyProp as keyof typeof val] !== "undefined") { - console.error( - `Deprecated: Unexpected falsy value for: ${unwantedProps.join( - "," - )}. They can't be used with \`responses\`. This will become a 400 error in the future.` - ); - } - if (val[legacyProp as keyof typeof val]) { - unwantedProps.push(legacyProp); - } - }); - if (unwantedProps.length) { - ctx.addIssue({ - code: "custom", - message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``, - }); - return; - } - } else if (val.customInputs) { - const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val); - if (!success) { - ctx.addIssue({ - code: "custom", - message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`, - }); - } - } - }); + const bookingDataSchema = getBookingDataSchema(req.body?.rescheduleUid, isNotAnApiCall, eventType); const reqBody = await bookingDataSchema.parseAsync(req.body); - // Work with Typescript to require reqBody.end - type ReqBodyWithoutEnd = z.infer; - type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; - const reqBodyWithEnd = (reqBody: ReqBodyWithoutEnd): reqBody is ReqBodyWithEnd => { // Use the event length to auto-set the event end time. if (!Object.prototype.hasOwnProperty.call(reqBody, "end")) { @@ -626,6 +616,181 @@ async function getBookingData({ } } +async function createBooking({ + originalRescheduledBooking, + evt, + eventTypeId, + eventTypeSlug, + reqBodyUser, + reqBodyMetadata, + reqBodyRecurringEventId, + uid, + responses, + isConfirmedByDefault, + smsReminderNumber, + organizerUser, + rescheduleReason, + eventType, + bookerEmail, + paymentAppData, + changedOrganizer, +}: { + originalRescheduledBooking: Awaited>; + evt: CalendarEvent; + eventType: NewBookingEventType; + eventTypeId: Awaited>["eventTypeId"]; + eventTypeSlug: Awaited>["eventTypeSlug"]; + reqBodyUser: ReqBodyWithEnd["user"]; + reqBodyMetadata: ReqBodyWithEnd["metadata"]; + reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; + uid: short.SUUID; + responses: ReqBodyWithEnd["responses"] | null; + isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; + smsReminderNumber: Awaited>["smsReminderNumber"]; + organizerUser: Awaited>[number] & { + isFixed?: boolean; + metadata?: Prisma.JsonValue; + }; + rescheduleReason: Awaited>["rescheduleReason"]; + bookerEmail: Awaited>["email"]; + paymentAppData: ReturnType; + changedOrganizer: boolean; +}) { + if (originalRescheduledBooking) { + evt.title = originalRescheduledBooking?.title || evt.title; + evt.description = originalRescheduledBooking?.description || evt.description; + evt.location = originalRescheduledBooking?.location || evt.location; + evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location; + } + + const eventTypeRel = !eventTypeId + ? {} + : { + connect: { + id: eventTypeId, + }, + }; + + const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; + const dynamicGroupSlugRef = !eventTypeId ? (reqBodyUser as string).toLowerCase() : null; + + const attendeesData = evt.attendees.map((attendee) => { + //if attendee is team member, it should fetch their locale not booker's locale + //perhaps make email fetch request to see if his locale is stored, else + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + locale: attendee.language.locale, + }; + }); + + if (evt.team?.members) { + attendeesData.push( + ...evt.team.members.map((member) => ({ + email: member.email, + name: member.name, + timeZone: member.timeZone, + locale: member.language.locale, + })) + ); + } + + const newBookingData: Prisma.BookingCreateInput = { + uid, + responses: responses === null ? Prisma.JsonNull : responses, + title: evt.title, + startTime: dayjs.utc(evt.startTime).toDate(), + endTime: dayjs.utc(evt.endTime).toDate(), + description: evt.additionalNotes, + customInputs: isPrismaObjOrUndefined(evt.customInputs), + status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING, + location: evt.location, + eventType: eventTypeRel, + smsReminderNumber, + metadata: reqBodyMetadata, + attendees: { + createMany: { + data: attendeesData, + }, + }, + dynamicEventSlugRef, + dynamicGroupSlugRef, + user: { + connect: { + id: organizerUser.id, + }, + }, + destinationCalendar: + evt.destinationCalendar && evt.destinationCalendar.length > 0 + ? { + connect: { id: evt.destinationCalendar[0].id }, + } + : undefined, + }; + + if (reqBodyRecurringEventId) { + newBookingData.recurringEventId = reqBodyRecurringEventId; + } + if (originalRescheduledBooking) { + newBookingData.metadata = { + ...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata), + }; + newBookingData["paid"] = originalRescheduledBooking.paid; + newBookingData["fromReschedule"] = originalRescheduledBooking.uid; + if (originalRescheduledBooking.uid) { + newBookingData.cancellationReason = rescheduleReason; + } + if (newBookingData.attendees?.createMany?.data) { + // Reschedule logic with booking with seats + if (eventType?.seatsPerTimeSlot && bookerEmail) { + newBookingData.attendees.createMany.data = attendeesData.filter( + (attendee) => attendee.email === bookerEmail + ); + } + } + if (originalRescheduledBooking.recurringEventId) { + newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; + } + } + const createBookingObj = { + include: { + user: { + select: { email: true, name: true, timeZone: true, username: true }, + }, + attendees: true, + payment: true, + references: true, + }, + data: newBookingData, + }; + + if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) { + const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success); + + if (bookingPayment) { + createBookingObj.data.payment = { + connect: { id: bookingPayment.id }, + }; + } + } + + if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) { + /* Validate if there is any payment app credential for this user */ + await prisma.credential.findFirstOrThrow({ + where: { + appId: paymentAppData.appId, + ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), + }, + select: { + id: true, + }, + }); + } + + return prisma.booking.create(createBookingObj); +} + function getCustomInputsResponses( reqBody: { responses?: Record; @@ -781,54 +946,11 @@ async function handler( throw new HttpError({ statusCode: 400, message: error.message }); } - const loadUsers = async () => { - try { - if (!eventTypeId) { - if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { - throw new Error("dynamicUserList is not properly defined or empty."); - } - - const users = await prisma.user.findMany({ - where: { - username: { in: dynamicUserList }, - organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""), - }, - select: { - ...userSelect.select, - credentials: { - select: credentialForCalendarServiceSelect, - }, - metadata: true, - }, - }); - - return users; - } else { - const hosts = eventType.hosts || []; - - if (!Array.isArray(hosts)) { - throw new Error("eventType.hosts is not properly defined."); - } - - const users = hosts.map(({ user, isFixed }) => ({ - ...user, - isFixed, - })); - - return users.length ? users : eventType.users; - } - } catch (error) { - if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { - throw new HttpError({ statusCode: 400, message: error.message }); - } - throw new HttpError({ statusCode: 500, message: "Unable to load users" }); - } - }; // loadUsers allows type inferring let users: (Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; - })[] = await loadUsers(); + })[] = await loadUsers(eventType, dynamicUserList, req.headers.host); const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); if (!isDynamicAllowed && !eventTypeId) { @@ -1923,147 +2045,9 @@ async function handler( eventType.schedulingType === SchedulingType.ROUND_ROBIN && originalRescheduledBooking.userId !== evt.organizer.id; - async function createBooking() { - if (originalRescheduledBooking) { - evt.title = originalRescheduledBooking?.title || evt.title; - evt.description = originalRescheduledBooking?.description || evt.description; - evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location; - } - - const eventTypeRel = !eventTypeId - ? {} - : { - connect: { - id: eventTypeId, - }, - }; - - const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; - const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; - - const attendeesData = evt.attendees.map((attendee) => { - //if attendee is team member, it should fetch their locale not booker's locale - //perhaps make email fetch request to see if his locale is stored, else - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - locale: attendee.language.locale, - }; - }); - - if (evt.team?.members) { - attendeesData.push( - ...evt.team.members.map((member) => ({ - email: member.email, - name: member.name, - timeZone: member.timeZone, - locale: member.language.locale, - })) - ); - } - - const newBookingData: Prisma.BookingCreateInput = { - uid, - responses: responses === null ? Prisma.JsonNull : responses, - title: evt.title, - startTime: dayjs.utc(evt.startTime).toDate(), - endTime: dayjs.utc(evt.endTime).toDate(), - description: evt.additionalNotes, - customInputs: isPrismaObjOrUndefined(evt.customInputs), - status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING, - location: evt.location, - eventType: eventTypeRel, - smsReminderNumber, - metadata: reqBody.metadata, - attendees: { - createMany: { - data: attendeesData, - }, - }, - dynamicEventSlugRef, - dynamicGroupSlugRef, - user: { - connect: { - id: organizerUser.id, - }, - }, - destinationCalendar: - evt.destinationCalendar && evt.destinationCalendar.length > 0 - ? { - connect: { id: evt.destinationCalendar[0].id }, - } - : undefined, - }; - - if (reqBody.recurringEventId) { - newBookingData.recurringEventId = reqBody.recurringEventId; - } - if (originalRescheduledBooking) { - newBookingData.metadata = { - ...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata), - }; - newBookingData["paid"] = originalRescheduledBooking.paid; - newBookingData["fromReschedule"] = originalRescheduledBooking.uid; - if (originalRescheduledBooking.uid) { - newBookingData.cancellationReason = rescheduleReason; - } - if (newBookingData.attendees?.createMany?.data) { - // Reschedule logic with booking with seats - if (eventType?.seatsPerTimeSlot && bookerEmail) { - newBookingData.attendees.createMany.data = attendeesData.filter( - (attendee) => attendee.email === bookerEmail - ); - } - } - if (originalRescheduledBooking.recurringEventId) { - newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; - } - } - const createBookingObj = { - include: { - user: { - select: { email: true, name: true, timeZone: true, username: true }, - }, - attendees: true, - payment: true, - references: true, - }, - data: newBookingData, - }; - - if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) { - const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success); - - if (bookingPayment) { - createBookingObj.data.payment = { - connect: { id: bookingPayment.id }, - }; - } - } - - if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) { - /* Validate if there is any payment app credential for this user */ - await prisma.credential.findFirstOrThrow({ - where: { - appId: paymentAppData.appId, - ...(paymentAppData.credentialId - ? { id: paymentAppData.credentialId } - : { userId: organizerUser.id }), - }, - select: { - id: true, - }, - }); - } - - return prisma.booking.create(createBookingObj); - } - let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; - type Booking = Prisma.PromiseReturnType; let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null; loggerWithEventDetails.debug( "Going to create booking in DB now", @@ -2077,7 +2061,25 @@ async function handler( ); try { - booking = await createBooking(); + booking = await createBooking({ + originalRescheduledBooking, + evt, + eventTypeId, + eventTypeSlug, + reqBodyUser: reqBody.user, + reqBodyMetadata: reqBody.metadata, + reqBodyRecurringEventId: reqBody.recurringEventId, + uid, + responses, + isConfirmedByDefault, + smsReminderNumber, + organizerUser, + rescheduleReason, + eventType, + bookerEmail, + paymentAppData, + changedOrganizer, + }); // @NOTE: Add specific try catch for all subsequent async calls to avoid error // Sync Services