From 630065ca89111698b689b3eebfe28e95adf6afea Mon Sep 17 00:00:00 2001 From: "Romit." <85230081+romitg2@users.noreply.github.com> Date: Sat, 14 Jun 2025 02:22:22 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Restrict=20same=20email=20to=20create?= =?UTF-8?q?=20more=20than=20'n'=20active=20bookings=20at=E2=80=A6=20(#2174?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/web/public/static/locales/en/common.json | 4 ++ .../features/bookings/lib/handleNewBooking.ts | 9 ++++ .../checkActiveBookingsLimitForBooker.ts | 39 ++++++++++++++ .../handleNewBooking/getEventTypesFromDB.ts | 1 + .../tabs/advanced/EventAdvancedTab.tsx | 1 + .../components/tabs/limits/EventLimitsTab.tsx | 51 +++++++++++++++++++ .../recurring/RecurringEventController.tsx | 11 +++- packages/features/eventtypes/lib/types.ts | 1 + packages/lib/defaultEvents.ts | 1 + packages/lib/server/repository/eventType.ts | 1 + packages/lib/test/builder.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/zod-utils.ts | 1 + .../viewer/eventTypes/update.handler.ts | 20 ++++++++ 16 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts create mode 100644 packages/prisma/migrations/20250609132708_added_bookerbookinglimit/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c52d163382..d050818c41 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 73e0b1397e..f8393e1521 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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; } diff --git a/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts b/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts new file mode 100644 index 0000000000..d521c21a7a --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/checkActiveBookingsLimitForBooker.ts @@ -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; +}; diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 04d420e94d..8571b6838e 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -65,6 +65,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { maxLeadThreshold: true, includeNoShowInRRCalculation: true, minimumBookingNotice: true, + maxActiveBookingsPerBooker: true, userId: true, price: true, currency: true, diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index cfab715163..8c889d7f72 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -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; diff --git a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index 203f0cceaa..7e6aaa8eb1 100644 --- a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -374,6 +374,8 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr const { t, i18n } = useLocale(); const formMethods = useFormContext(); + 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 ); }} /> + { + 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/recurring/RecurringEventController.tsx b/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx index b31b1a4caf..43cc1d4e02 100644 --- a/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx +++ b/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx @@ -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 }); diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 613cd1b7df..5144403447 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -123,6 +123,7 @@ export type FormValues = { scheduleName: string; minimumBookingNotice: number; minimumBookingNoticeInDurationType: number; + maxActiveBookingsPerBooker: number | null; beforeEventBuffer: number; afterEventBuffer: number; slotInterval: number | null; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 90043d5b52..a5af61be57 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -104,6 +104,7 @@ const commons = { requiresConfirmationForFreeEmail: false, requiresBookerEmailVerification: false, bookingLimits: null, + maxActiveBookingsPerBooker: null, durationLimits: null, hidden: false, userId: 0, diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 32436a1112..05b6849c1e 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -530,6 +530,7 @@ export class EventTypeRepository { bookingLimits: true, onlyShowFirstAvailableSlot: true, durationLimits: true, + maxActiveBookingsPerBooker: true, assignAllTeamMembers: true, allowReschedulingPastBookings: true, hideOrganizerEmail: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 6d55339777..53e98f2d05 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -133,6 +133,7 @@ export const buildEventType = (eventType?: Partial): EventType => { schedulingType: null, scheduleId: null, bookingLimits: null, + maxActiveBookingsPerBooker: null, 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 7feb4d07a5..754c3ab639 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -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, diff --git a/packages/prisma/migrations/20250609132708_added_bookerbookinglimit/migration.sql b/packages/prisma/migrations/20250609132708_added_bookerbookinglimit/migration.sql new file mode 100644 index 0000000000..21490ce0ef --- /dev/null +++ b/packages/prisma/migrations/20250609132708_added_bookerbookinglimit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "maxActiveBookingsPerBooker" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b04a4460b3..f19348184e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 5897005e71..3adafc6634 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -662,6 +662,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit { 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)