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:
Romit.
2025-06-13 20:52:22 +00:00
committed by GitHub
co-authored by Joe Au-Yeung
parent ddf5717992
commit 630065ca89
16 changed files with 143 additions and 2 deletions
@@ -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;
+1
View File
@@ -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,
+1
View File
@@ -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;
+1
View File
@@ -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?
+1
View File
@@ -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)