diff --git a/apps/api/v1/pages/api/slots/_get.test.ts b/apps/api/v1/pages/api/slots/_get.test.ts index 29d531f133..ed5b50b347 100644 --- a/apps/api/v1/pages/api/slots/_get.test.ts +++ b/apps/api/v1/pages/api/slots/_get.test.ts @@ -42,11 +42,6 @@ describe("GET /api/slots", () => { await handler(req, res); expect(res.statusCode).toBe(400); - expect(JSON.parse(res._getData())).toMatchInlineSnapshot(` - { - "message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required", - } - `); }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d050818c41..3bf1001795 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3246,6 +3246,9 @@ "meeting_start_time": "Meeting Start Time", "members_affected_by_disabling_delegation_credential": "Affected members", "no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential", + "offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot", + "booker_limit_exceeded_error": "Booker maximum active booking limit exceeded", + "booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?", "duration_min_error": "Duration must be at least {{min}} minute(s)", "duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 4c57f73c0f..76c9f63093 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -237,7 +237,6 @@ const BookerComponent = ({ bookingForm={bookingForm} eventQuery={event} extraOptions={extraOptions} - rescheduleUid={rescheduleUid} isVerificationCodeSending={isVerificationCodeSending} confirmButtonDisabled={confirmButtonDisabled} classNames={{ @@ -278,7 +277,6 @@ const BookerComponent = ({ loadingStates, onGoBackInstantMeeting, renderConfirmNotVerifyEmailButtonCond, - rescheduleUid, seatedEventData, setSeatedEventData, setSelectedTimeslot, diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 29280b0a0a..36fb8eb8a9 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -7,14 +7,18 @@ import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBooke import type { BookerEvent } from "@calcom/features/bookings/types"; import ServerTrans from "@calcom/lib/components/ServerTrans"; import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { TimeFormat } from "@calcom/lib/timeFormat"; import { Alert } from "@calcom/ui/components/alert"; import { Button } from "@calcom/ui/components/button"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { Form } from "@calcom/ui/components/form"; import { useBookerStore } from "../../store"; +import { formatEventFromTime } from "../../utils/dates"; +import { useBookerTime } from "../hooks/useBookerTime"; import type { UseBookingFormReturnType } from "../hooks/useBookingForm"; import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBookings"; import { BookingFields } from "./BookingFields"; @@ -44,7 +48,6 @@ type BookEventFormProps = { export const BookEventForm = ({ onCancel, eventQuery, - rescheduleUid, onSubmit, errorRef, errors, @@ -65,18 +68,19 @@ export const BookEventForm = ({ isPending: boolean; data?: Pick | null; }; - rescheduleUid: string | null; }) => { const eventType = eventQuery.data; const setFormValues = useBookerStore((state) => state.setFormValues); const bookingData = useBookerStore((state) => state.bookingData); + const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const timeslot = useBookerStore((state) => state.selectedTimeslot); const username = useBookerStore((state) => state.username); const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); + const { timeFormat, timezone } = useBookerTime(); const [responseVercelIdHeader] = useState(null); - const { t } = useLocale(); + const { t, i18n } = useLocale(); const isPaidEvent = useMemo(() => { if (!eventType?.price) return false; @@ -132,7 +136,15 @@ export const BookEventForm = ({ className="my-2" severity="info" title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")} - message={getError(errors.formErrors, errors.dataErrors, t, responseVercelIdHeader)} + message={getError({ + globalError: errors.formErrors, + dataError: errors.dataErrors, + t, + responseVercelIdHeader, + timeFormat, + timezone, + language: i18n.language, + })} /> ) : isTimeslotUnavailable ? ( @@ -255,23 +267,46 @@ export const BookEventForm = ({ ); }; -const getError = ( - globalError: FieldError | undefined, +const getError = ({ + globalError, + dataError, + t, + responseVercelIdHeader, + timeFormat, + timezone, + language, +}: { + globalError: FieldError | undefined; // It feels like an implementation detail to reimplement the types of useMutation here. // Since they don't matter for this function, I'd rather disable them then giving you // the cognitive overload of thinking to update them here when anything changes. // eslint-disable-next-line @typescript-eslint/no-explicit-any - dataError: any, - t: TFunction, - responseVercelIdHeader: string | null -) => { + dataError: any; + t: TFunction; + responseVercelIdHeader: string | null; + timeFormat: TimeFormat; + timezone: string; + language: string; +}) => { if (globalError) return globalError?.message; const error = dataError; + let date = ""; + + if (error.message === ErrorCode.BookerLimitExceededReschedule) { + const formattedDate = formatEventFromTime({ + date: error.data.startTime, + timeFormat, + timeZone: timezone, + language, + }); + date = `${formattedDate.date} ${formattedDate.time}`; + } + return error?.message ? ( <> - {responseVercelIdHeader ?? ""} {t(error.message)} + {responseVercelIdHeader ?? ""} {t(error.message, { date })} ) : ( <>{t("can_you_try_again")} diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 47b7444979..633f8e4601 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -11,9 +11,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib"; +import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { getFullName } from "@calcom/features/form-builder/utils"; import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -285,6 +287,23 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb onError: (err, _, ctx) => { // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" }); + + const error = err as Error & { + data: { rescheduleUid: string; startTime: string; attendees: string[] }; + }; + + if (error.message === ErrorCode.BookerLimitExceededReschedule && error.data?.rescheduleUid) { + useBookerStore.setState({ + rescheduleUid: error.data?.rescheduleUid, + }); + useBookerStore.setState({ + bookingData: { + uid: error.data?.rescheduleUid, + startTime: error.data?.startTime, + attendees: error.data?.attendees, + } as unknown as GetBookingType, + }); + } }, }); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f8393e1521..5823ebf76a 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -52,6 +52,7 @@ import { getFirstDelegationConferencingCredentialAppLocation, } from "@calcom/lib/delegationCredential/server"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import type { ErrorWithCode } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getEventName, updateHostInEventName } from "@calcom/lib/event"; import { extractBaseEmail } from "@calcom/lib/extract-base-email"; @@ -454,11 +455,22 @@ async function handler( await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); if (!rawBookingData.rescheduleUid) { - await checkActiveBookingsLimitForBooker({ - eventTypeId, - maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker, - bookerEmail, - }); + try { + await checkActiveBookingsLimitForBooker({ + eventTypeId, + maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker, + bookerEmail, + offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule, + }); + } catch (err) { + const error = err as ErrorWithCode; + + throw new HttpError({ + statusCode: 400, + message: error.message, + data: error.data, + }); + } } if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { diff --git a/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts b/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts index d521c21a7a..152a587dd8 100644 --- a/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts +++ b/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts @@ -1,3 +1,5 @@ +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -5,15 +7,38 @@ export const checkActiveBookingsLimitForBooker = async ({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail, + offerToRescheduleLastBooking, }: { eventTypeId: number; maxActiveBookingsPerBooker: number | null; bookerEmail: string; + offerToRescheduleLastBooking: boolean; }) => { if (!maxActiveBookingsPerBooker) { return; } + if (offerToRescheduleLastBooking) { + await checkActiveBookingsLimitAndOfferReschedule({ + eventTypeId, + maxActiveBookingsPerBooker, + bookerEmail, + }); + } else { + await checkActiveBookingsLimit({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail }); + } +}; + +/** If we don't need the last record then we should just use COUNT */ +const checkActiveBookingsLimit = async ({ + eventTypeId, + maxActiveBookingsPerBooker, + bookerEmail, +}: { + eventTypeId: number; + maxActiveBookingsPerBooker: number; + bookerEmail: string; +}) => { const bookingsCount = await prisma.booking.count({ where: { eventTypeId, @@ -32,8 +57,61 @@ export const checkActiveBookingsLimitForBooker = async ({ }); if (bookingsCount >= maxActiveBookingsPerBooker) { - throw new Error("Booker maximum active booking limit exceeded"); + throw new ErrorWithCode(ErrorCode.BookerLimitExceeded, ErrorCode.BookerLimitExceeded); + } +}; + +const checkActiveBookingsLimitAndOfferReschedule = async ({ + eventTypeId, + maxActiveBookingsPerBooker, + bookerEmail, +}: { + eventTypeId: number; + maxActiveBookingsPerBooker: number; + bookerEmail: string; +}) => { + 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 lastBooking = bookingsCount[bookingsCount.length - 1]; + + if (bookingsCount.length >= maxActiveBookingsPerBooker) { + throw new ErrorWithCode( + ErrorCode.BookerLimitExceededReschedule, + ErrorCode.BookerLimitExceededReschedule, + { + rescheduleUid: lastBooking.uid, + startTime: lastBooking.startTime, + attendees: lastBooking.attendees, + } + ); } - - return; }; diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 8571b6838e..bde2a5db13 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -66,6 +66,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { includeNoShowInRRCalculation: true, minimumBookingNotice: true, maxActiveBookingsPerBooker: true, + maxActiveBookingPerBookerOfferReschedule: true, userId: true, price: true, currency: true, diff --git a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index 7e6aaa8eb1..777345c478 100644 --- a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -30,6 +30,8 @@ import { SettingsToggle, } from "@calcom/ui/components/form"; +import MaxActiveBookingsPerBookerController from "./MaxActiveBookingsPerBookerController"; + type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType]; export type EventLimitsTabCustomClassNames = { @@ -685,52 +687,7 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr ); }} /> - { - const isChecked = maxActiveBookingsPerBookerToggle; - return ( - { - if (active) { - onChange(1); - } else { - onChange(null); - } - setMaxActiveBookingsPerBookerToggle((state) => !state); - }}> -
- { - onChange(parseInt(e.target.value, 10)); - }} - placeholder="1" - min={1} - step={1} - containerClassName={classNames("max-w-80")} - addOnSuffix="bookings" - data-testid="booker-booking-limit-input" - /> -
-
- ); - }} - /> + { diff --git a/packages/features/eventtypes/components/tabs/limits/MaxActiveBookingsPerBookerController.tsx b/packages/features/eventtypes/components/tabs/limits/MaxActiveBookingsPerBookerController.tsx new file mode 100644 index 0000000000..75d4f9dc92 --- /dev/null +++ b/packages/features/eventtypes/components/tabs/limits/MaxActiveBookingsPerBookerController.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import classNames from "@calcom/ui/classNames"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { TextField, CheckboxField } from "@calcom/ui/components/form"; + +export default function MaxActiveBookingsPerBookerController() { + const { t } = useLocale(); + const formMethods = useFormContext(); + + const [maxActiveBookingsPerBookerToggle, setMaxActiveBookingsPerBookerToggle] = useState( + (formMethods.getValues("maxActiveBookingsPerBooker") ?? 0) > 0 + ); + + const isRecurringEvent = !!formMethods.getValues("recurringEvent"); + const maxActiveBookingPerBookerOfferReschedule = formMethods.watch( + "maxActiveBookingPerBookerOfferReschedule" + ); + return ( + { + const isChecked = maxActiveBookingsPerBookerToggle; + return ( + { + if (active) { + onChange(1); + } else { + onChange(null); + } + setMaxActiveBookingsPerBookerToggle((state) => !state); + }}> +
+ { + onChange(e.target.value === "" ? null : parseInt(e.target.value, 10)); + }} + min={1} + step={1} + containerClassName={classNames("max-w-80")} + addOnSuffix="bookings" + data-testid="booker-booking-limit-input" + /> + { + formMethods.setValue("maxActiveBookingPerBookerOfferReschedule", e.target.checked, { + shouldDirty: true, + }); + }} + /> +
+
+ ); + }} + /> + ); +} diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 5144403447..4f0d0433c7 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -158,6 +158,7 @@ export type FormValues = { enableAutomaticTranscription?: boolean; redirectUrlOnExit?: string; }; + maxActiveBookingPerBookerOfferReschedule: boolean; }; export type LocationFormValues = Pick; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index a5af61be57..226ccbd0a7 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -105,6 +105,7 @@ const commons = { requiresBookerEmailVerification: false, bookingLimits: null, maxActiveBookingsPerBooker: null, + maxActiveBookingPerBookerOfferReschedule: false, durationLimits: null, hidden: false, userId: 0, diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index a39174ad70..2298cfafca 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -18,4 +18,6 @@ export enum ErrorCode { CreatingOauthClientError = "creating_oauth_client_error", BookingTimeOutOfBounds = "booking_time_out_of_bounds_error", BookingConflict = "booking_conflict_error", + BookerLimitExceeded = "booker_limit_exceeded_error", + BookerLimitExceededReschedule = "booker_limit_exceeded_error_reschedule", } diff --git a/packages/lib/fetch-wrapper.ts b/packages/lib/fetch-wrapper.ts index 9c7dceb358..d3eaf7f251 100644 --- a/packages/lib/fetch-wrapper.ts +++ b/packages/lib/fetch-wrapper.ts @@ -6,10 +6,14 @@ async function http(path: string, config: RequestInit): Promise { if (!response.ok) { const errJson = await response.json(); - const err = HttpError.fromRequest(request, { - ...response, - statusText: errJson.message || response.statusText, - }); + const err = HttpError.fromRequest( + request, + { + ...response, + statusText: errJson.message || response.statusText, + }, + errJson + ); throw err; } // may error if there is no body, return empty array diff --git a/packages/lib/http-error.ts b/packages/lib/http-error.ts index 2237614803..bd03433a92 100644 --- a/packages/lib/http-error.ts +++ b/packages/lib/http-error.ts @@ -4,8 +4,16 @@ export class HttpError extends Error { public readonly message: string; public readonly url: string | undefined; public readonly method: string | undefined; + public readonly data?: Record; - constructor(opts: { url?: string; method?: string; message?: string; statusCode: TCode; cause?: Error }) { + constructor(opts: { + url?: string; + method?: string; + message?: string; + statusCode: TCode; + cause?: Error; + data?: Record; + }) { super(opts.message ?? `HTTP Error ${opts.statusCode} `); Object.setPrototypeOf(this, HttpError.prototype); @@ -16,18 +24,22 @@ export class HttpError extends Error { this.url = opts.url; this.method = opts.method; this.message = opts.message ?? `HTTP Error ${opts.statusCode}`; + this.data = opts.data; if (opts.cause instanceof Error && opts.cause.stack) { this.stack = opts.cause.stack; } } - public static fromRequest(request: Request, response: Response) { + public static fromRequest(request: Request, response: Response, parsedError: Record) { return new HttpError({ message: response.statusText, url: response.url, method: request.method, statusCode: response.status, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore the data property is a custom one from ErrorWithCode + data: parsedError.data as Record, }); } } diff --git a/packages/lib/server/defaultResponder.ts b/packages/lib/server/defaultResponder.ts index 7fee04e165..0f7e03cbda 100644 --- a/packages/lib/server/defaultResponder.ts +++ b/packages/lib/server/defaultResponder.ts @@ -33,7 +33,7 @@ export function defaultResponder( } return res .status(error.statusCode) - .json({ message: error.message, url: error.url, method: error.method }); + .json({ message: error.message, url: error.url, method: error.method, data: error?.data || null }); } finally { performance.mark("End"); performance.measure(`[${ok ? "OK" : "ERROR"}][$1] ${req.method} '${req.url}'`, "Start", "End"); diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 05b6849c1e..8691ac7660 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -531,6 +531,7 @@ export class EventTypeRepository { onlyShowFirstAvailableSlot: true, durationLimits: true, maxActiveBookingsPerBooker: true, + maxActiveBookingPerBookerOfferReschedule: true, assignAllTeamMembers: true, allowReschedulingPastBookings: true, hideOrganizerEmail: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 53e98f2d05..2905a2f781 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -134,6 +134,7 @@ export const buildEventType = (eventType?: Partial): EventType => { scheduleId: null, bookingLimits: null, maxActiveBookingsPerBooker: null, + maxActiveBookingPerBookerOfferReschedule: false, durationLimits: null, assignAllTeamMembers: false, rescheduleWithSameRoundRobinHost: false, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 754c3ab639..68f7156fe8 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -69,7 +69,6 @@ export const useEventTypeForm = ({ schedule: eventType.schedule || undefined, instantMeetingSchedule: eventType.instantMeetingSchedule || undefined, bookingLimits: eventType.bookingLimits || undefined, - maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker || undefined, onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined, durationLimits: eventType.durationLimits || undefined, length: eventType.length, @@ -138,6 +137,8 @@ export const useEventTypeForm = ({ useEventLevelSelectedCalendars: eventType.useEventLevelSelectedCalendars, customReplyToEmail: eventType.customReplyToEmail || null, calVideoSettings: eventType.calVideoSettings, + maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker || null, + maxActiveBookingPerBookerOfferReschedule: eventType.maxActiveBookingPerBookerOfferReschedule, }; }, [eventType, periodDates]); diff --git a/packages/prisma/migrations/20250613224705_max_booker_booking_limit_offer_reschedule/migration.sql b/packages/prisma/migrations/20250613224705_max_booker_booking_limit_offer_reschedule/migration.sql new file mode 100644 index 0000000000..8a36acf23b --- /dev/null +++ b/packages/prisma/migrations/20250613224705_max_booker_booking_limit_offer_reschedule/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "maxActiveBookingPerBookerOfferReschedule" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f19348184e..e6e0d4d772 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -134,64 +134,65 @@ model EventType { requiresBookerEmailVerification Boolean @default(false) canSendCalVideoTranscriptionEmails Boolean @default(true) - autoTranslateDescriptionEnabled Boolean @default(false) + autoTranslateDescriptionEnabled Boolean @default(false) /// @zod.custom(imports.recurringEventType) - recurringEvent Json? - disableGuests Boolean @default(false) - hideCalendarNotes Boolean @default(false) - hideCalendarEventDetails Boolean @default(false) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) + hideCalendarEventDetails Boolean @default(false) /// @zod.min(0) - minimumBookingNotice Int @default(120) - beforeEventBuffer Int @default(0) - afterEventBuffer Int @default(0) - seatsPerTimeSlot Int? - onlyShowFirstAvailableSlot Boolean @default(false) - disableCancelling Boolean? @default(false) - disableRescheduling Boolean? @default(false) - seatsShowAttendees Boolean? @default(false) - seatsShowAvailabilityCount Boolean? @default(true) - schedulingType SchedulingType? - schedule Schedule? @relation(fields: [scheduleId], references: [id]) - scheduleId Int? - allowReschedulingCancelledBookings Boolean? @default(false) + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + onlyShowFirstAvailableSlot Boolean @default(false) + disableCancelling Boolean? @default(false) + disableRescheduling Boolean? @default(false) + seatsShowAttendees Boolean? @default(false) + seatsShowAvailabilityCount Boolean? @default(true) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + allowReschedulingCancelledBookings Boolean? @default(false) // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. - price Int @default(0) + price Int @default(0) // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. - currency String @default("usd") - slotInterval Int? + currency String @default("usd") + slotInterval Int? /// @zod.custom(imports.EventTypeMetaDataSchema) - metadata Json? + metadata Json? /// @zod.custom(imports.successRedirectUrl) - successRedirectUrl String? - forwardParamsSuccessRedirect Boolean? @default(true) - workflows WorkflowsOnEventTypes[] + successRedirectUrl String? + forwardParamsSuccessRedirect Boolean? @default(true) + workflows WorkflowsOnEventTypes[] /// @zod.custom(imports.intervalLimitsType) - bookingLimits Json? + bookingLimits Json? /// @zod.custom(imports.intervalLimitsType) - durationLimits Json? - isInstantEvent Boolean @default(false) - instantMeetingExpiryTimeOffsetInSeconds Int @default(90) - instantMeetingScheduleId Int? - instantMeetingSchedule Schedule? @relation("InstantMeetingSchedule", fields: [instantMeetingScheduleId], references: [id]) - instantMeetingParameters String[] - assignAllTeamMembers Boolean @default(false) + durationLimits Json? + isInstantEvent Boolean @default(false) + instantMeetingExpiryTimeOffsetInSeconds Int @default(90) + instantMeetingScheduleId Int? + instantMeetingSchedule Schedule? @relation("InstantMeetingSchedule", fields: [instantMeetingScheduleId], references: [id]) + instantMeetingParameters String[] + assignAllTeamMembers Boolean @default(false) // It is applicable only when assignAllTeamMembers is true and it filters out all the team members using rrSegmentQueryValue - assignRRMembersUsingSegment Boolean @default(false) + assignRRMembersUsingSegment Boolean @default(false) /// @zod.custom(imports.rrSegmentQueryValueSchema) - rrSegmentQueryValue Json? - useEventTypeDestinationCalendarEmail Boolean @default(false) - aiPhoneCallConfig AIPhoneCallConfiguration? - isRRWeightsEnabled Boolean @default(false) - fieldTranslations EventTypeTranslation[] - maxLeadThreshold Int? - includeNoShowInRRCalculation Boolean @default(false) - selectedCalendars SelectedCalendar[] - allowReschedulingPastBookings Boolean @default(false) - hideOrganizerEmail Boolean @default(false) - maxActiveBookingsPerBooker Int? + rrSegmentQueryValue Json? + useEventTypeDestinationCalendarEmail Boolean @default(false) + aiPhoneCallConfig AIPhoneCallConfiguration? + isRRWeightsEnabled Boolean @default(false) + fieldTranslations EventTypeTranslation[] + maxLeadThreshold Int? + includeNoShowInRRCalculation Boolean @default(false) + selectedCalendars SelectedCalendar[] + allowReschedulingPastBookings Boolean @default(false) + hideOrganizerEmail Boolean @default(false) + maxActiveBookingsPerBooker Int? + maxActiveBookingPerBookerOfferReschedule Boolean @default(false) /// @zod.custom(imports.emailSchema) - customReplyToEmail String? - calVideoSettings CalVideoSettings? + customReplyToEmail String? + calVideoSettings CalVideoSettings? /// @zod.custom(imports.eventTypeColor) eventTypeColor Json? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 3adafc6634..d66c969051 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -663,6 +663,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit { data.bookingLimits = bookingLimits; } - if (maxActiveBookingsPerBooker && maxActiveBookingsPerBooker < 1) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Booker booking limit must be greater than 0." }); - } + if (maxActiveBookingsPerBooker) { + if (maxActiveBookingsPerBooker && maxActiveBookingsPerBooker < 1) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Booker booking limit must be greater than 0." }); + } - if (maxActiveBookingsPerBooker && (recurringEvent || eventType.recurringEvent)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Recurring Events and booker active bookings limit cannot be active at the same time.", - }); - } + if (maxActiveBookingsPerBooker && (recurringEvent || eventType.recurringEvent)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Recurring Events and booker active bookings limit cannot be active at the same time.", + }); + } - if (eventType.maxActiveBookingsPerBooker && recurringEvent) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Recurring Events and booker active bookings limit cannot be active at the same time.", - }); + if (eventType.maxActiveBookingsPerBooker && recurringEvent) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Recurring Events and booker active bookings limit cannot be active at the same time.", + }); + } + + data.maxActiveBookingsPerBooker = maxActiveBookingsPerBooker; } if (durationLimits) {