diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 3862480656..95a814a987 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -826,22 +826,8 @@ const BookingPage = ({ - {mutation.isError && ( -
-
-
-
-
-

- {rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "} - {(mutation.error as HttpError)?.message} -

-
-
-
+ {(mutation.isError || recurringMutation.isError) && ( + )} @@ -853,3 +839,24 @@ const BookingPage = ({ }; export default BookingPage; + +function ErrorMessage({ error }: { error: unknown }) { + const { t } = useLocale(); + const { query: { rescheduleUid } = {} } = useRouter(); + + return ( +
+
+
+
+
+

+ {rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "} + {error instanceof HttpError || error instanceof Error ? error.message : "Unknown error"} +

+
+
+
+ ); +} diff --git a/apps/web/lib/mutations/event-types/create-event-type.ts b/apps/web/lib/mutations/event-types/create-event-type.ts deleted file mode 100644 index fb3deaecab..0000000000 --- a/apps/web/lib/mutations/event-types/create-event-type.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as fetch from "@lib/core/http/fetch-wrapper"; -import { CreateEventType, CreateEventTypeResponse } from "@lib/types/event-type"; - -/** - * @deprecated Use `trpc.useMutation("viewer.eventTypes.create")` instead. - */ -const createEventType = async (data: CreateEventType) => { - const response = await fetch.post( - "/api/availability/eventtype", - data - ); - return response; -}; - -export default createEventType; diff --git a/apps/web/lib/mutations/event-types/delete-event-type.ts b/apps/web/lib/mutations/event-types/delete-event-type.ts deleted file mode 100644 index 82f77c45df..0000000000 --- a/apps/web/lib/mutations/event-types/delete-event-type.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as fetch from "@lib/core/http/fetch-wrapper"; - -/** - * @deprecated Use `trpc.useMutation("viewer.eventTypes.delete")` instead. - */ -const deleteEventType = async (data: { id: number }) => { - const response = await fetch.remove<{ id: number }, Record>( - "/api/availability/eventtype", - data - ); - return response; -}; - -export default deleteEventType; diff --git a/apps/web/lib/mutations/event-types/update-event-type.ts b/apps/web/lib/mutations/event-types/update-event-type.ts deleted file mode 100644 index 02deaf5ca7..0000000000 --- a/apps/web/lib/mutations/event-types/update-event-type.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EventType } from "@prisma/client"; - -import * as fetch from "@lib/core/http/fetch-wrapper"; -import { EventTypeInput } from "@lib/types/event-type"; - -type EventTypeResponse = { - eventType: EventType; -}; - -/** - * @deprecated Use `trpc.useMutation("viewer.eventTypes.update")` instead. - */ -const updateEventType = async (data: EventTypeInput) => { - const response = await fetch.patch("/api/availability/eventtype", data); - return response; -}; - -export default updateEventType; diff --git a/apps/web/lib/queries/availability/index.ts b/apps/web/lib/queries/availability/index.ts deleted file mode 100644 index a3c88c0dec..0000000000 --- a/apps/web/lib/queries/availability/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Prisma } from "@prisma/client"; -import dayjs from "dayjs"; - -import { asStringOrNull } from "@lib/asStringOrNull"; -import { getWorkingHours } from "@lib/availability"; -import getBusyTimes from "@lib/getBusyTimes"; -import prisma from "@lib/prisma"; - -export async function getUserAvailability(query: { - username: string; - dateFrom: string; - dateTo: string; - eventTypeId?: number; - timezone?: string; -}) { - const username = asStringOrNull(query.username); - const dateFrom = dayjs(asStringOrNull(query.dateFrom)); - const dateTo = dayjs(asStringOrNull(query.dateTo)); - - if (!username) throw new Error("Missing username"); - if (!dateFrom.isValid() || !dateTo.isValid()) throw new Error("Invalid time range given."); - - const rawUser = await prisma.user.findUnique({ - where: { - username: username, - }, - select: { - credentials: true, - timeZone: true, - bufferTime: true, - availability: true, - id: true, - startTime: true, - endTime: true, - selectedCalendars: true, - }, - }); - - const getEventType = (id: number) => - prisma.eventType.findUnique({ - where: { id }, - select: { - timeZone: true, - availability: { - select: { - startTime: true, - endTime: true, - days: true, - }, - }, - }, - }); - - type EventType = Prisma.PromiseReturnType; - let eventType: EventType | null = null; - if (query.eventTypeId) eventType = await getEventType(query.eventTypeId); - - if (!rawUser) throw new Error("No user found"); - - const { selectedCalendars, ...currentUser } = rawUser; - - const busyTimes = await getBusyTimes({ - credentials: currentUser.credentials, - startTime: dateFrom.format(), - endTime: dateTo.format(), - eventTypeId: query.eventTypeId, - userId: currentUser.id, - selectedCalendars, - }); - - const bufferedBusyTimes = busyTimes.map((a) => ({ - start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(), - end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(), - })); - - const timeZone = query.timezone || eventType?.timeZone || currentUser.timeZone; - const workingHours = getWorkingHours( - { timeZone }, - eventType?.availability.length ? eventType.availability : currentUser.availability - ); - - return { - busy: bufferedBusyTimes, - timeZone, - workingHours, - }; -} diff --git a/apps/web/modules/common/api/defaultResponder.ts b/apps/web/modules/common/api/defaultResponder.ts deleted file mode 100644 index f230928d41..0000000000 --- a/apps/web/modules/common/api/defaultResponder.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import Stripe from "stripe"; -import { ZodError } from "zod"; - -import { HttpError } from "@calcom/lib/http-error"; - -type Handle = (req: NextApiRequest, res: NextApiResponse) => Promise; - -/** Allows us to get type inference from API handler responses */ -function defaultResponder(f: Handle) { - return async (req: NextApiRequest, res: NextApiResponse) => { - try { - const result = await f(req, res); - res.json(result); - } catch (err) { - if (err instanceof HttpError) { - res.statusCode = err.statusCode; - res.json({ message: err.message }); - } else if (err instanceof Stripe.errors.StripeInvalidRequestError) { - console.error("err", err); - res.statusCode = err.statusCode || 500; - res.json({ message: "Stripe error: " + err.message }); - } else if (err instanceof ZodError && err.name === "ZodError") { - console.error("err", JSON.parse(err.message)[0].message); - res.statusCode = 400; - res.json({ - message: "Validation errors: " + err.issues.map((i) => `—'${i.path}' ${i.message}`).join(". "), - }); - } else { - console.error("err", err); - res.statusCode = 500; - res.json({ message: "Unknown error" }); - } - } - }; -} - -export default defaultResponder; diff --git a/apps/web/modules/common/api/index.ts b/apps/web/modules/common/api/index.ts deleted file mode 100644 index db67b1334c..0000000000 --- a/apps/web/modules/common/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as defaultHandler } from "./defaultHandler"; -export { default as defaultResponder } from "./defaultResponder"; diff --git a/apps/web/modules/common/index.ts b/apps/web/modules/common/index.ts deleted file mode 100644 index d158c57640..0000000000 --- a/apps/web/modules/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./api"; diff --git a/apps/web/pages/api/availability/[user].ts b/apps/web/pages/api/availability/[user].ts index e9ecc51068..1074b7b8d1 100644 --- a/apps/web/pages/api/availability/[user].ts +++ b/apps/web/pages/api/availability/[user].ts @@ -1,141 +1,25 @@ -import { Prisma } from "@prisma/client"; -import dayjs from "dayjs"; -import timezone from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextApiRequest } from "next"; +import { z } from "zod"; -import { asStringOrNull } from "@lib/asStringOrNull"; -import { getWorkingHours } from "@lib/availability"; -import getBusyTimes from "@lib/getBusyTimes"; -import prisma from "@lib/prisma"; +import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { defaultResponder } from "@calcom/lib/server"; +import { stringOrNumber } from "@calcom/prisma/zod-utils"; -dayjs.extend(utc); -dayjs.extend(timezone); +const availabilitySchema = z.object({ + user: z.string(), + dateFrom: z.string(), + dateTo: z.string(), + eventTypeId: stringOrNumber.optional(), +}); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const user = asStringOrNull(req.query.user); - const dateFrom = dayjs(asStringOrNull(req.query.dateFrom)); - const dateTo = dayjs(asStringOrNull(req.query.dateTo)); - const eventTypeId = typeof req.query.eventTypeId === "string" ? parseInt(req.query.eventTypeId) : undefined; - - if (!dateFrom.isValid() || !dateTo.isValid()) { - return res.status(400).json({ message: "Invalid time range given." }); - } - - const rawUser = await prisma.user.findUnique({ - where: { - username: user as string, - }, - select: { - credentials: true, - timeZone: true, - bufferTime: true, - availability: true, - id: true, - startTime: true, - endTime: true, - selectedCalendars: true, - schedules: { - select: { - availability: true, - timeZone: true, - id: true, - }, - }, - defaultScheduleId: true, - }, - }); - - const getEventType = (id: number) => - prisma.eventType.findUnique({ - where: { id }, - select: { - seatsPerTimeSlot: true, - timeZone: true, - schedule: { - select: { - availability: true, - timeZone: true, - }, - }, - availability: { - select: { - startTime: true, - endTime: true, - days: true, - }, - }, - }, - }); - - type EventType = Prisma.PromiseReturnType; - let eventType: EventType | null = null; - if (eventTypeId) eventType = await getEventType(eventTypeId); - - if (!rawUser) throw new Error("No user found"); - - const { selectedCalendars, ...currentUser } = rawUser; - - const busyTimes = await getBusyTimes({ - credentials: currentUser.credentials, - startTime: dateFrom.format(), - endTime: dateTo.format(), +async function handler(req: NextApiRequest) { + const { user: username, eventTypeId, dateTo, dateFrom } = availabilitySchema.parse(req.query); + return getUserAvailability({ + username, + dateFrom, + dateTo, eventTypeId, - userId: currentUser.id, - selectedCalendars, - }); - - const bufferedBusyTimes = busyTimes.map((a) => ({ - start: dayjs(a.start).subtract(currentUser.bufferTime, "minute"), - end: dayjs(a.end).add(currentUser.bufferTime, "minute"), - })); - - const schedule = eventType?.schedule - ? { ...eventType?.schedule } - : { - ...currentUser.schedules.filter( - (schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId - )[0], - }; - - const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone; - - const workingHours = getWorkingHours( - { - timeZone, - }, - schedule.availability || - (eventType?.availability.length ? eventType.availability : currentUser.availability) - ); - - /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab - current bookings with a seats event type and display them on the calendar, even if they are full */ - let currentSeats; - if (eventType?.seatsPerTimeSlot) { - currentSeats = await prisma.booking.findMany({ - where: { - eventTypeId: eventTypeId, - startTime: { - gte: dateFrom.format(), - lte: dateTo.format(), - }, - }, - select: { - uid: true, - startTime: true, - _count: { - select: { - attendees: true, - }, - }, - }, - }); - } - - res.status(200).json({ - busy: bufferedBusyTimes, - timeZone, - workingHours, - currentSeats, }); } + +export default defaultResponder(handler); diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts index d40a5b2680..02cf8334da 100644 --- a/apps/web/pages/api/book/confirm.ts +++ b/apps/web/pages/api/book/confirm.ts @@ -6,6 +6,7 @@ import EventManager from "@calcom/core/EventManager"; import { sendDeclinedEmails, sendScheduledEmails } from "@calcom/emails"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import logger from "@calcom/lib/logger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; import { refund } from "@ee/lib/stripe/server"; @@ -15,8 +16,6 @@ import { HttpError } from "@lib/core/http/error"; import { getTranslation } from "@server/lib/i18n"; -import { defaultHandler, defaultResponder } from "~/common"; - const authorized = async ( currentUser: Pick, booking: Pick diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 774d040f53..a56ca2e051 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -5,33 +5,35 @@ import dayjsBusinessTime from "dayjs-business-days2"; import isBetween from "dayjs/plugin/isBetween"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextApiRequest } from "next"; import rrule from "rrule"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import EventManager from "@calcom/core/EventManager"; +import { getUserAvailability } from "@calcom/core/getUserAvailability"; import { sendAttendeeRequestEmail, sendOrganizerRequestEmail, sendRescheduledEmails, sendScheduledEmails, } from "@calcom/emails"; -import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { getLuckyUsers, isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma, { userSelect } from "@calcom/prisma"; +import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import { handlePayment } from "@ee/lib/stripe/server"; +import { HttpError } from "@lib/core/http/error"; import { ensureArray } from "@lib/ensureArray"; import { getEventName } from "@lib/event"; -import getBusyTimes from "@lib/getBusyTimes"; import isOutOfBounds from "@lib/isOutOfBounds"; -import prisma from "@lib/prisma"; -import { BookingCreateBody } from "@lib/types/booking"; import sendPayload from "@lib/webhooks/sendPayload"; import getSubscribers from "@lib/webhooks/subscriptions"; @@ -81,45 +83,38 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, lengt // Check for conflicts let t = true; - if (Array.isArray(busyTimes) && busyTimes.length > 0) { - busyTimes.forEach((busyTime) => { - const startTime = dayjs(busyTime.start); - const endTime = dayjs(busyTime.end); + // Early return + if (!Array.isArray(busyTimes) || busyTimes.length < 1) return t; - // Check if time is between start and end times - if (dayjs(time).isBetween(startTime, endTime, null, "[)")) { - t = false; - } + let i = 0; + while (t === true && i < busyTimes.length) { + const busyTime = busyTimes[i]; + i++; + const startTime = dayjs(busyTime.start); + const endTime = dayjs(busyTime.end); - // Check if slot end time is between start and end time - if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) { - t = false; - } + // Check if time is between start and end times + if (dayjs(time).isBetween(startTime, endTime, null, "[)")) { + t = false; + break; + } - // Check if startTime is between slot - if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) { - t = false; - } - }); + // Check if slot end time is between start and end time + if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) { + t = false; + break; + } + + // Check if startTime is between slot + if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) { + t = false; + break; + } } return t; } -const userSelect = Prisma.validator()({ - select: { - id: true, - email: true, - name: true, - username: true, - timeZone: true, - credentials: true, - bufferTime: true, - destinationCalendar: true, - locale: true, - }, -}); - const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNames: string[]) => { const users = await prisma.user.findMany({ where: { @@ -191,6 +186,20 @@ const getEventTypesFromDB = async (eventTypeId: number) => { seatsPerTimeSlot: true, recurringEvent: true, locations: true, + timeZone: true, + schedule: { + select: { + availability: true, + timeZone: true, + }, + }, + availability: { + select: { + startTime: true, + endTime: true, + days: true, + }, + }, }, }); @@ -202,23 +211,15 @@ const getEventTypesFromDB = async (eventTypeId: number) => { type User = Prisma.UserGetPayload; -type ExtendedBookingCreateBody = BookingCreateBody & { - noEmail?: boolean; - recurringCount?: number; - rescheduleReason?: string; -}; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody; +async function handler(req: NextApiRequest) { + const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } = + extendedBookingCreateBody.parse(req.body); // handle dynamic user const dynamicUserList = Array.isArray(reqBody.user) - ? getGroupName(req.body.user) - : getUsernameList(reqBody.user as string); - const hasHashedBookingLink = reqBody.hasHashedBookingLink; - const eventTypeSlug = reqBody.eventTypeSlug; - const eventTypeId = reqBody.eventTypeId; - const tAttendees = await getTranslation(reqBody.language ?? "en", "common"); + ? getGroupName(reqBody.user) + : getUsernameList(reqBody.user); + const tAttendees = await getTranslation(language ?? "en", "common"); const tGuests = await getTranslation("en", "common"); log.debug(`Booking eventType ${eventTypeId} started`); @@ -233,11 +234,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; log.error(`Booking ${eventTypeId} failed`, error); - return res.status(400).json(error); + throw new HttpError({ statusCode: 400, message: error.message }); } const eventType = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId); - if (!eventType) return res.status(404).json({ message: "eventType.notFound" }); + if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" }); let users = !eventTypeId ? await prisma.user.findMany({ @@ -258,7 +259,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, ...userSelect, }); - if (!eventTypeUser) return res.status(404).json({ message: "eventTypeUser.notFound" }); + if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); users.push(eventTypeUser); } const [organizerUser] = users; @@ -287,23 +288,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone, - language: { translate: tAttendees, locale: reqBody.language ?? "en" }, + language: { translate: tAttendees, locale: language ?? "en" }, }, ]; - const guests = (reqBody.guests || []).map((guest) => { - const g = { - email: guest, - name: "", - timeZone: reqBody.timeZone, - language: { translate: tGuests, locale: "en" }, - }; - return g; - }); + const guests = (reqBody.guests || []).map((guest) => ({ + email: guest, + name: "", + timeZone: reqBody.timeZone, + language: { translate: tGuests, locale: "en" }, + })); // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (reqBody.bookingUid) { if (!eventType.seatsPerTimeSlot) - return res.status(404).json({ message: "Event type does not have seats" }); + throw new HttpError({ statusCode: 404, message: "Event type does not have seats" }); const booking = await prisma.booking.findUnique({ where: { @@ -313,13 +311,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) attendees: true, }, }); - if (!booking) return res.status(404).json({ message: "Booking not found" }); + if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" }); if (eventType.seatsPerTimeSlot <= booking.attendees.length) - return res.status(409).json({ message: "Booking seats are full" }); + throw new HttpError({ statusCode: 409, message: "Booking seats are full" }); if (booking.attendees.some((attendee) => attendee.email === invitee[0].email)) - return res.status(409).json({ message: "Already signed up for time slot" }); + throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" }); await prisma.booking.update({ where: { @@ -336,7 +334,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }, }); - return res.status(201).json(booking); + req.statusCode = 201; + return booking; } const teamMemberPromises = @@ -358,7 +357,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const attendeesList = [...invitee, ...guests, ...teamMembers]; - const seed = `${organizerUser.username}:${dayjs(req.body.start).utc().format()}:${new Date().getTime()}`; + const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); const location = !!eventType.locations ? (eventType.locations as Array<{ type: string }>)[0] : ""; @@ -456,8 +455,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) async function createBooking() { // @TODO: check as metadata - if (req.body.web3Details) { - const { web3Details } = req.body; + if (reqBody.web3Details) { + const { web3Details } = reqBody; await verifyAccount(web3Details.userSignature, web3Details.userWallet); } @@ -477,7 +476,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; - const isConfirmedByDefault = (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid; const newBookingData: Prisma.BookingCreateInput = { uid, @@ -548,25 +546,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - /* Validate if there is any stripe_payment credential for this user */ - const stripePaymentCredential = await prisma.credential.findFirst({ - where: { - type: "stripe_payment", - userId: organizerUser.id, - }, - select: { - id: true, - }, - }); - /** eventType doesn’t require payment then we create a booking - * OR - * stripePaymentCredential is found and price is higher than 0 then we create a booking - */ - if (!eventType.price || (stripePaymentCredential && eventType.price > 0)) { - return prisma.booking.create(createBookingObj); + if (typeof eventType.price === "number" && eventType.price > 0) { + /* Validate if there is any stripe_payment credential for this user */ + await prisma.credential.findFirst({ + rejectOnNotFound(err) { + throw new HttpError({ statusCode: 400, message: "Missing stripe credentials", cause: err }); + }, + where: { + type: "stripe_payment", + userId: organizerUser.id, + }, + select: { + id: true, + }, + }); } - // stripePaymentCredential not found and eventType requires payment we return null - return null; + + return prisma.booking.create(createBookingObj); } let results: EventResult[] = []; @@ -577,31 +573,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) for (const currentUser of users) { if (!currentUser) { console.error(`currentUser not found`); - return; + continue; } if (!user) user = currentUser; - const selectedCalendars = await prisma.selectedCalendar.findMany({ - where: { + const { busy: bufferedBusyTimes } = await getUserAvailability( + { userId: currentUser.id, + dateFrom: reqBody.start, + dateTo: reqBody.end, + eventTypeId, }, - }); + { user, eventType } + ); - const busyTimes = await getBusyTimes({ - credentials: currentUser.credentials, - startTime: reqBody.start, - endTime: reqBody.end, - eventTypeId, - userId: currentUser.id, - selectedCalendars, - }); - - console.log("calendarBusyTimes==>>>", busyTimes); - - const bufferedBusyTimes = busyTimes.map((a) => ({ - start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(), - end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(), - })); + console.log("calendarBusyTimes==>>>", bufferedBusyTimes); let isAvailableToBeBooked = true; try { @@ -609,9 +595,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const recurringEvent = parseRecurringEvent(eventType.recurringEvent); const allBookingDates = new rrule({ dtstart: new Date(reqBody.start), ...recurringEvent }).all(); // Go through each date for the recurring event and check if each one's availability - isAvailableToBeBooked = allBookingDates - .map((aDate) => isAvailable(bufferedBusyTimes, aDate, eventType.length)) // <-- array of booleans - .reduce((acc, value) => acc && value, true); // <-- checks boolean array applying "AND" to each value and the current one, starting in true + // DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop + // running at the first unavailable time. + let i = 0; + while (isAvailableToBeBooked === true && i < allBookingDates.length) { + const aDate = allBookingDates[i]; + i++; + isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length); + /* We bail at the first false, we don't need to keep checking */ + if (!isAvailableToBeBooked) break; + } } else { isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length); } @@ -628,8 +621,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; log.debug(`Booking ${currentUser.name} failed`, error); - res.status(409).json(error); - return; + throw new HttpError({ statusCode: 409, message: error.message }); } let timeOutOfBounds = false; @@ -655,8 +647,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; log.debug(`Booking ${currentUser.name} failed`, error); - res.status(400).json(error); - return; + throw new HttpError({ statusCode: 409, message: error.message }); } } @@ -669,14 +660,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const err = getErrorFromUnknown(_err); log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message); if (err.code === "P2002") { - res.status(409).json({ message: "booking.conflict" }); - return; + throw new HttpError({ statusCode: 409, message: "booking.conflict" }); } - res.status(500).end(); - return; + throw err; } - if (!user) throw Error("Can't continue, user not found."); + if (!user) throw new HttpError({ statusCode: 404, message: "Can't continue, user not found." }); // After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again. const credentials = await refreshCredentials(user.credentials); @@ -777,21 +766,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) !originalRescheduledBooking?.paid && !!booking ) { - try { - const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment"); + const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment"); - if (!firstStripeCredential) return res.status(500).json({ message: "Missing payment credentials" }); + if (!firstStripeCredential) + throw new HttpError({ statusCode: 400, message: "Missing payment credentials" }); - if (!booking.user) booking.user = user; - const payment = await handlePayment(evt, eventType, firstStripeCredential, booking); + if (!booking.user) booking.user = user; + const payment = await handlePayment(evt, eventType, firstStripeCredential, booking); - res.status(201).json({ ...booking, message: "Payment required", paymentUid: payment.uid }); - return; - } catch (e) { - log.error(`Creating payment failed`, e); - res.status(500).json({ message: "Payment Failed" }); - return; - } + req.statusCode = 201; + return { ...booking, message: "Payment required", paymentUid: payment.uid }; } log.debug(`Booking ${user.username} completed`); @@ -821,7 +805,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await Promise.all(promises); // Avoid passing referencesToCreate with id unique constrain values // refresh hashed link if used - const urlSeed = `${organizerUser.username}:${dayjs(req.body.start).utc().format()}`; + const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`; const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL)); if (hasHashedBookingLink) { @@ -834,32 +818,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); } - if (booking) { - await prisma.booking.update({ - where: { - uid: booking.uid, - }, - data: { - references: { - createMany: { - data: referencesToCreate, - }, + if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" }); + await prisma.booking.update({ + where: { + uid: booking.uid, + }, + data: { + references: { + createMany: { + data: referencesToCreate, }, }, - }); - // booking successful - return res.status(201).json(booking); - } - return res.status(400).json({ message: "There is not a stripe_payment credential" }); + }, + }); + // booking successful + req.statusCode = 201; + return booking; } -export function getLuckyUsers( - users: User[], - bookingCounts: Prisma.PromiseReturnType -) { - if (!bookingCounts.length) users.slice(0, 1); - - const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1)); - const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username); - return luckyUser ? [luckyUser] : users; -} +export default defaultResponder(handler); diff --git a/apps/web/pages/getting-started.tsx b/apps/web/pages/getting-started.tsx index 1df4c2a53f..809d37a10b 100644 --- a/apps/web/pages/getting-started.tsx +++ b/apps/web/pages/getting-started.tsx @@ -12,7 +12,7 @@ import { NextPageContext } from "next"; import { useSession } from "next-auth/react"; import Head from "next/head"; import { useRouter } from "next/router"; -import React, { useEffect, useRef, useState, useCallback } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; @@ -120,21 +120,7 @@ export default function Onboarding(props: inferSSRProps { - const res = await fetch(`/api/availability/eventtype`, { - method: "POST", - body: JSON.stringify(data), - headers: { - "Content-Type": "application/json", - }, - }); - - if (!res.ok) { - throw new Error((await res.json()).message); - } - const responseData = await res.json(); - return responseData.data; - }; + const createEventType = trpc.useMutation("viewer.eventTypes.create"); const createSchedule = trpc.useMutation("viewer.availability.schedule.create", { onError: (err) => { @@ -229,7 +215,7 @@ export default function Onboarding(props: inferSSRProps { - return await createEventType(event); + return createEventType.mutate(event); }) ); } diff --git a/apps/web/server/routers/viewer/teams.tsx b/apps/web/server/routers/viewer/teams.tsx index f9e9744a07..7a32ae2c98 100644 --- a/apps/web/server/routers/viewer/teams.tsx +++ b/apps/web/server/routers/viewer/teams.tsx @@ -2,7 +2,9 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; import { randomBytes } from "crypto"; import { z } from "zod"; +import { getUserAvailability } from "@calcom/core/getUserAvailability"; import { sendTeamInviteEmail } from "@calcom/emails"; +import { availabilityUserSelect } from "@calcom/prisma"; import { addSeat, downgradeTeamMembers, @@ -13,7 +15,6 @@ import { } from "@calcom/stripe/team-billing"; import { BASE_URL, HOSTED_CAL_FEATURES } from "@lib/config/constants"; -import { getUserAvailability } from "@lib/queries/availability"; import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@lib/queries/teams"; import slugify from "@lib/slugify"; @@ -408,7 +409,14 @@ export const viewerTeamsRouter = createProtectedRouter() // verify member is in team const members = await ctx.prisma.membership.findMany({ where: { teamId: input.teamId }, - include: { user: true }, + include: { + user: { + select: { + username: true, + ...availabilityUserSelect, + }, + }, + }, }); const member = members?.find((m) => m.userId === input.memberId); if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); @@ -416,12 +424,15 @@ export const viewerTeamsRouter = createProtectedRouter() throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" }); // get availability for this member - return await getUserAvailability({ - username: member.user.username, - timezone: input.timezone, - dateFrom: input.dateFrom, - dateTo: input.dateTo, - }); + return await getUserAvailability( + { + username: member.user.username, + timezone: input.timezone, + dateFrom: input.dateFrom, + dateTo: input.dateTo, + }, + { user: member.user } + ); }, }) .mutation("upgradeTeam", { diff --git a/apps/web/test/lib/team-event-types.test.ts b/apps/web/test/lib/team-event-types.test.ts index 241574113f..4262ce90c3 100644 --- a/apps/web/test/lib/team-event-types.test.ts +++ b/apps/web/test/lib/team-event-types.test.ts @@ -1,6 +1,6 @@ import { UserPlan } from "@prisma/client"; -import { getLuckyUsers } from "../../pages/api/book/event"; +import { getLuckyUsers } from "@calcom/lib"; const baseUser = { id: 0, diff --git a/apps/web/lib/getBusyTimes.ts b/packages/core/getBusyTimes.ts similarity index 87% rename from apps/web/lib/getBusyTimes.ts rename to packages/core/getBusyTimes.ts index 8b14e6582c..3f4d829e77 100644 --- a/apps/web/lib/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -3,11 +3,10 @@ import { BookingStatus, Credential, SelectedCalendar } from "@prisma/client"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import { getBusyVideoTimes } from "@calcom/core/videoClient"; import notEmpty from "@calcom/lib/notEmpty"; +import prisma from "@calcom/prisma"; import type { EventBusyDate } from "@calcom/types/Calendar"; -import prisma from "@lib/prisma"; - -async function getBusyTimes(params: { +export async function getBusyTimes(params: { credentials: Credential[]; userId: number; eventTypeId?: number; @@ -32,7 +31,7 @@ async function getBusyTimes(params: { endTime: true, }, }) - .then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime }))); + .then((bookings) => bookings.map(({ startTime, endTime }) => ({ end: endTime, start: startTime }))); if (credentials) { const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts new file mode 100644 index 0000000000..ab9d16cacc --- /dev/null +++ b/packages/core/getUserAvailability.ts @@ -0,0 +1,145 @@ +import { Prisma } from "@prisma/client"; +import dayjs from "dayjs"; +import { z } from "zod"; + +import { getWorkingHours } from "@calcom/lib/availability"; +import { HttpError } from "@calcom/lib/http-error"; +import prisma, { availabilityUserSelect } from "@calcom/prisma"; +import { stringToDayjs } from "@calcom/prisma/zod-utils"; + +import { getBusyTimes } from "./getBusyTimes"; + +const availabilitySchema = z + .object({ + dateFrom: stringToDayjs, + dateTo: stringToDayjs, + eventTypeId: z.number().optional(), + timezone: z.string().optional(), + username: z.string().optional(), + userId: z.number().optional(), + }) + .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); + +const getEventType = (id: number) => + prisma.eventType.findUnique({ + where: { id }, + select: { + seatsPerTimeSlot: true, + timeZone: true, + schedule: { + select: { + availability: true, + timeZone: true, + }, + }, + availability: { + select: { + startTime: true, + endTime: true, + days: true, + }, + }, + }, + }); + +type EventType = Awaited>; + +const getUser = (where: Prisma.UserWhereUniqueInput) => + prisma.user.findUnique({ + where, + select: availabilityUserSelect, + }); + +type User = Awaited>; + +export async function getUserAvailability( + query: ({ username: string } | { userId: number }) & { + dateFrom: string; + dateTo: string; + eventTypeId?: number; + timezone?: string; + }, + initialData?: { + user?: User; + eventType?: EventType; + } +) { + const { username, userId, dateFrom, dateTo, eventTypeId, timezone } = availabilitySchema.parse(query); + + if (!dateFrom.isValid() || !dateTo.isValid()) + throw new HttpError({ statusCode: 400, message: "Invalid time range given." }); + + const where: Prisma.UserWhereUniqueInput = {}; + if (username) where.username = username; + if (userId) where.id = userId; + + let user: User | null = initialData?.user || null; + if (!user) user = await getUser(where); + if (!user) throw new HttpError({ statusCode: 404, message: "No user found" }); + + let eventType: EventType | null = initialData?.eventType || null; + if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId); + + const { selectedCalendars, ...currentUser } = user; + + const busyTimes = await getBusyTimes({ + credentials: currentUser.credentials, + startTime: dateFrom.toISOString(), + endTime: dateTo.toISOString(), + eventTypeId, + userId: currentUser.id, + selectedCalendars, + }); + + const bufferedBusyTimes = busyTimes.map((a) => ({ + start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toISOString(), + end: dayjs(a.end).add(currentUser.bufferTime, "minute").toISOString(), + })); + + const timeZone = timezone || eventType?.timeZone || currentUser.timeZone; + + const schedule = eventType?.schedule + ? { ...eventType?.schedule } + : { + ...currentUser.schedules.filter( + (schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId + )[0], + }; + + const workingHours = getWorkingHours( + { timeZone }, + schedule.availability || + (eventType?.availability.length ? eventType.availability : currentUser.availability) + ); + + /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab + current bookings with a seats event type and display them on the calendar, even if they are full */ + let currentSeats; + if (eventType?.seatsPerTimeSlot) { + currentSeats = await prisma.booking.findMany({ + where: { + eventTypeId: eventTypeId, + startTime: { + gte: dateFrom.format(), + lte: dateTo.format(), + }, + }, + select: { + uid: true, + startTime: true, + _count: { + select: { + attendees: true, + }, + }, + }, + }); + } + + return { + busy: bufferedBusyTimes, + timeZone, + workingHours, + currentSeats, + }; +} diff --git a/packages/core/index.ts b/packages/core/index.ts index b2e810437b..3f4526787a 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -1,3 +1,4 @@ export * from "./CalendarManager"; export * from "./EventManager"; +export { default as getBusyTimes } from "./getBusyTimes"; export * from "./videoClient"; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index f6809cac8b..56d8e03446 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -1,5 +1,10 @@ import type { EventTypeCustomInput } from "@prisma/client"; -import { PeriodType, SchedulingType, UserPlan } from "@prisma/client"; +import { PeriodType, Prisma, SchedulingType, UserPlan } from "@prisma/client"; + +import { baseUserSelect } from "@calcom/prisma/selects"; + +const userSelectData = Prisma.validator()({ select: baseUserSelect }); +type User = Prisma.UserGetPayload; const availability = [ { @@ -81,7 +86,13 @@ const commons = { theme: null, brandColor: "#292929", darkBrandColor: "#fafafa", - }, + availability: [], + selectedCalendars: [], + startTime: 0, + endTime: 0, + schedules: [], + defaultScheduleId: null, + } as User, ], }; diff --git a/packages/lib/getLuckyUsers.ts b/packages/lib/getLuckyUsers.ts new file mode 100644 index 0000000000..0f31359063 --- /dev/null +++ b/packages/lib/getLuckyUsers.ts @@ -0,0 +1,19 @@ +import { Prisma } from "@prisma/client"; + +import { userSelect } from "@calcom/prisma"; + +type User = Prisma.UserGetPayload; + +export function getLuckyUsers( + users: User[], + bookingCounts: { + username: string | null; + bookingCount: number; + }[] +) { + if (!bookingCounts.length) users.slice(0, 1); + + const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1)); + const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username); + return luckyUser ? [luckyUser] : users; +} diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 78836ecdd1..00ae0059e8 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -1,2 +1,3 @@ +export { getLuckyUsers } from "./getLuckyUsers"; export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj"; export * from "./isRecurringEvent"; diff --git a/apps/web/modules/common/api/defaultHandler.ts b/packages/lib/server/defaultHandler.ts similarity index 100% rename from apps/web/modules/common/api/defaultHandler.ts rename to packages/lib/server/defaultHandler.ts diff --git a/packages/lib/server/defaultResponder.ts b/packages/lib/server/defaultResponder.ts new file mode 100644 index 0000000000..e2167db87f --- /dev/null +++ b/packages/lib/server/defaultResponder.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { performance } from "perf_hooks"; + +import { perfObserver } from "."; +import { getServerErrorFromUnkown } from "./getServerErrorFromUnkown"; + +type Handle = (req: NextApiRequest, res: NextApiResponse) => Promise; + +perfObserver.observe({ entryTypes: ["measure"], buffered: true }); + +/** Allows us to get type inference from API handler responses */ +function defaultResponder(f: Handle) { + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + performance.mark("Start"); + const result = await f(req, res); + res.json(result); + } catch (err) { + const error = getServerErrorFromUnkown(err); + res.statusCode = error.statusCode; + res.json({ message: error.message }); + } finally { + performance.mark("End"); + performance.measure("Measuring endpoint: " + req.url, "Start", "End"); + } + }; +} + +export default defaultResponder; diff --git a/packages/lib/server/getServerErrorFromUnkown.ts b/packages/lib/server/getServerErrorFromUnkown.ts new file mode 100644 index 0000000000..7e90d2b06a --- /dev/null +++ b/packages/lib/server/getServerErrorFromUnkown.ts @@ -0,0 +1,29 @@ +import { Prisma } from "@prisma/client"; +import Stripe from "stripe"; +import { ZodError } from "zod"; + +import { HttpError } from "../http-error"; + +export function getServerErrorFromUnkown(cause: unknown): HttpError { + if (cause instanceof Prisma.PrismaClientKnownRequestError) { + return new HttpError({ statusCode: 400, message: cause.message, cause }); + } + if (cause instanceof Error) { + return new HttpError({ statusCode: 500, message: cause.message, cause }); + } + if (cause instanceof HttpError) { + return cause; + } + if (cause instanceof Stripe.errors.StripeInvalidRequestError) { + return new HttpError({ statusCode: 400, message: cause.message, cause }); + } + if (cause instanceof ZodError) { + return new HttpError({ statusCode: 400, message: cause.message, cause }); + } + if (typeof cause === "string") { + // @ts-expect-error https://github.com/tc39/proposal-error-cause + return new Error(cause, { cause }); + } + + return new HttpError({ statusCode: 500, message: `Unhandled error of type '${typeof cause}'` }); +} diff --git a/packages/lib/server/index.ts b/packages/lib/server/index.ts new file mode 100644 index 0000000000..532895f110 --- /dev/null +++ b/packages/lib/server/index.ts @@ -0,0 +1,5 @@ +export { default as defaultHandler } from "./defaultHandler"; +export { default as defaultResponder } from "./defaultResponder"; +export { getServerErrorFromUnkown } from "./getServerErrorFromUnkown"; +export { getTranslation } from "./i18n"; +export { default as perfObserver } from "./perfObserver"; diff --git a/packages/lib/server/perfObserver.ts b/packages/lib/server/perfObserver.ts new file mode 100644 index 0000000000..4076aca994 --- /dev/null +++ b/packages/lib/server/perfObserver.ts @@ -0,0 +1,20 @@ +import { PerformanceObserver } from "perf_hooks"; + +declare global { + // eslint-disable-next-line no-var + var perfObserver: PerformanceObserver | undefined; +} + +export const perfObserver = + globalThis.perfObserver || + new PerformanceObserver((items) => { + items.getEntries().forEach((entry) => { + console.log(entry); // fake call to our custom logging solution + }); + }); + +if (process.env.NODE_ENV !== "production") { + globalThis.perfObserver = perfObserver; +} + +export default perfObserver; diff --git a/packages/prisma/selects/index.ts b/packages/prisma/selects/index.ts index b59bed2e77..8376ce7e82 100644 --- a/packages/prisma/selects/index.ts +++ b/packages/prisma/selects/index.ts @@ -1,2 +1,3 @@ export * from "./booking"; export * from "./event-types"; +export * from "./user"; diff --git a/packages/prisma/selects/user.ts b/packages/prisma/selects/user.ts new file mode 100644 index 0000000000..153a71d45d --- /dev/null +++ b/packages/prisma/selects/user.ts @@ -0,0 +1,52 @@ +import { Prisma } from "@prisma/client"; + +export const availabilityUserSelect = Prisma.validator()({ + credentials: true, + timeZone: true, + bufferTime: true, + availability: true, + id: true, + startTime: true, + endTime: true, + selectedCalendars: true, + schedules: { + select: { + availability: true, + timeZone: true, + id: true, + }, + }, + defaultScheduleId: true, +}); + +export const baseUserSelect = Prisma.validator()({ + email: true, + name: true, + username: true, + destinationCalendar: true, + locale: true, + plan: true, + avatar: true, + hideBranding: true, + theme: true, + brandColor: true, + darkBrandColor: true, + ...availabilityUserSelect, +}); + +export const userSelect = Prisma.validator()({ + select: { + email: true, + name: true, + username: true, + destinationCalendar: true, + locale: true, + plan: true, + avatar: true, + hideBranding: true, + theme: true, + brandColor: true, + darkBrandColor: true, + ...availabilityUserSelect, + }, +}); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9ac22ed62e..162e760ef4 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -56,3 +56,39 @@ export const stringOrNumber = z.union([ ]); export const stringToDayjs = z.string().transform((val) => dayjs(val)); + +export const bookingCreateBodySchema = z.object({ + email: z.string(), + end: z.string(), + web3Details: z + .object({ + userWallet: z.string(), + userSignature: z.string(), + }) + .optional(), + eventTypeId: z.number(), + eventTypeSlug: z.string(), + guests: z.array(z.string()).optional(), + location: z.string(), + name: z.string(), + notes: z.string().optional(), + rescheduleUid: z.string().optional(), + recurringEventId: z.string().optional(), + start: z.string(), + timeZone: z.string(), + user: z.union([z.string(), z.array(z.string())]).optional(), + language: z.string(), + bookingUid: z.string().optional(), + customInputs: z.array(z.object({ label: z.string(), value: z.union([z.string(), z.boolean()]) })), + metadata: z.record(z.string()), + hasHashedBookingLink: z.boolean(), + hashedLink: z.string().nullish(), +}); + +export const extendedBookingCreateBody = bookingCreateBodySchema.merge( + z.object({ + noEmail: z.boolean().optional(), + recurringCount: z.number().optional(), + rescheduleReason: z.string().optional(), + }) +);