diff --git a/apps/web/components/settings/GlobalBookingLimitsController.tsx b/apps/web/components/settings/GlobalBookingLimitsController.tsx
new file mode 100644
index 00000000000000..8578f16a9e316c
--- /dev/null
+++ b/apps/web/components/settings/GlobalBookingLimitsController.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useForm, Controller } from "react-hook-form";
+
+import { IntervalLimitsManager } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab";
+import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
+import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
+import { trpc } from "@calcom/trpc/react";
+import type { IntervalLimit } from "@calcom/types/Calendar";
+import classNames from "@calcom/ui/classNames";
+import { Button } from "@calcom/ui/components/button";
+import { Form, SettingsToggle } from "@calcom/ui/components/form";
+import { showToast } from "@calcom/ui/components/toast";
+
+const GlobalBookingLimitsController = ({
+ bookingLimits,
+}: {
+ bookingLimits: IntervalLimit | null | undefined;
+}) => {
+ const { t } = useLocale();
+ const safeBookingLimits = bookingLimits ?? {};
+ const bookingsLimitFormMethods = useForm({
+ defaultValues: {
+ bookingLimits: safeBookingLimits,
+ },
+ });
+
+ const utils = trpc.useUtils();
+ 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");
+ },
+ });
+
+ 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 });
+ };
+
+ return (
+
+ );
+};
+
+export default GlobalBookingLimitsController;
diff --git a/apps/web/modules/settings/my-account/general-view.tsx b/apps/web/modules/settings/my-account/general-view.tsx
index f4fe33c01199bf..0d3b4a0a75d182 100644
--- a/apps/web/modules/settings/my-account/general-view.tsx
+++ b/apps/web/modules/settings/my-account/general-view.tsx
@@ -21,6 +21,7 @@ import { SettingsToggle } from "@calcom/ui/components/form";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
+import GlobalBookingLimitsController from "@components/settings/GlobalBookingLimitsController";
import TravelScheduleModal from "@components/settings/TravelScheduleModal";
export type FormValues = {
@@ -395,6 +396,7 @@ const GeneralView = ({ localeProp, user, travelSchedules, revalidatePage }: Gene
setValue={formMethods.setValue}
existingSchedules={formMethods.getValues("travelSchedules") ?? []}
/>
+
);
};
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 3e734f7ca76e88..e91be619c4f378 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -2673,6 +2673,8 @@
"disconnect_account": "Disconnect connected account",
"disconnect_account_hint": "Disconnecting your connected account will change the way you log in. You will only be able to login to your account using email + password",
"cookie_consent_checkbox": "I agree to the privacy policy and cookie usage",
+ "global_limit_booking_frequency_description": "Limit how many bookings you allow across all your event types.",
+ "failed_to_save_global_settings": "Failed to save global settings",
"make_a_call": "Make a Call",
"skip_rr_assignment_label": "Skip round robin assignment if contact exists in Salesforce",
"skip_rr_description": "URL must contain the contacts email as a parameter ex. ?email=contactEmail",
diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts
index cdb8b7e9a9da81..84b87c94fafd6c 100644
--- a/apps/web/test/utils/bookingScenario/bookingScenario.ts
+++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts
@@ -1485,6 +1485,7 @@ export function getOrganizer({
organizationId,
metadata,
smsLockState,
+ bookingLimits,
completedOnboarding,
username,
locked,
@@ -1502,6 +1503,7 @@ export function getOrganizer({
teams?: InputUser["teams"];
metadata?: userMetadataType;
smsLockState?: SMSLockState;
+ bookingLimits?: IntervalLimit;
completedOnboarding?: boolean;
username?: string;
locked?: boolean;
@@ -1524,6 +1526,7 @@ export function getOrganizer({
profiles: [],
metadata,
smsLockState,
+ bookingLimits,
completedOnboarding,
locked,
};
diff --git a/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts b/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
index 7963c3b5f60902..7e0790a4c2b45c 100644
--- a/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
+++ b/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
@@ -3,10 +3,14 @@ import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSche
import { checkBookingLimits } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
import { checkDurationLimits } from "@calcom/lib/intervalLimits/server/checkDurationLimits";
import { withReporting } from "@calcom/lib/sentryWrapper";
+import prisma from "@calcom/prisma";
import type { NewBookingEventType } from "./getEventTypesFromDB";
-type EventType = Pick;
+type EventType = Pick<
+ NewBookingEventType,
+ "bookingLimits" | "durationLimits" | "id" | "schedule" | "userId" | "schedulingType"
+>;
type InputProps = {
eventType: EventType;
@@ -19,11 +23,11 @@ const _checkBookingAndDurationLimits = async ({
reqBodyStart,
reqBodyRescheduleUid,
}: InputProps) => {
+ const startAsDate = dayjs(reqBodyStart).toDate();
if (
Object.prototype.hasOwnProperty.call(eventType, "bookingLimits") ||
Object.prototype.hasOwnProperty.call(eventType, "durationLimits")
) {
- const startAsDate = dayjs(reqBodyStart).toDate();
if (eventType.bookingLimits && Object.keys(eventType.bookingLimits).length > 0) {
await checkBookingLimits(
eventType.bookingLimits as IntervalLimit,
@@ -42,6 +46,31 @@ const _checkBookingAndDurationLimits = async ({
);
}
}
+
+ // 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 checkBookingLimits(
+ eventTypeUser.bookingLimits as IntervalLimit,
+ startAsDate,
+ eventType.id,
+ reqBodyRescheduleUid,
+ eventType.schedule?.timeZone,
+ { id: eventTypeUser.id, email: eventTypeUser.email },
+ /* isGlobalBookingLimits */ true
+ );
+ }
+ }
};
export const checkBookingAndDurationLimits = withReporting(
diff --git a/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts
index a79f14f0ed79b6..908fb0e6cc6bb7 100644
--- a/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts
+++ b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts
@@ -15,6 +15,7 @@ import { describe, expect, vi, beforeAll } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
+import type { IntervalLimit } from "@calcom/types/Calendar";
import { test } from "@calcom/web/test/fixtures/fixtures";
// Local test runs sometime gets too slow
@@ -36,6 +37,15 @@ const organizer = getOrganizer({
schedules: [TestData.schedules.IstWorkHours],
});
+const organizerWithBookingLimits = (bookingLimits: IntervalLimit) =>
+ getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ bookingLimits,
+ });
+
const otherTeamMembers = [
{
name: "Other Team Member 1",
@@ -199,10 +209,9 @@ describe(
// this is the third team booking of this week for user 101, limit reached
await expect(
- async () =>
- await handleNewBooking({
- bookingData: mockBookingAboveLimit,
- })
+ handleNewBooking({
+ bookingData: mockBookingAboveLimit,
+ })
).rejects.toThrowError("no_available_users_found_error");
});
@@ -284,12 +293,9 @@ describe(
});
// this is the second team booking of this day for user 101, limit reached
- await expect(
- async () =>
- await handleNewBooking({
- bookingData: mockBookingAboveLimit,
- })
- ).rejects.toThrowError("no_available_users_found_error");
+ await expect(handleNewBooking({ bookingData: mockBookingAboveLimit })).rejects.toThrowError(
+ "no_available_users_found_error"
+ );
});
test(`Booking limits per month`, async ({}) => {
@@ -545,6 +551,305 @@ describe(
).rejects.toThrowError("no_available_users_found_error");
});
});
+
+ describe("Individual Booking Limits", () => {
+ test(`Booking limits per day`, async ({}) => {
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: eventLength,
+ length: eventLength,
+ schedulingType: null,
+ userId: 101,
+ },
+ {
+ id: 2,
+ slotInterval: eventLength,
+ length: eventLength,
+ schedulingType: null,
+ userId: 101,
+ },
+ ],
+ bookings: [],
+ organizer: organizerWithBookingLimits({ PER_DAY: 1 }),
+ })
+ );
+
+ const mockBookingData1 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-07T04:30:00.000Z`,
+ end: `2024-08-07T05:00:00.000Z`,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({
+ bookingData: mockBookingData1,
+ });
+
+ expect(createdBooking.responses).toEqual(
+ expect.objectContaining({
+ email: booker.email,
+ name: booker.name,
+ })
+ );
+
+ const mockBookingData2 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-07T08:00:00.000Z`,
+ end: `2024-08-07T08:30:00.000Z`,
+ eventTypeId: 2,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ // this is the second booking of this day for user 101, limit reached
+ await expect(handleNewBooking({ bookingData: mockBookingData2 })).rejects.toThrowError(
+ "booking_limit_reached"
+ );
+ });
+ test(`Booking limits per week`, async ({}) => {
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: eventLength,
+ length: eventLength,
+ schedulingType: null,
+ userId: 101,
+ },
+ {
+ id: 2,
+ slotInterval: eventLength,
+ length: eventLength,
+ schedulingType: null,
+ userId: 101,
+ },
+ ],
+ bookings: [
+ {
+ eventTypeId: 2,
+ userId: 101,
+ status: BookingStatus.ACCEPTED,
+ startTime: `2024-08-05T03:30:00.000Z`,
+ endTime: `2024-08-05T04:00:00.000Z`,
+ },
+ {
+ eventTypeId: 1,
+ userId: 101,
+ status: BookingStatus.ACCEPTED,
+ startTime: `2024-08-07T04:30:00.000Z`,
+ endTime: `2024-08-07T05:00:00.000Z`,
+ },
+ ],
+ organizer: organizerWithBookingLimits({ PER_WEEK: 3 }),
+ })
+ );
+
+ const mockBookingData1 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-08T04:00:00.000Z`,
+ end: `2024-08-08T04:30:00.000Z`,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({ bookingData: mockBookingData1 });
+
+ expect(createdBooking.responses).toEqual(
+ expect.objectContaining({
+ email: booker.email,
+ name: booker.name,
+ })
+ );
+
+ const mockBookingData2 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-09T04:00:00.000Z`,
+ end: `2024-08-09T04:30:00.000Z`,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ // this is the fourth booking of this week for user 101, limit reached
+ await expect(handleNewBooking({ bookingData: mockBookingData2 })).rejects.toThrowError(
+ "booking_limit_reached"
+ );
+ });
+ test(`Booking limits per month`, async ({}) => {
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: eventLength,
+ length: eventLength,
+ schedulingType: null,
+ userId: 101,
+ },
+ {
+ id: 2,
+ slotInterval: eventLength,
+ length: eventLength,
+ schedulingType: null,
+ userId: 101,
+ },
+ ],
+ bookings: [
+ {
+ eventTypeId: 1,
+ userId: 101,
+ status: BookingStatus.ACCEPTED,
+ startTime: `2024-08-03T03:30:00.000Z`,
+ endTime: `2024-08-03T04:00:00.000Z`,
+ },
+ {
+ eventTypeId: 2,
+ userId: 101,
+ status: BookingStatus.ACCEPTED,
+ startTime: `2024-08-22T03:30:00.000Z`,
+ endTime: `2024-08-22T04:00:00.000Z`,
+ },
+ ],
+ organizer: organizerWithBookingLimits({ PER_MONTH: 3 }),
+ })
+ );
+
+ const mockBookingData1 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-29T04:30:00.000Z`,
+ end: `2024-08-29T05:00:00.000Z`,
+ eventTypeId: 1,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({ bookingData: mockBookingData1 });
+
+ expect(createdBooking.responses).toEqual(
+ expect.objectContaining({
+ email: booker.email,
+ name: booker.name,
+ })
+ );
+
+ const mockBookingData2 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-25T04:00:00.000Z`,
+ end: `2024-08-25T04:30:00.000Z`,
+ eventTypeId: 2,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ // this is the fourth booking of this month for user 101, limit reached
+ await expect(handleNewBooking({ bookingData: mockBookingData2 })).rejects.toThrowError(
+ "booking_limit_reached"
+ );
+ });
+ test(`Booking limits per year`, async ({}) => {
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: eventLength,
+ length: eventLength,
+ userId: 101,
+ schedulingType: null,
+ },
+ {
+ id: 2,
+ slotInterval: eventLength,
+ length: eventLength,
+ userId: 101,
+ schedulingType: null,
+ },
+ ],
+ bookings: [
+ {
+ eventTypeId: 1,
+ userId: 101,
+ status: BookingStatus.ACCEPTED,
+ startTime: `2024-02-03T03:30:00.000Z`,
+ endTime: `2024-02-03T04:00:00.000Z`,
+ },
+ ],
+ organizer: organizerWithBookingLimits({ PER_YEAR: 2 }),
+ })
+ );
+
+ const mockBookingData1 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-08-29T04:30:00.000Z`,
+ end: `2024-08-29T05:00:00.000Z`,
+ eventTypeId: 2,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ const createdBooking = await handleNewBooking({ bookingData: mockBookingData1 });
+
+ expect(createdBooking.responses).toEqual(
+ expect.objectContaining({
+ email: booker.email,
+ name: booker.name,
+ })
+ );
+
+ const mockBookingData2 = getMockRequestDataForBooking({
+ data: {
+ start: `2024-11-25T04:00:00.000Z`,
+ end: `2024-11-25T04:30:00.000Z`,
+ eventTypeId: 2,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: "New York" },
+ },
+ },
+ });
+
+ // this is the third booking of this year for user 101, limit reached
+ await expect(handleNewBooking({ bookingData: mockBookingData2 })).rejects.toThrowError(
+ "booking_limit_reached"
+ );
+ });
+ });
},
timeout
);
diff --git a/packages/lib/getUserAvailability.ts b/packages/lib/getUserAvailability.ts
index 2e5076ed459737..8868a0bdc4efae 100644
--- a/packages/lib/getUserAvailability.ts
+++ b/packages/lib/getUserAvailability.ts
@@ -20,6 +20,7 @@ import { HttpError } from "@calcom/lib/http-error";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import {
+ getBusyTimesFromGlobalBookingLimits,
getBusyTimesFromLimits,
getBusyTimesFromTeamLimits,
} from "@calcom/lib/intervalLimits/server/getBusyTimesFromLimits";
@@ -30,8 +31,8 @@ import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
-import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
-import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
+import { EventTypeMetaDataSchema, intervalLimitsType } from "@calcom/prisma/zod-utils";
+import type { EventBusyDetails, IntervalLimit, IntervalLimitUnit } from "@calcom/types/Calendar";
import type { TimeRange } from "@calcom/types/schedule";
import { getBusyTimes } from "./getBusyTimes";
@@ -50,6 +51,7 @@ const availabilitySchema = z
duration: z.number().optional(),
withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(),
+ userBookingLimits: intervalLimitsType.optional(),
bypassBusyCalendarTimes: z.boolean().optional(),
shouldServeCache: z.boolean().optional(),
})
@@ -205,6 +207,7 @@ type GetUserAvailabilityQuery = {
returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean;
shouldServeCache?: boolean;
+ userBookingLimits?: IntervalLimit | null;
};
const _getCurrentSeats = async (
@@ -285,6 +288,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
returnDateOverrides,
bypassBusyCalendarTimes = false,
shouldServeCache,
+ userBookingLimits,
} = availabilitySchema.parse(query);
log.debug(
@@ -392,6 +396,23 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
);
}
+ let busyTimesFromGlobalBookingLimits: EventBusyDetails[] = [];
+ // We are only interested in global booking limits for individual and managed events for which schedulingType is null
+ if (eventType && !eventType.schedulingType) {
+ const globalBookingLimits = parseBookingLimit(userBookingLimits);
+ if (globalBookingLimits) {
+ busyTimesFromGlobalBookingLimits = await getBusyTimesFromGlobalBookingLimits(
+ user.id,
+ user.email,
+ globalBookingLimits,
+ dateFrom.tz(timeZone),
+ dateTo.tz(timeZone),
+ timeZone,
+ initialData?.rescheduleUid ?? undefined
+ );
+ }
+ }
+
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
const getBusyTimesEnd = dateTo.toISOString();
@@ -429,6 +450,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
})),
...busyTimesFromLimits,
...busyTimesFromTeamLimits,
+ ...busyTimesFromGlobalBookingLimits,
];
const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id;
@@ -692,6 +714,7 @@ const calculateOutOfOfficeRanges = (
type GetUsersAvailabilityProps = {
users: (GetAvailabilityUser & {
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
+ bookingLimits?: IntervalLimit | null;
outOfOfficeDays?: GetUserAvailabilityInitialData["outOfOfficeDays"];
})[];
query: Omit;
@@ -706,6 +729,7 @@ const _getUsersAvailability = async ({ users, query, initialData }: GetUsersAvai
...query,
userId: user.id,
username: user.username || "",
+ userBookingLimits: user.bookingLimits,
},
initialData
? {
diff --git a/packages/lib/intervalLimits/server/checkBookingLimits.ts b/packages/lib/intervalLimits/server/checkBookingLimits.ts
index e69f60ce23b8d3..f2bdd35300726e 100644
--- a/packages/lib/intervalLimits/server/checkBookingLimits.ts
+++ b/packages/lib/intervalLimits/server/checkBookingLimits.ts
@@ -1,3 +1,5 @@
+import type { Prisma } from "@prisma/client";
+
import dayjs from "@calcom/dayjs";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
@@ -16,6 +18,8 @@ async function _checkBookingLimits(
eventId: number,
rescheduleUid?: string | undefined,
timeZone?: string | null,
+ user?: { id: number; email: string },
+ isGlobalBookingLimits?: boolean,
includeManagedEvents?: boolean
) {
const parsedBookingLimits = parseBookingLimit(bookingLimits);
@@ -30,6 +34,8 @@ async function _checkBookingLimits(
eventId,
timeZone,
rescheduleUid,
+ user,
+ isGlobalBookingLimits,
includeManagedEvents,
})
);
@@ -52,6 +58,7 @@ async function _checkBookingLimit({
timeZone,
teamId,
user,
+ isGlobalBookingLimits,
includeManagedEvents = false,
}: {
eventStartDate: Date;
@@ -62,6 +69,7 @@ async function _checkBookingLimit({
timeZone?: string | null;
teamId?: number;
user?: { id: number; email: string };
+ isGlobalBookingLimits?: boolean;
includeManagedEvents?: boolean;
}) {
{
@@ -76,6 +84,18 @@ async function _checkBookingLimit({
let bookingsInPeriod;
+ let whereInput: Prisma.BookingWhereInput = {
+ eventTypeId: eventId,
+ };
+ if (user?.id && isGlobalBookingLimits) {
+ whereInput = {
+ userId: user.id,
+ eventType: {
+ schedulingType: null,
+ },
+ };
+ }
+
if (teamId && user) {
bookingsInPeriod = await BookingRepository.getAllAcceptedTeamBookingsOfUser({
user: { id: user.id, email: user.email },
@@ -90,7 +110,7 @@ async function _checkBookingLimit({
bookingsInPeriod = await prisma.booking.count({
where: {
status: BookingStatus.ACCEPTED,
- eventTypeId: eventId,
+ ...whereInput,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: startDate,
diff --git a/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts b/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts
index f87261f34d1fe4..792884a8e5f66f 100644
--- a/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts
+++ b/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts
@@ -1,3 +1,5 @@
+import type { Prisma } from "@prisma/client";
+
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getStartEndDateforLimitCheck } from "@calcom/lib/getBusyTimes";
@@ -7,6 +9,8 @@ import { withReporting } from "@calcom/lib/sentryWrapper";
import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
+import prisma from "@calcom/prisma";
+import { BookingStatus } from "@calcom/prisma/enums";
import type { EventBusyDetails } from "@calcom/types/Calendar";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
@@ -82,6 +86,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
eventTypeId?: number;
teamId?: number;
user?: { id: number; email: string };
+ isGlobalBookingLimits?: boolean;
includeManagedEvents?: boolean;
timeZone?: string | null;
}) => {
@@ -95,6 +100,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
teamId,
user,
rescheduleUid,
+ isGlobalBookingLimits,
includeManagedEvents = false,
timeZone,
} = params;
@@ -120,6 +126,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
teamId,
user,
rescheduleUid,
+ isGlobalBookingLimits,
includeManagedEvents,
timeZone,
});
@@ -268,6 +275,88 @@ const _getBusyTimesFromTeamLimits = async (
return limitManager.getBusyTimes();
};
+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"
+);
+
export const getBusyTimesFromLimits = withReporting(_getBusyTimesFromLimits, "getBusyTimesFromLimits");
export const getBusyTimesFromBookingLimits = withReporting(
diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts
index f4b9fe33ee35e5..6e83491ac8190b 100644
--- a/packages/lib/server/repository/eventType.ts
+++ b/packages/lib/server/repository/eventType.ts
@@ -948,6 +948,7 @@ export class EventTypeRepository {
select: {
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
+ bookingLimits: true,
},
},
},
diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts
index 323ae2b18143c0..261f3ac1b739de 100644
--- a/packages/lib/server/repository/user.ts
+++ b/packages/lib/server/repository/user.ts
@@ -867,6 +867,7 @@ export class UserRepository {
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
profiles: true,
+ bookingLimits: true,
},
});
diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts
index 3860088336a35b..db55ecd1771d4a 100644
--- a/packages/lib/test/builder.ts
+++ b/packages/lib/test/builder.ts
@@ -281,6 +281,7 @@ type UserPayload = Prisma.UserGetPayload<{
movedToProfileId: true;
isPlatformManaged: true;
smsLockState: true;
+ bookingLimits: true;
};
}>;
export const buildUser = >(
@@ -335,6 +336,7 @@ export const buildUser = >(
priority: user?.priority ?? 2,
weight: user?.weight ?? 100,
isPlatformManaged: false,
+ bookingLimits: null,
...user,
};
};
diff --git a/packages/platform/types/organizations/teams/outputs/team.output.ts b/packages/platform/types/organizations/teams/outputs/team.output.ts
index 9a7792dc7a9845..9e0c84bb4eb36b 100644
--- a/packages/platform/types/organizations/teams/outputs/team.output.ts
+++ b/packages/platform/types/organizations/teams/outputs/team.output.ts
@@ -1,4 +1,4 @@
-import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger";
+import { ApiPropertyOptional } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsInt, IsOptional } from "class-validator";
diff --git a/packages/prisma/migrations/20240425092819_add_booking_limits/migration.sql b/packages/prisma/migrations/20240425092819_add_booking_limits/migration.sql
new file mode 100644
index 00000000000000..43f96f2bff8959
--- /dev/null
+++ b/packages/prisma/migrations/20240425092819_add_booking_limits/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "bookingLimits" JSONB;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 30431e937f69bb..087a9d43bac796 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -421,6 +421,9 @@ model User {
creditBalance CreditBalance?
whitelistWorkflows Boolean @default(false)
+ /// @zod.custom(imports.intervalLimitsType)
+ bookingLimits Json?
+
@@unique([email])
@@unique([email, username])
@@unique([username, organizationId])
diff --git a/packages/trpc/server/routers/viewer/me/get.handler.ts b/packages/trpc/server/routers/viewer/me/get.handler.ts
index 939a3ca48cf13f..d59be00364876d 100644
--- a/packages/trpc/server/routers/viewer/me/get.handler.ts
+++ b/packages/trpc/server/routers/viewer/me/get.handler.ts
@@ -1,13 +1,16 @@
import type { Session } from "next-auth";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
+import { getTranslation } from "@calcom/lib/server/i18n";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
-import { userMetadata } from "@calcom/prisma/zod-utils";
+import { userMetadata, intervalLimitsType } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
+import { TRPCError } from "@trpc/server";
+
import type { TGetInputSchema } from "./get.schema";
type MeOptions = {
@@ -106,6 +109,16 @@ export const getHandler = async ({ ctx, input }: MeOptions) => {
},
})) !== null;
+ 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,
+ });
+ }
+
return {
id: user.id,
name: user.name,
@@ -141,6 +154,7 @@ export const getHandler = async ({ ctx, input }: MeOptions) => {
allowSEOIndexing: user.allowSEOIndexing,
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail,
...profileData,
+ bookingLimits: bookingLimitsResult.data,
secondaryEmails,
isPremium: userMetadataPrased?.isPremium,
...(passwordAdded ? { passwordAdded } : {}),
diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
index a8f4cca1962196..9c89f04ac05518 100644
--- a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
+++ b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
@@ -9,6 +9,7 @@ import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billlin
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
+import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/lib/server/checkUsername";
@@ -43,7 +44,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const featuresRepository = new FeaturesRepository();
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
- const { travelSchedules, ...rest } = input;
+ const { travelSchedules, bookingLimits, ...rest } = input;
const secondaryEmails = input?.secondaryEmails || [];
delete input.secondaryEmails;
@@ -221,6 +222,16 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
});
}
+ 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;
+ }
+
const updatedUserSelect = {
select: {
id: true,
diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts b/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts
index c3c75b1b625d81..33bceeb1d25127 100644
--- a/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts
+++ b/packages/trpc/server/routers/viewer/me/updateProfile.schema.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
+import { intervalLimitsType } from "@calcom/prisma/zod-utils";
import { bookerLayouts, userMetadata } from "@calcom/prisma/zod-utils";
export const updateUserMetadataAllowedKeys = z.object({
@@ -49,6 +50,7 @@ export const ZUpdateProfileInputSchema = z.object({
})
)
.optional(),
+ bookingLimits: intervalLimitsType.optional(),
});
export type TUpdateProfileInputSchema = z.infer;