feat: Allow reschedule when max booker bookings have been reached (#21778)
* feat - Restrict same email to create more than 'n' active bookings at a time * updated checkbookerbookinglimit function * type fix * minor change * import fix * minor fixes * back to null on disable * back * type check * managed edge cases * chore: name changes * name changes * fix * minor change * changed name * use default value for maxactivebookingsperbooker, and some minor changes * disabling bookerbooking limit for recurring event * disabling bookerbooking limit for recurring event * type fix * ui fix and backend eventtype update check * Add `maxActiveBookingPerBookerOfferReschedule` to schema * Create `MaxActiveBookingsPerBookerController` and offer reschedule option * Add offer reschedule to event type form data * Pass data through to HttpError * When checking max bookings, return last booking info if applicable * removed unused code * minor changes * update validation * chore * Do not check booking limits if rescheduling * Add data for reschedule * Add reschedule specific error code * On maximum booking error, write to booker store reschedule params * Add translations for error codes * Write to error message previous booking time * minor fix * Write to error message previous booking time * Type fixes * Clean up comment * Refactor eventType update errors * Typo fix * Type fix * Type fix * Type fix * Fix test * Fix test * Add migration * Addressed feedback and missed merges --------- Co-authored-by: romit <romitgabani@icloud.com>
This commit is contained in:
1 parent
ad93099171
commit
54e518efad
23 files changed
+349
-141
No files matched your search
@@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BookerEvent, "price" | "currency" | "metadata" | "bookingFields" | "locations"> | 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<string | null>(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,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : 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")}</>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
+81
-3
@@ -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;
|
||||
};
|
||||
@@ -66,6 +66,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
includeNoShowInRRCalculation: true,
|
||||
minimumBookingNotice: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
maxActiveBookingPerBookerOfferReschedule: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="maxActiveBookingsPerBooker"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const isChecked = maxActiveBookingsPerBookerToggle;
|
||||
return (
|
||||
<SettingsToggle
|
||||
labelClassName={classNames("text-sm")}
|
||||
disabled={isRecurringEvent}
|
||||
tooltip={isRecurringEvent ? t("recurring_event_doesnt_support_booker_booking_limit") : ""}
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName={classNames("lg:ml-0")}
|
||||
title={t("booker_booking_limit")}
|
||||
description={t("booker_booking_limit_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
onChange(1);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
setMaxActiveBookingsPerBookerToggle((state) => !state);
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MaxActiveBookingsPerBookerController />
|
||||
<Controller
|
||||
name="periodType"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
|
||||
+79
@@ -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<FormValues>();
|
||||
|
||||
const [maxActiveBookingsPerBookerToggle, setMaxActiveBookingsPerBookerToggle] = useState(
|
||||
(formMethods.getValues("maxActiveBookingsPerBooker") ?? 0) > 0
|
||||
);
|
||||
|
||||
const isRecurringEvent = !!formMethods.getValues("recurringEvent");
|
||||
const maxActiveBookingPerBookerOfferReschedule = formMethods.watch(
|
||||
"maxActiveBookingPerBookerOfferReschedule"
|
||||
);
|
||||
return (
|
||||
<Controller
|
||||
name="maxActiveBookingsPerBooker"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const isChecked = maxActiveBookingsPerBookerToggle;
|
||||
return (
|
||||
<SettingsToggle
|
||||
labelClassName={classNames("text-sm")}
|
||||
disabled={isRecurringEvent}
|
||||
tooltip={isRecurringEvent ? t("recurring_event_doesnt_support_booker_booking_limit") : ""}
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
|
||||
isChecked && "rounded-b-none"
|
||||
)}
|
||||
childrenClassName={classNames("lg:ml-0")}
|
||||
title={t("booker_booking_limit")}
|
||||
description={t("booker_booking_limit_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
onChange(1);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
setMaxActiveBookingsPerBookerToggle((state) => !state);
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<CheckboxField
|
||||
checked={!!maxActiveBookingPerBookerOfferReschedule}
|
||||
descriptionAsLabel
|
||||
description={t("offer_to_reschedule_last_booking")}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("maxActiveBookingPerBookerOfferReschedule", e.target.checked, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,7 @@ export type FormValues = {
|
||||
enableAutomaticTranscription?: boolean;
|
||||
redirectUrlOnExit?: string;
|
||||
};
|
||||
maxActiveBookingPerBookerOfferReschedule: boolean;
|
||||
};
|
||||
|
||||
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
|
||||
|
||||
@@ -105,6 +105,7 @@ const commons = {
|
||||
requiresBookerEmailVerification: false,
|
||||
bookingLimits: null,
|
||||
maxActiveBookingsPerBooker: null,
|
||||
maxActiveBookingPerBookerOfferReschedule: false,
|
||||
durationLimits: null,
|
||||
hidden: false,
|
||||
userId: 0,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -6,10 +6,14 @@ async function http<T>(path: string, config: RequestInit): Promise<T> {
|
||||
|
||||
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
|
||||
|
||||
@@ -4,8 +4,16 @@ export class HttpError<TCode extends number = number> extends Error {
|
||||
public readonly message: string;
|
||||
public readonly url: string | undefined;
|
||||
public readonly method: string | undefined;
|
||||
public readonly data?: Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
}) {
|
||||
super(opts.message ?? `HTTP Error ${opts.statusCode} `);
|
||||
|
||||
Object.setPrototypeOf(this, HttpError.prototype);
|
||||
@@ -16,18 +24,22 @@ export class HttpError<TCode extends number = number> 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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function defaultResponder<T>(
|
||||
}
|
||||
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");
|
||||
|
||||
@@ -531,6 +531,7 @@ export class EventTypeRepository {
|
||||
onlyShowFirstAvailableSlot: true,
|
||||
durationLimits: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
maxActiveBookingPerBookerOfferReschedule: true,
|
||||
assignAllTeamMembers: true,
|
||||
allowReschedulingPastBookings: true,
|
||||
hideOrganizerEmail: true,
|
||||
|
||||
@@ -134,6 +134,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
scheduleId: null,
|
||||
bookingLimits: null,
|
||||
maxActiveBookingsPerBooker: null,
|
||||
maxActiveBookingPerBookerOfferReschedule: false,
|
||||
durationLimits: null,
|
||||
assignAllTeamMembers: false,
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "maxActiveBookingPerBookerOfferReschedule" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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?
|
||||
|
||||
@@ -663,6 +663,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
||||
bookingFields: true,
|
||||
durationLimits: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
maxActiveBookingPerBookerOfferReschedule: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
assignAllTeamMembers: true,
|
||||
|
||||
@@ -261,22 +261,26 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user