feat: Implement global booking limits for users (v2)#23100
feat: Implement global booking limits for users (v2)#23100
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the WalkthroughThis PR adds global booking limits at the user level. It introduces a settings UI component (GlobalBookingLimitsController) wired into My Account > General, persists limits on the User model (DB migration and schema), exposes them via TRPC get/update handlers with validation, and enforces them in booking flows. Enforcement threads a global-limits flag and user context through interval limit services, adds repository selects to surface bookingLimits, and integrates a global busy-times path. Test utilities and comprehensive tests for per-day/week/month/year global limits are included. English locale strings are added. Assessment against linked issues
Possibly related PRs
✨ Finishing Touches🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Graphite Automations"Add consumer team as reviewer" took an action on this PR • (08/14/25)1 reviewer was added to this PR based on Keith Williams's automation. |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (7)
packages/trpc/server/routers/viewer/me/updateProfile.schema.ts (1)
5-7: Optional: consolidate imports from @calcom/prisma/zod-utilsYou’re importing from the same module twice. Consider consolidating into a single import for readability.
-import { bookerLayouts, userMetadata } from "@calcom/prisma/zod-utils"; -import { intervalLimitsType } from "@calcom/prisma/zod-utils"; +import { bookerLayouts, userMetadata, intervalLimitsType } from "@calcom/prisma/zod-utils";packages/lib/server/repository/user.ts (1)
914-915: Selecting bookingLimits for session payload: good additionIncluding bookingLimits in findUnlockedUserForSession ensures the viewer/me path can surface the data. Keep an eye on other selectors that may need this field depending on usage, but no blocker here.
If bookingLimits becomes commonly needed across repository methods, consider adding it to the shared userSelect or providing a typed helper select to keep consistency.
apps/web/test/utils/bookingScenario/bookingScenario.ts (1)
1510-1510: Surfacing organizer.bookingLimits in tests looks correctThe optional bookingLimits plumbed through getOrganizer is consistent with the IntervalLimit type used elsewhere and will persist via addUsersToDb. No functional issues spotted.
If you want to avoid downstream undefined checks, consider defaulting bookingLimits to {} here (non-blocking).
Also applies to: 1528-1528, 1552-1552
apps/web/components/settings/GlobalBookingLimitsController.tsx (4)
83-89: Also disable Update button when saving to prevent double submissionsYou already show loading; explicitly disabling while pending avoids duplicate submits.
- <Button + <Button color="primary" type="submit" loading={updateProfileMutation.isPending} - disabled={!bookingsLimitFormMethods.formState.dirtyFields.bookingLimits}> + disabled={ + updateProfileMutation.isPending || + !bookingsLimitFormMethods.formState.dirtyFields.bookingLimits + }> {t("update")} </Button>
24-28: Strengthen typing for form valuesProvide a generic to
useFormfor stronger type safety. Since you default to{}when there are no limits, coerce the default value toIntervalLimit.- const bookingsLimitFormMethods = useForm({ + const bookingsLimitFormMethods = useForm<{ bookingLimits: IntervalLimit }>({ defaultValues: { - bookingLimits: safeBookingLimits, + bookingLimits: safeBookingLimits as IntervalLimit, }, });
3-16: Sync form state with updated props after refetchAfter
invalidate(), the parent may pass updatedbookingLimits. The form won't auto-syncdefaultValues. Reset the form when the prop changes (without clobbering unsaved edits).import { useForm, Controller } from "react-hook-form"; +import { useEffect } from "react"; @@ const bookingsLimitFormMethods = useForm({ defaultValues: { bookingLimits: safeBookingLimits, }, }); + // Keep form in sync with server-driven updates (e.g., after invalidate()) + useEffect(() => { + if (!bookingsLimitFormMethods.formState.isDirty) { + bookingsLimitFormMethods.reset({ bookingLimits: safeBookingLimits as IntervalLimit }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookingLimits]);
31-40: Surface server error details when available (fallback to i18n key)Minor UX enhancement: if the server returns a meaningful error message, prefer displaying it; otherwise fall back to the generic localized message.
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({ onSuccess: async () => { await utils.viewer.me.invalidate(); bookingsLimitFormMethods.reset(bookingsLimitFormMethods.getValues()); showToast(t("booking_limits_updated_successfully"), "success"); }, onError: () => { - showToast(t("failed_to_save_global_settings"), "error"); + // Prefer specific server message if available; otherwise, use generic fallback + // Note: adjust based on your TRPC error shape if needed + showToast(t("failed_to_save_global_settings"), "error"); }, });
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these settings in your CodeRabbit configuration.
📒 Files selected for processing (16)
apps/web/components/settings/GlobalBookingLimitsController.tsx(1 hunks)apps/web/modules/settings/my-account/general-view.tsx(2 hunks)apps/web/public/static/locales/en/common.json(1 hunks)apps/web/test/utils/bookingScenario/bookingScenario.ts(3 hunks)packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts(2 hunks)packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts(2 hunks)packages/lib/intervalLimits/server/checkBookingLimits.ts(4 hunks)packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts(5 hunks)packages/lib/server/repository/eventTypeRepository.ts(1 hunks)packages/lib/server/repository/user.ts(1 hunks)packages/lib/test/builder.ts(2 hunks)packages/prisma/migrations/20250814170930_add_user_booking_limits/migration.sql(1 hunks)packages/prisma/schema.prisma(1 hunks)packages/trpc/server/routers/viewer/me/get.handler.ts(3 hunks)packages/trpc/server/routers/viewer/me/updateProfile.handler.ts(3 hunks)packages/trpc/server/routers/viewer/me/updateProfile.schema.ts(1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (8)
packages/trpc/server/routers/viewer/me/updateProfile.schema.ts (1)
packages/prisma/zod-utils.ts (1)
bookerLayouts(54-59)
apps/web/test/utils/bookingScenario/bookingScenario.ts (2)
packages/types/Calendar.d.ts (1)
IntervalLimit(138-138)packages/lib/intervalLimits/intervalLimitSchema.ts (1)
IntervalLimit(4-4)
packages/trpc/server/routers/viewer/me/updateProfile.handler.ts (2)
packages/lib/intervalLimits/validateIntervalLimitOrder.ts (1)
validateIntervalLimitOrder(4-15)packages/platform/libraries/index.ts (2)
getTranslation(72-72)TRPCError(56-56)
apps/web/components/settings/GlobalBookingLimitsController.tsx (4)
apps/web/app/_trpc/trpc.ts (1)
trpc(7-7)packages/lib/intervalLimits/validateIntervalLimitOrder.ts (1)
validateIntervalLimitOrder(4-15)packages/ui/components/form/index.ts (1)
Form(23-23)packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx (1)
IntervalLimitsManager(909-1014)
packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts (2)
packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts (1)
NewBookingEventType(228-228)packages/lib/intervalLimits/intervalLimitSchema.ts (1)
IntervalLimit(4-4)
packages/lib/intervalLimits/server/checkBookingLimits.ts (1)
scripts/prepare-local-for-delegation-credentials-testing.js (1)
user(15-17)
packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts (2)
apps/web/test/utils/bookingScenario/bookingScenario.ts (4)
getOrganizer(1496-1555)TestData(1215-1487)createBookingScenario(954-985)getScenarioData(1557-1643)apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts (1)
getMockRequestDataForBooking(39-53)
packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts (4)
packages/lib/intervalLimits/intervalLimitSchema.ts (1)
IntervalLimit(4-4)packages/lib/getBusyTimes.ts (1)
getStartEndDateforLimitCheck(290-313)packages/lib/intervalLimits/limitManager.ts (1)
LimitManager(11-78)packages/lib/sentryWrapper.ts (1)
withReporting(107-139)
🪛 Biome (2.1.2)
packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts
[error] 558-558: Unexpected empty object pattern.
(lint/correctness/noEmptyPattern)
[error] 624-624: Unexpected empty object pattern.
(lint/correctness/noEmptyPattern)
[error] 703-703: Unexpected empty object pattern.
(lint/correctness/noEmptyPattern)
[error] 782-782: Unexpected empty object pattern.
(lint/correctness/noEmptyPattern)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Tests / Unit
🔇 Additional comments (16)
packages/prisma/schema.prisma (1)
444-444: LGTM!The addition of the
bookingLimitsfield to the User model is correctly implemented with appropriate type and placement.packages/lib/intervalLimits/server/checkBookingLimits.ts (4)
1-2: LGTM!The Prisma type import is properly added to support the new
whereInputtyping.
20-28: LGTM!The method signature update correctly adds optional parameters for global booking limits support.
34-45: LGTM!Parameters are correctly propagated to per-key limit calculations.
88-98: Missing parameter propagation in the conditional branchThe
isGlobalBookingLimitsparameter is defined but never used in the function. It appears you intended to use it in the conditional check but missed adding it.Apply this diff to fix the missing parameter:
- if (user?.id && isGlobalBookingLimits) { + if (user?.id && isGlobalBookingLimits) {Wait, I see the parameter is already being used correctly in line 91. Let me reconsider...
Actually, the code is correct as-is. The
isGlobalBookingLimitsparameter is properly used in the conditional.packages/lib/server/repository/eventTypeRepository.ts (1)
1273-1273: LGTM!The addition of
bookingLimits: trueto the user selection is correctly placed and consistent with the global booking limits feature.packages/prisma/migrations/20250814170930_add_user_booking_limits/migration.sql (1)
1-2: LGTM!The migration correctly adds a nullable JSONB column for storing user booking limits, which aligns with the schema changes.
packages/lib/test/builder.ts (2)
285-285: LGTM!The addition of
bookingLimits: trueto the UserPayload select is correct and properly placed.
340-340: LGTM!The default value of
nullforbookingLimitsis appropriate and consistent with the optional nature of the field in the schema.apps/web/modules/settings/my-account/general-view.tsx (1)
26-26: UI integration looks good; verify top-level bookingLimits is returned and acceptedRendering GlobalBookingLimitsController with bookingLimits={user.bookingLimits} is correct. Ensure:
- viewer.me.get returns bookingLimits in user (server/get.handler.ts).
- updateProfile accepts top-level bookingLimits (schema must include it, see server schema comment) so the controller’s save action persists.
If the schema isn’t updated, saves from the controller will be silently stripped by Zod.
Also applies to: 363-363
apps/web/public/static/locales/en/common.json (1)
2700-2702: Locale entries added appropriatelyThe new strings for the global booking limits UI are clear and consistent with existing naming. No issues found.
packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts (1)
73-74: Verify timers are mocked before setSystemTimevi.setSystemTime typically requires vi.useFakeTimers(). If setupAndTeardown doesn’t enable fake timers, setSystemTime will be ignored.
If not already done in setupAndTeardown, add:
beforeAll(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-08-05")); }); afterAll(() => { vi.useRealTimers(); });packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts (3)
11-11: Importing BookingStatus for global checks is appropriateThis aligns with filtering accepted bookings when computing busy times for global limits.
76-104: Threading isGlobalBookingLimits through yearly checks looks goodThe additional flag is cleanly passed into checkBookingLimit for the optimized yearly path. Non-yearly paths continue to use in-memory counting.
356-360: New public API getBusyTimesFromGlobalBookingLimits is cohesive with existing exportsWrapping with withReporting matches established patterns. No issues.
apps/web/components/settings/GlobalBookingLimitsController.tsx (1)
52-97: Overall: solid integration and UX wiringNice job threading validation, TRPC mutation, and settings UI together. The auto-submit on toggle and sort/validation alignment with IntervalLimitsManager are consistent with the rest of the app.
| const handleSubmit = async (values: { bookingLimits: IntervalLimit }) => { | ||
| const { bookingLimits } = values; | ||
| const parsedBookingLimits = parseBookingLimit(bookingLimits) || {}; | ||
| if (bookingLimits) { | ||
| const isValid = validateIntervalLimitOrder(parsedBookingLimits); | ||
| if (!isValid) throw new Error(t("event_setup_booking_limits_error")); | ||
| } | ||
| updateProfileMutation.mutate({ ...values, bookingLimits: parsedBookingLimits }); | ||
| }; |
There was a problem hiding this comment.
Avoid throwing in submit handler; validate, toast, and await mutateAsync to prevent unhandled rejections
Throwing inside handleSubmit can surface as an unhandled promise rejection (especially when invoked programmatically from the toggle), and the user won’t see any feedback. Also, mutate is not awaited which can lead to race conditions with rapid interactions.
Refactor to:
- Use toast instead of throwing on validation failure.
- Await
mutateAsyncwith try/catch (errors already surfaced viaonError).
- const handleSubmit = async (values: { bookingLimits: IntervalLimit }) => {
- const { bookingLimits } = values;
- const parsedBookingLimits = parseBookingLimit(bookingLimits) || {};
- if (bookingLimits) {
- const isValid = validateIntervalLimitOrder(parsedBookingLimits);
- if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
- }
- updateProfileMutation.mutate({ ...values, bookingLimits: parsedBookingLimits });
- };
+ const handleSubmit = async (values: { bookingLimits: IntervalLimit }) => {
+ const parsedBookingLimits = parseBookingLimit(values.bookingLimits);
+ if (parsedBookingLimits && !validateIntervalLimitOrder(parsedBookingLimits)) {
+ showToast(t("event_setup_booking_limits_error"), "error");
+ return;
+ }
+ try {
+ await updateProfileMutation.mutateAsync({
+ ...values,
+ bookingLimits: parsedBookingLimits ?? {},
+ });
+ } catch {
+ // Handled via onError
+ }
+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleSubmit = async (values: { bookingLimits: IntervalLimit }) => { | |
| const { bookingLimits } = values; | |
| const parsedBookingLimits = parseBookingLimit(bookingLimits) || {}; | |
| if (bookingLimits) { | |
| const isValid = validateIntervalLimitOrder(parsedBookingLimits); | |
| if (!isValid) throw new Error(t("event_setup_booking_limits_error")); | |
| } | |
| updateProfileMutation.mutate({ ...values, bookingLimits: parsedBookingLimits }); | |
| }; | |
| const handleSubmit = async (values: { bookingLimits: IntervalLimit }) => { | |
| const parsedBookingLimits = parseBookingLimit(values.bookingLimits); | |
| if (parsedBookingLimits && !validateIntervalLimitOrder(parsedBookingLimits)) { | |
| showToast(t("event_setup_booking_limits_error"), "error"); | |
| return; | |
| } | |
| try { | |
| await updateProfileMutation.mutateAsync({ | |
| ...values, | |
| bookingLimits: parsedBookingLimits ?? {}, | |
| }); | |
| } catch { | |
| // Handled via onError | |
| } | |
| }; |
🤖 Prompt for AI Agents
In apps/web/components/settings/GlobalBookingLimitsController.tsx around lines
42 to 50, do not throw an Error inside the submit handler; instead, show a
user-facing toast when validation fails and stop submission, and replace
updateProfileMutation.mutate with await updateProfileMutation.mutateAsync
wrapped in try/catch so we don't create unhandled promise rejections or race
conditions. Specifically: after parsing bookingLimits, if
validateIntervalLimitOrder returns false call the toast error
(t("event_setup_booking_limits_error")) and return; otherwise await
updateProfileMutation.mutateAsync({ ...values, bookingLimits:
parsedBookingLimits }) inside a try/catch and let existing onError handle server
errors (or optionally show toast in catch).
| onCheckedChange={(active) => { | ||
| if (active) { | ||
| bookingsLimitFormMethods.setValue("bookingLimits", { | ||
| PER_DAY: 1, | ||
| }); | ||
| } else { | ||
| bookingsLimitFormMethods.setValue("bookingLimits", {}); | ||
| } | ||
| handleSubmit(bookingsLimitFormMethods.getValues()); | ||
| }} |
There was a problem hiding this comment.
Make toggle submission awaitable and race-safe; mark updates as dirty
The toggle currently fires handleSubmit(getValues()) without awaiting, which can cause unhandled rejections and out-of-order writes on rapid toggles. It also doesn’t mark the field dirty, making UI state inconsistent with other edits.
- onCheckedChange={(active) => {
- if (active) {
- bookingsLimitFormMethods.setValue("bookingLimits", {
- PER_DAY: 1,
- });
- } else {
- bookingsLimitFormMethods.setValue("bookingLimits", {});
- }
- handleSubmit(bookingsLimitFormMethods.getValues());
- }}
+ onCheckedChange={async (active) => {
+ if (updateProfileMutation.isPending) return;
+ if (active) {
+ bookingsLimitFormMethods.setValue(
+ "bookingLimits",
+ { PER_DAY: 1 },
+ { shouldDirty: true }
+ );
+ } else {
+ bookingsLimitFormMethods.setValue("bookingLimits", {}, { shouldDirty: true });
+ }
+ await bookingsLimitFormMethods.handleSubmit(handleSubmit)();
+ }}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| onCheckedChange={(active) => { | |
| if (active) { | |
| bookingsLimitFormMethods.setValue("bookingLimits", { | |
| PER_DAY: 1, | |
| }); | |
| } else { | |
| bookingsLimitFormMethods.setValue("bookingLimits", {}); | |
| } | |
| handleSubmit(bookingsLimitFormMethods.getValues()); | |
| }} | |
| onCheckedChange={async (active) => { | |
| if (updateProfileMutation.isPending) return; | |
| if (active) { | |
| bookingsLimitFormMethods.setValue( | |
| "bookingLimits", | |
| { PER_DAY: 1 }, | |
| { shouldDirty: true } | |
| ); | |
| } else { | |
| bookingsLimitFormMethods.setValue("bookingLimits", {}, { shouldDirty: true }); | |
| } | |
| await bookingsLimitFormMethods.handleSubmit(handleSubmit)(); | |
| }} |
🤖 Prompt for AI Agents
In apps/web/components/settings/GlobalBookingLimitsController.tsx around lines
64-73, make the toggle handler async, await the submission, mark the form value
dirty, and guard against concurrent/racy submissions: change the onCheckedChange
callback to async, call bookingsLimitFormMethods.setValue("bookingLimits",
value, { shouldDirty: true }) when updating, then await
handleSubmit(bookingsLimitFormMethods.getValues()) inside a try/catch to surface
errors, and add a simple race guard (e.g., incrementing submissionRef captured
locally before awaiting and verify it still matches after await or use a
submitting boolean) to prevent out-of-order writes on rapid toggles.
| <IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} /> | ||
| </div> |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Disable IntervalLimitsManager when the global toggle is off
With bookingLimits as {}, IntervalLimitsManager still renders the “Add limit” button, allowing edits while the toggle is off. Disable the manager when not checked to prevent inconsistent UX.
- <IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
+ <IntervalLimitsManager
+ propertyName="bookingLimits"
+ defaultLimit={1}
+ step={1}
+ disabled={!isChecked}
+ />📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} /> | |
| </div> | |
| <IntervalLimitsManager | |
| propertyName="bookingLimits" | |
| defaultLimit={1} | |
| step={1} | |
| disabled={!isChecked} | |
| /> | |
| </div> |
🤖 Prompt for AI Agents
In apps/web/components/settings/GlobalBookingLimitsController.tsx around lines
80-81, IntervalLimitsManager is still interactive when the global booking limits
toggle is off; update the render to disable user interaction by passing a
disabled prop (e.g. disabled={!bookingLimitsEnabled}) or conditionally render a
non-interactive placeholder when the toggle is off so the “Add limit” button and
edits are inactive while the global toggle is unchecked.
| // We are only interested in global booking limits for individual and managed events for which schedulingType is null | ||
| if (eventType.userId && !eventType.schedulingType) { | ||
| const eventTypeUser = await prisma.user.findUnique({ | ||
| where: { | ||
| id: eventType.userId, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| email: true, | ||
| bookingLimits: true, | ||
| }, | ||
| }); | ||
| if (eventTypeUser?.bookingLimits && Object.keys(eventTypeUser.bookingLimits).length > 0) { | ||
| await await this.dependencies.checkBookingLimitsService.checkBookingLimits( | ||
| eventTypeUser.bookingLimits as IntervalLimit, | ||
| startAsDate, | ||
| eventType.id, | ||
| reqBodyRescheduleUid, | ||
| eventType.schedule?.timeZone, | ||
| { id: eventTypeUser.id, email: eventTypeUser.email }, | ||
| /* isGlobalBookingLimits */ true | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Double await and misleading comment
- Duplicate await at Line 70 causes unnecessary syntax noise (harmless at runtime but should be removed).
- The comment suggests including “managed” events with schedulingType null; managed child events have schedulingType null, but managed parent is SchedulingType.MANAGED. Consider clarifying.
Apply this diff:
- // We are only interested in global booking limits for individual and managed events for which schedulingType is null
+ // Apply global booking limits for event types with a direct userId and schedulingType === null (individual + managed child events)
if (eventType.userId && !eventType.schedulingType) {
const eventTypeUser = await prisma.user.findUnique({
@@
- if (eventTypeUser?.bookingLimits && Object.keys(eventTypeUser.bookingLimits).length > 0) {
- await await this.dependencies.checkBookingLimitsService.checkBookingLimits(
+ if (eventTypeUser?.bookingLimits && Object.keys(eventTypeUser.bookingLimits).length > 0) {
+ await this.dependencies.checkBookingLimitsService.checkBookingLimits(
eventTypeUser.bookingLimits as IntervalLimit,
startAsDate,
eventType.id,
reqBodyRescheduleUid,
eventType.schedule?.timeZone,
{ id: eventTypeUser.id, email: eventTypeUser.email },
/* isGlobalBookingLimits */ true
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // We are only interested in global booking limits for individual and managed events for which schedulingType is null | |
| if (eventType.userId && !eventType.schedulingType) { | |
| const eventTypeUser = await prisma.user.findUnique({ | |
| where: { | |
| id: eventType.userId, | |
| }, | |
| select: { | |
| id: true, | |
| email: true, | |
| bookingLimits: true, | |
| }, | |
| }); | |
| if (eventTypeUser?.bookingLimits && Object.keys(eventTypeUser.bookingLimits).length > 0) { | |
| await await this.dependencies.checkBookingLimitsService.checkBookingLimits( | |
| eventTypeUser.bookingLimits as IntervalLimit, | |
| startAsDate, | |
| eventType.id, | |
| reqBodyRescheduleUid, | |
| eventType.schedule?.timeZone, | |
| { id: eventTypeUser.id, email: eventTypeUser.email }, | |
| /* isGlobalBookingLimits */ true | |
| ); | |
| } | |
| } | |
| // Apply global booking limits for event types with a direct userId and schedulingType === null (individual + managed child events) | |
| if (eventType.userId && !eventType.schedulingType) { | |
| const eventTypeUser = await prisma.user.findUnique({ | |
| where: { | |
| id: eventType.userId, | |
| }, | |
| select: { | |
| id: true, | |
| email: true, | |
| bookingLimits: true, | |
| }, | |
| }); | |
| if (eventTypeUser?.bookingLimits && Object.keys(eventTypeUser.bookingLimits).length > 0) { | |
| await this.dependencies.checkBookingLimitsService.checkBookingLimits( | |
| eventTypeUser.bookingLimits as IntervalLimit, | |
| startAsDate, | |
| eventType.id, | |
| reqBodyRescheduleUid, | |
| eventType.schedule?.timeZone, | |
| { id: eventTypeUser.id, email: eventTypeUser.email }, | |
| /* isGlobalBookingLimits */ true | |
| ); | |
| } | |
| } |
🤖 Prompt for AI Agents
In
packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
around lines 57 to 80, remove the duplicate await before
this.dependencies.checkBookingLimits (the extra await is redundant) and update
the preceding comment to accurately describe which event types are covered —
clarify that schedulingType === null targets individual events and managed child
events (not managed parent events which have SchedulingType.MANAGED) so the
comment isn’t misleading.
| const organizerWithBookingLimits = (bookingLimits: IntervalLimit) => | ||
| getOrganizer({ | ||
| name: "Organizer", | ||
| email: "organizer@example.com", | ||
| id: 101, | ||
| schedules: [TestData.schedules.IstWorkHours], | ||
| bookingLimits, | ||
| }); | ||
|
|
There was a problem hiding this comment.
Type import for IntervalLimit is missing
organizerWithBookingLimits types its parameter as IntervalLimit but the type isn’t imported in this file, which will fail type-checking unless you rely on an ambient global (unlikely/desirable).
Add this import near the top:
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";🤖 Prompt for AI Agents
In packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts
around lines 39 to 47, the parameter type IntervalLimit is used but not
imported; add the type-only import near the top of the file: import type {
IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; so the
test file can type-check correctly.
| const _getBusyTimesFromGlobalBookingLimits = async ( | ||
| userId: number, | ||
| userEmail: string, | ||
| bookingLimits: IntervalLimit, | ||
| dateFrom: Dayjs, | ||
| dateTo: Dayjs, | ||
| timeZone: string, | ||
| rescheduleUid?: string | ||
| ) => { | ||
| const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck( | ||
| dateFrom.toISOString(), | ||
| dateTo.toISOString(), | ||
| bookingLimits | ||
| ); | ||
|
|
||
| const where: Prisma.BookingWhereInput = { | ||
| userId, | ||
| status: BookingStatus.ACCEPTED, | ||
| startTime: { | ||
| gte: limitDateFrom.toDate(), | ||
| }, | ||
| endTime: { | ||
| lte: limitDateTo.toDate(), | ||
| }, | ||
| eventType: { | ||
| schedulingType: null, | ||
| }, | ||
| }; | ||
|
|
||
| if (rescheduleUid) { | ||
| where.NOT = { | ||
| uid: rescheduleUid, | ||
| }; | ||
| } | ||
|
|
||
| const bookings = await prisma.booking.findMany({ | ||
| where, | ||
| select: { | ||
| id: true, | ||
| startTime: true, | ||
| endTime: true, | ||
| eventType: { | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }, | ||
| title: true, | ||
| userId: true, | ||
| }, | ||
| }); | ||
|
|
||
| const busyTimes = bookings.map(({ id, startTime, endTime, eventType, title, userId }) => ({ | ||
| start: dayjs(startTime).toDate(), | ||
| end: dayjs(endTime).toDate(), | ||
| title, | ||
| source: `eventType-${eventType?.id}-booking-${id}`, | ||
| userId, | ||
| })); | ||
|
|
||
| const limitManager = new LimitManager(); | ||
|
|
||
| await getBusyTimesFromBookingLimits({ | ||
| bookings: busyTimes, | ||
| bookingLimits, | ||
| dateFrom, | ||
| dateTo, | ||
| eventTypeId: undefined, | ||
| limitManager, | ||
| rescheduleUid, | ||
| user: { id: userId, email: userEmail }, | ||
| isGlobalBookingLimits: true, | ||
| timeZone, | ||
| }); | ||
|
|
||
| return limitManager.getBusyTimes(); | ||
| }; | ||
|
|
||
| export const getBusyTimesFromGlobalBookingLimits = withReporting( | ||
| _getBusyTimesFromGlobalBookingLimits, | ||
| "getBusyTimesFromGlobalBookingLimits" | ||
| ); | ||
|
|
There was a problem hiding this comment.
Two compile-time issues: missing Prisma type import and missing getStartEndDateforLimitCheck reference
- Prisma.BookingWhereInput is used without importing Prisma.
- getStartEndDateforLimitCheck is referenced but not imported; elsewhere you use the BusyTimes service wrapper. Reuse it here for consistency.
Apply this diff within the changed block:
- const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck(
- dateFrom.toISOString(),
- dateTo.toISOString(),
- bookingLimits
- );
+ const busyTimesService = getBusyTimesService();
+ const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck(
+ dateFrom.toISOString(),
+ dateTo.toISOString(),
+ bookingLimits
+ );And add the missing type import at the top of the file:
import type { Prisma } from "@prisma/client";| const bookingLimitsResult = intervalLimitsType.safeParse(user.bookingLimits || {}); | ||
| if (!bookingLimitsResult.success) { | ||
| const t = await getTranslation(user.locale ?? "en", "common"); | ||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: t("event_setup_booking_limits_error"), | ||
| cause: bookingLimitsResult.error, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Missing imports for intervalLimitsType and TRPCError cause build-time errors
intervalLimitsType and TRPCError are referenced but not imported.
Apply this import addition at the top of the file:
import { TRPCError } from "@trpc/server";
import { intervalLimitsType } from "@calcom/lib/intervalLimits/intervalLimitSchema";🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/me/get.handler.ts around lines 109 to
117, the code references intervalLimitsType and TRPCError but they are not
imported; add the missing imports at the top of the file: import TRPCError from
@trpc/server (named import) and import intervalLimitsType from
@calcom/lib/intervalLimits/intervalLimitSchema so the safeParse and thrown
TRPCError compile correctly.
🛠️ Refactor suggestion
Prefer graceful fallback over throwing from GET handler on invalid stored bookingLimits
Rejecting the entire me payload with BAD_REQUEST for malformed bookingLimits can break settings views and unrelated consumers. Since updateProfile validates on write, consider sanitizing here and returning {} (and optionally logging/telemetry) instead of throwing.
Here’s a minimal refactor within the changed lines:
- const bookingLimitsResult = intervalLimitsType.safeParse(user.bookingLimits || {});
- if (!bookingLimitsResult.success) {
- const t = await getTranslation(user.locale ?? "en", "common");
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: t("event_setup_booking_limits_error"),
- cause: bookingLimitsResult.error,
- });
- }
+ const parse = intervalLimitsType.safeParse(user.bookingLimits || {});
+ if (!parse.success) {
+ // Avoid breaking the profile fetch. Log for observability and sanitize.
+ const t = await getTranslation(user.locale ?? "en", "common");
+ // eslint-disable-next-line no-console -- replace with your logger if available here
+ console.warn("Invalid user.bookingLimits; returning empty limits", parse.error);
+ }And later in the return payload:
- bookingLimits: bookingLimitsResult.data,
+ bookingLimits: parse.success ? parse.data : {},Also applies to: 154-154
🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/me/get.handler.ts around lines 109-117
(also apply same change at line ~154), the handler currently throws a
BAD_REQUEST when stored bookingLimits fails zod parsing; instead, silently
sanitize and return an empty object so the GET remains resilient: replace the
throw path by assigning bookingLimits = bookingLimitsResult.success ?
bookingLimitsResult.data : {} (and add an optional debug log/telemetry call when
parse fails), remove the TRPCError throw, and ensure the sanitized bookingLimits
value is used in the response payload later (also update the similar check at
line ~154).
| const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification"); | ||
|
|
||
| const { travelSchedules, ...rest } = input; | ||
| const { travelSchedules, bookingLimits, ...rest } = input; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Type/schema mismatch: bookingLimits not declared in input schema but destructured here
You destructure bookingLimits from input, but the current ZUpdateProfileInputSchema doesn’t expose it at the top level (it’s mistakenly in metadata allowed keys). Zod will strip unknown keys by default, so bookingLimits will always be undefined here and never persist.
Fix by adding bookingLimits: intervalLimitsType.nullable().optional() to ZUpdateProfileInputSchema and removing it from updateUserMetadataAllowedKeys. See schema file comment for the diff.
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Searching for ZUpdateProfileInputSchema..."
rg -n --hidden --no-ignore-vcs "ZUpdateProfileInputSchema" || true
echo
echo "Searching for updateUserMetadataAllowedKeys..."
rg -n --hidden --no-ignore-vcs "updateUserMetadataAllowedKeys" || true
echo
echo "Searching for bookingLimits occurrences..."
rg -n --hidden --no-ignore-vcs "bookingLimits" || true
echo
echo "Searching for intervalLimitsType..."
rg -n --hidden --no-ignore-vcs "intervalLimitsType" || true
echo
echo "Showing handler file (packages/trpc/server/routers/viewer/me/updateProfile.handler.ts) if present:"
if [ -f packages/trpc/server/routers/viewer/me/updateProfile.handler.ts ]; then
sed -n '1,240p' packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
else
echo "File not found: packages/trpc/server/routers/viewer/me/updateProfile.handler.ts"
fi
echo
echo "Printing files that define ZUpdateProfileInputSchema (first 240 lines):"
files=$(rg -l --hidden --no-ignore-vcs "ZUpdateProfileInputSchema" || true)
if [ -n "$files" ]; then
for f in $files; do
echo "---- $f ----"
sed -n '1,240p' "$f" || true
done
else
echo "No files found containing ZUpdateProfileInputSchema"
fi
echo
echo "Printing files that reference updateUserMetadataAllowedKeys (first 240 lines):"
files2=$(rg -l --hidden --no-ignore-vcs "updateUserMetadataAllowedKeys" || true)
if [ -n "$files2" ]; then
for f in $files2; do
echo "---- $f ----"
sed -n '1,240p' "$f" || true
done
else
echo "No files found containing updateUserMetadataAllowedKeys"
fi
echo
echo "Done."Length of output: 62982
Fix: Add bookingLimits to ZUpdateProfileInputSchema and remove it from updateUserMetadataAllowedKeys
bookingLimits is destructured from input in updateProfile.handler but currently only allowed inside updateUserMetadataAllowedKeys (not at the top level of ZUpdateProfileInputSchema). Zod strips unknown keys by default, so bookingLimits will always be undefined here and never persisted.
Files to change:
- packages/trpc/server/routers/viewer/me/updateProfile.handler.ts — destructures bookingLimits from input:
- const { travelSchedules, bookingLimits, ...rest } = input; (around line ~47)
- packages/trpc/server/routers/viewer/me/updateProfile.schema.ts — bookingLimits is currently inside updateUserMetadataAllowedKeys and must be moved to the top-level input schema.
Suggested changes (updateProfile.schema.ts):
- Remove bookingLimits from updateUserMetadataAllowedKeys:
-
- bookingLimits: intervalLimitsType.optional(),
-
- Add bookingLimits to ZUpdateProfileInputSchema (match teams schema):
-
- bookingLimits: intervalLimitsType.optional(),
-
Example diff (conceptual):
--- a/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts
@@
export const updateUserMetadataAllowedKeys = z.object({
sessionTimeout: z.number().optional(), // Minutes
defaultBookerLayouts: bookerLayouts.optional(),
- bookingLimits: intervalLimitsType.optional(),
});
+});
@@
export const ZUpdateProfileInputSchema = z.object({
username: z.string().optional(),
- bookingLimits: intervalLimitsType.optional(),
name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).optional(),
...
This will ensure bookingLimits survives Zod parsing and is available to updateProfile.handler (and avoid duplicated/incorrect placement in metadata).
🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/me/updateProfile.handler.ts around line
47, bookingLimits is being destructured from input but will be undefined because
bookingLimits is not part of the top-level ZUpdateProfileInputSchema; update
packages/trpc/server/routers/viewer/me/updateProfile.schema.ts to remove
bookingLimits from updateUserMetadataAllowedKeys and add bookingLimits:
intervalLimitsType.optional() to ZUpdateProfileInputSchema (matching the teams
schema), ensuring intervalLimitsType is imported if needed so the handler's
destructuring receives a real value after Zod parsing.
| if (bookingLimits) { | ||
| const isValid = validateIntervalLimitOrder(bookingLimits); | ||
| if (!isValid) { | ||
| const t = await getTranslation(locale, "common"); | ||
| throw new TRPCError({ code: "BAD_REQUEST", message: t("event_setup_booking_limits_error") }); | ||
| } | ||
|
|
||
| data.bookingLimits = bookingLimits; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Support clearing limits and validate only when present
The current guard if (bookingLimits) prevents clearing existing limits and skips assignment when null is intended. Also, validation should run only when bookingLimits is a non-null object.
Apply this diff:
- if (bookingLimits) {
- const isValid = validateIntervalLimitOrder(bookingLimits);
- if (!isValid) {
- const t = await getTranslation(locale, "common");
- throw new TRPCError({ code: "BAD_REQUEST", message: t("event_setup_booking_limits_error") });
- }
-
- data.bookingLimits = bookingLimits;
- }
+ // bookingLimits can be undefined (not changed), null (clear), or a filled object (set/update)
+ if (bookingLimits !== undefined) {
+ if (bookingLimits === null) {
+ data.bookingLimits = null;
+ } else {
+ const isValid = validateIntervalLimitOrder(bookingLimits);
+ if (!isValid) {
+ const t = await getTranslation(locale, "common");
+ throw new TRPCError({ code: "BAD_REQUEST", message: t("event_setup_booking_limits_error") });
+ }
+ data.bookingLimits = bookingLimits;
+ }
+ }Note: This assumes the schema is updated to intervalLimitsType.nullable().optional() so null is accepted.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (bookingLimits) { | |
| const isValid = validateIntervalLimitOrder(bookingLimits); | |
| if (!isValid) { | |
| const t = await getTranslation(locale, "common"); | |
| throw new TRPCError({ code: "BAD_REQUEST", message: t("event_setup_booking_limits_error") }); | |
| } | |
| data.bookingLimits = bookingLimits; | |
| } | |
| // bookingLimits can be undefined (not changed), null (clear), or a filled object (set/update) | |
| if (bookingLimits !== undefined) { | |
| if (bookingLimits === null) { | |
| data.bookingLimits = null; | |
| } else { | |
| const isValid = validateIntervalLimitOrder(bookingLimits); | |
| if (!isValid) { | |
| const t = await getTranslation(locale, "common"); | |
| throw new TRPCError({ code: "BAD_REQUEST", message: t("event_setup_booking_limits_error") }); | |
| } | |
| data.bookingLimits = bookingLimits; | |
| } | |
| } |
🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/me/updateProfile.handler.ts around lines
225 to 233, change the presence check so null is allowed to clear existing
limits and validation only runs for non-null objects: replace the truthy guard
with an undefined check (e.g. if (bookingLimits !== undefined) { if
(bookingLimits !== null) { validateIntervalLimitOrder(bookingLimits) and throw
on invalid } data.bookingLimits = bookingLimits } ), ensuring assignment occurs
for null to clear limits; this assumes the schema was updated to allow
nullable().optional() for bookingLimits.
| import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; | ||
| import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; | ||
| import { bookerLayouts, userMetadata } from "@calcom/prisma/zod-utils"; | ||
| import { intervalLimitsType } from "@calcom/prisma/zod-utils"; |
There was a problem hiding this comment.
🛠️ Refactor suggestion
bookingLimits belongs in the top-level input schema, not in metadata allowed keys
Right now, bookingLimits is added to updateUserMetadataAllowedKeys (metadata cleaner) but not to ZUpdateProfileInputSchema. TRPC/Zod will strip unknown top-level keys by default, so updateProfile.handler will never receive bookingLimits. Also, storing bookingLimits in metadata is misleading given there’s a dedicated column on User.
- Move bookingLimits to ZUpdateProfileInputSchema as a top-level optional, nullable field.
- Remove bookingLimits from updateUserMetadataAllowedKeys to avoid duplication.
Apply within this file:
export const updateUserMetadataAllowedKeys = z.object({
sessionTimeout: z.number().optional(), // Minutes
defaultBookerLayouts: bookerLayouts.optional(),
- bookingLimits: intervalLimitsType.optional(),
});And add to the top-level input schema (outside the selected lines; illustrative snippet):
export const ZUpdateProfileInputSchema = z.object({
// ...existing fields
metadata: userMetadata.optional(),
// add this:
bookingLimits: intervalLimitsType.nullable().optional(),
// ...rest
});This aligns the schema with the handler which expects bookingLimits at the top level and allows clearing it by sending null.
Also applies to: 11-11
🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/me/updateProfile.schema.ts around lines 6
and 11, move bookingLimits out of updateUserMetadataAllowedKeys and add it to
the top-level ZUpdateProfileInputSchema as an optional nullable field using
intervalLimitsType (bookingLimits: intervalLimitsType.nullable().optional()).
Remove bookingLimits from the metadata allowed-keys array so it’s no longer
treated as metadata. Ensure the top-level schema includes bookingLimits before
export so the handler receives it and can be cleared by sending null.
…cal.com into booking-global-limits-v2
What does this PR do?
Fixes #8985
https://www.loom.com/share/7db5ef02bc0746419c083d38998a6a48?sid=0e487055-5f25-48ae-a634-30e9da88dd7f
Type of change
Mandatory Tasks