feat: Restrict same email to create more than 'n' active bookings at… (#21745)
* 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 * removed unused code * minor changes * update validation * chore * Do not check booking limits if rescheduling * minor fix * Refactor eventType update errors * Typo fix --------- Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
This commit is contained in:
@@ -1699,6 +1699,9 @@
|
||||
"only_show_first_available_slot_description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time.",
|
||||
"limit_total_booking_duration": "Limit total booking duration",
|
||||
"limit_total_booking_duration_description": "Limit total amount of time that this event can be booked",
|
||||
"booker_booking_limit": "Booker active booking limit",
|
||||
"booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type",
|
||||
"recurring_event_doesnt_support_booker_booking_limit": "Recurring events don't support booker booking limit",
|
||||
"add_limit": "Add Limit",
|
||||
"team_name_required": "Team name required",
|
||||
"show_attendees": "Share attendee information between guests",
|
||||
@@ -3098,6 +3101,7 @@
|
||||
"user_has_no_team_yet": "You don't have a team yet",
|
||||
"no_team_members": "You don't have team members yet",
|
||||
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||
"segment": "Segment",
|
||||
|
||||
@@ -88,6 +88,7 @@ import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsFo
|
||||
import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials";
|
||||
import getBookingDataSchema from "./getBookingDataSchema";
|
||||
import { addVideoCallDataToEvent } from "./handleNewBooking/addVideoCallDataToEvent";
|
||||
import { checkActiveBookingsLimitForBooker } from "./handleNewBooking/checkActiveBookingsLimitForBooker";
|
||||
import { checkBookingAndDurationLimits } from "./handleNewBooking/checkBookingAndDurationLimits";
|
||||
import { checkIfBookerEmailIsBlocked } from "./handleNewBooking/checkIfBookerEmailIsBlocked";
|
||||
import { createBooking } from "./handleNewBooking/createBooking";
|
||||
@@ -452,6 +453,14 @@ async function handler(
|
||||
|
||||
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
|
||||
|
||||
if (!rawBookingData.rescheduleUid) {
|
||||
await checkActiveBookingsLimitForBooker({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
||||
logger.settings.minLevel = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export const checkActiveBookingsLimitForBooker = async ({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
maxActiveBookingsPerBooker: number | null;
|
||||
bookerEmail: string;
|
||||
}) => {
|
||||
if (!maxActiveBookingsPerBooker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bookingsCount = await prisma.booking.count({
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
status: {
|
||||
in: [BookingStatus.ACCEPTED],
|
||||
},
|
||||
attendees: {
|
||||
some: {
|
||||
email: bookerEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingsCount >= maxActiveBookingsPerBooker) {
|
||||
throw new Error("Booker maximum active booking limit exceeded");
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -65,6 +65,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
maxLeadThreshold: true,
|
||||
includeNoShowInRRCalculation: true,
|
||||
minimumBookingNotice: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
|
||||
@@ -449,6 +449,7 @@ export const EventAdvancedTab = ({
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
|
||||
|
||||
const isRecurringEvent = !!formMethods.getValues("recurringEvent");
|
||||
|
||||
const isRoundRobinEventType =
|
||||
eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN;
|
||||
|
||||
|
||||
@@ -374,6 +374,8 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr
|
||||
const { t, i18n } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const isRecurringEvent = !!formMethods.getValues("recurringEvent");
|
||||
|
||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
@@ -387,6 +389,9 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr
|
||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
||||
|
||||
const [offsetToggle, setOffsetToggle] = useState(formMethods.getValues("offsetStart") > 0);
|
||||
const [maxActiveBookingsPerBookerToggle, setMaxActiveBookingsPerBookerToggle] = useState(
|
||||
(formMethods.getValues("maxActiveBookingsPerBooker") ?? 0) > 0
|
||||
);
|
||||
|
||||
// Preview how the offset will affect start times
|
||||
const watchOffsetStartValue = formMethods.watch("offsetStart");
|
||||
@@ -680,6 +685,52 @@ 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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="periodType"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function RecurringEventController({
|
||||
formMethods.getValues("recurringEvent")
|
||||
);
|
||||
const isSeatsOffered = !!formMethods.getValues("seatsPerTimeSlot");
|
||||
const hasBookingLimitPerBooker = !!formMethods.getValues("maxActiveBookingsPerBooker");
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
const recurringEventFreqOptions = Object.entries(Frequency)
|
||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||
@@ -93,8 +94,14 @@ export default function RecurringEventController({
|
||||
description={t("recurring_event_description")}
|
||||
checked={!!recurringEventState}
|
||||
data-testid="recurring-event-check"
|
||||
disabled={!recurringEventState && isSeatsOffered}
|
||||
tooltip={isSeatsOffered ? t("seats_doesnt_support_recurring") : undefined}
|
||||
disabled={(!recurringEventState && isSeatsOffered) || hasBookingLimitPerBooker}
|
||||
tooltip={
|
||||
isSeatsOffered
|
||||
? t("seats_doesnt_support_recurring")
|
||||
: hasBookingLimitPerBooker
|
||||
? t("booking_limit_per_booker_doesnt_support_recurring")
|
||||
: undefined
|
||||
}
|
||||
onCheckedChange={(e) => {
|
||||
if (!e) {
|
||||
formMethods.setValue("recurringEvent", null, { shouldDirty: true });
|
||||
|
||||
@@ -123,6 +123,7 @@ export type FormValues = {
|
||||
scheduleName: string;
|
||||
minimumBookingNotice: number;
|
||||
minimumBookingNoticeInDurationType: number;
|
||||
maxActiveBookingsPerBooker: number | null;
|
||||
beforeEventBuffer: number;
|
||||
afterEventBuffer: number;
|
||||
slotInterval: number | null;
|
||||
|
||||
@@ -104,6 +104,7 @@ const commons = {
|
||||
requiresConfirmationForFreeEmail: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
bookingLimits: null,
|
||||
maxActiveBookingsPerBooker: null,
|
||||
durationLimits: null,
|
||||
hidden: false,
|
||||
userId: 0,
|
||||
|
||||
@@ -530,6 +530,7 @@ export class EventTypeRepository {
|
||||
bookingLimits: true,
|
||||
onlyShowFirstAvailableSlot: true,
|
||||
durationLimits: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
assignAllTeamMembers: true,
|
||||
allowReschedulingPastBookings: true,
|
||||
hideOrganizerEmail: true,
|
||||
|
||||
@@ -133,6 +133,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
schedulingType: null,
|
||||
scheduleId: null,
|
||||
bookingLimits: null,
|
||||
maxActiveBookingsPerBooker: null,
|
||||
durationLimits: null,
|
||||
assignAllTeamMembers: false,
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
|
||||
@@ -69,6 +69,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "maxActiveBookingsPerBooker" INTEGER;
|
||||
@@ -188,6 +188,7 @@ model EventType {
|
||||
selectedCalendars SelectedCalendar[]
|
||||
allowReschedulingPastBookings Boolean @default(false)
|
||||
hideOrganizerEmail Boolean @default(false)
|
||||
maxActiveBookingsPerBooker Int?
|
||||
/// @zod.custom(imports.emailSchema)
|
||||
customReplyToEmail String?
|
||||
calVideoSettings CalVideoSettings?
|
||||
|
||||
@@ -662,6 +662,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
||||
workflows: true,
|
||||
bookingFields: true,
|
||||
durationLimits: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
assignAllTeamMembers: true,
|
||||
|
||||
@@ -64,6 +64,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
locations,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
maxActiveBookingsPerBooker,
|
||||
destinationCalendar,
|
||||
customInputs,
|
||||
recurringEvent,
|
||||
@@ -98,6 +99,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
description: true,
|
||||
seatsPerTimeSlot: true,
|
||||
recurringEvent: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
fieldTranslations: {
|
||||
select: {
|
||||
field: true,
|
||||
@@ -259,6 +261,24 @@ 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 && (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 (durationLimits) {
|
||||
const isValid = validateIntervalLimitOrder(durationLimits);
|
||||
if (!isValid)
|
||||
|
||||
Reference in New Issue
Block a user